Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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

Base URL

https://<node_host>/

Content Type

All requests and responses use application/json.

Authentication

OperationMethod
CommitSchnorr or ECDSA signature over commit hash (per alg; see Signature Schemes)
QuerySession token (see Session)
PullSession token
WebSocket QuerySession token
WebSocket CommitSchnorr or ECDSA signature (per alg)

Endpoints Summary

Enclave API (all enclaves):
MethodPathDescription
POST/Submit commit, query, or pull request
WS/Real-time subscriptions
GET/enclaves/:id/snapshotDownload complete enclave state (.enc) — see Snapshot Endpoints
POST/enclaves/:id/restoreBootstrap enclave from a .enc payload — see Snapshot Endpoints
Node Bootstrap (legacy):
MethodPathDescription
POST/create-enclaveLegacy 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:
MethodPathAccessDescription
GET/:enclave/sthPublicCurrent signed tree head
GET/:enclave/consistencyPublicCT consistency proof
POST/inclusionRCT inclusion proof
POST/bundleRBundle membership proof
POST/stateRSMT state proof
POST/state-batchRBatched SMT state proofs
Registry DataView API (Registry enclave only):
MethodPathDescription
GET/nodes/:seq_pubResolve node by public key
GET/enclaves/:enclave_idResolve enclave → enclave record + hosting node
GET/identity/:id_pubResolve 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 == expected

Clock skew tolerance (±60 seconds) allows clients with slightly-off clocks to connect.

Curve Parameters:

All EC arithmetic uses secp256k1. The curve order is:

n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

All 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.

Client Derivation:
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.

Node Verification:
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) == from

The 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 sessionDelegated session
Identity bindingintrinsic (token is a Schnorr signature by from)explicit (ecrecover(session_auth) == from)
Recoverable from token alonenoyes (via ecrecover)
Self-contained in the 68-byte tokenyesno (auth travels with the request)
Requires Schnorr signingyesno (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 * G
Design Rationale:

The 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:
PropertyProtected?Reason
Cross-session reuseDifferent session_pub → different t → different signer
Cross-enclave reuseDifferent enclave → different t → different signer
Cross-node reuseDifferent seq_pub → different t → different signer
Same session + enclaveSame signerIntentional — enables session continuity

No additional replay protection is needed; the derivation inputs guarantee uniqueness.

Session Properties

PropertyValue
Max expiry7200 seconds (2 hours)
Timestamp unitSeconds (for uint32 compactness; API timestamps use milliseconds)
ReusableYes, until expiry
Per-node signerYes (different ECDH per node)
Multi-keyOne 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 roots

The 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 reject short-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

HookTriggerRejects when
BearerAuthorization: Bearer <SHARED_SECRET> headerheader missing or token != configured secret
ProvisionerSigX-Provisioner-Sig + X-Provisioner-Ts headerssig invalid, ts skewed, or signer not allowlisted
ParseCommitSigner(no headers — reads commit.from_pub)from_pub not in allowlist
RateLimitByPubper-pubkey counterrate 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:

  1. Hosted gating — the hosting plane (e.g., impl-cloud) accepts user bootstrap requests, signs the manifest commit itself, and POSTs the resulting commit to POST / (the standard multiplexed endpoint). The node treats this as any other commit; a Bearer hook authorizes the hosting plane's API access.

  2. 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.
When to OMIT /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).

Request:
{
  "manifest": {
    "RBAC": { "use_temp": "none", "schema": [ ... ] },
    "<other manifest fields>": ...
  }
}
Response (success):
{
  "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_id MUST 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.

Request:
{
  "hash": "<hex64>",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "type": "<string>",
  "content": "<string>",
  "content_hash": "<hex64>",
  "exp": 1706000000000,
  "tags": [["key", "value"]],
  "sig": "<hex128>"
}
FieldTypeRequiredDescription
hashhex64YesCBOR hash of commit (see spec.md)
enclavehex64YesTarget enclave ID
fromhex64YesSender's identity public key
typestringYesEvent type
contentstringYesEvent 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_hashhex64Yessha256(utf8_bytes(content)). Bound in the commit signature via the CBOR pre-image; the node enforces the match against content.
expuintYesExpiration timestamp (Unix milliseconds)
tagsarrayNoArray of [key, value] pairs
algstringNoSignature scheme: "schnorr" (default if absent) or "ecdsa" (see Signature Schemes)
sighex128YesSignature 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>"
}
FieldTypeDescription
typestringAlways "Receipt"
idhex64Event ID
hashhex64Original commit hash
timestampuintSequencer timestamp (Unix milliseconds) — recorded when sequencer finalizes the event, not client submission time
sequencerhex64Sequencer public key
sequintSequence number
sighex128Client's signature (from commit)
seq_sighex128Sequencer's signature over event

Note: Receipt omits enclave for privacy — client already knows which enclave it submitted to.

Errors:
CodeHTTPDescription
INVALID_COMMIT400Malformed commit structure
INVALID_HASH400Hash doesn't match CBOR encoding
INVALID_SIGNATURE400Signature verification failed
EXPIRED400exp < current time
DUPLICATE409Commit hash already processed
UNAUTHORIZED403Insufficient RBAC permissions
ENCLAVE_NOT_FOUND404Enclave doesn't exist
ENCLAVE_PAUSED403Enclave is paused
ENCLAVE_TERMINATED410Enclave is terminated
RATE_LIMITED429Too many requests

POST / (Query)

Query events from the enclave.

Detection: Request contains type: "Query" field.

Request:
{
  "type": "Query",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}
FieldTypeRequiredDescription
typestringYesMust be "Query"
enclavehex64YesTarget enclave ID (plaintext for routing)
fromhex64YesRequester's identity public key
contentstringYesEncrypted payload (see Encryption)
Content (plaintext):
{
  "session": "<hex136>",
  "filter": { ... }
}
FieldTypeRequiredDescription
sessionhex136YesSession token (see Session)
session_authobjectNoECDSA authorization { sig, recovery } for a delegated session (see Delegated Session)
filterobjectYesQuery filter (see Filter)

Note: enclave is plaintext for routing — node needs it before decryption.

Response (200 OK):
{
  "type": "Response",
  "content": "<encrypted>"
}
Response Content (plaintext):
{
  "events": [
    { "event": Event, "status": "active" },
    { "event": Event, "status": "updated", "updated_by": "<hex64>" },
    ...
  ]
}
FieldDescription
eventThe 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:
CodeHTTPDescription
INVALID_QUERY400Malformed query structure
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
DECRYPT_FAILED400Cannot decrypt content
INVALID_FILTER400Malformed filter
UNAUTHORIZED403No read permission
ENCLAVE_NOT_FOUND404Enclave doesn't exist
RATE_LIMITED429Too 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 with r tag matching value
  • { "r": ["abc123.", "def456."] } — events with r tag matching any value
  • { "auto-delete": true } — events with auto-delete tag (any value)
Default Sort Order:

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

PatternMeaning
Top-level fieldsAND
Array valuesOR
Omitted fieldMatch all

Limits

FieldMax
id[]100
seq[]100
type[]20
from[]100
tags keys10
tags values per key20
limit1000

Examples

By type:
{ "type": "message" }
By authors:
{ "type": "message", "from": ["abc...", "def..."] }
Time range:
{ "timestamp": { "start_at": 1704067200000, "end_before": 1704153600000 } }
Resume from seq:
{ "seq": { "start_after": 150 }, "limit": 100 }
Newest first:
{ "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

DirectionTypeDescription
C → NQueryFirst valid Query subscribes; node adopts a caller-supplied sub_id or assigns one
C ← NEventStored events (encrypted)
C ← NEOSEEnd of stored events
C ← NEventLive updates (encrypted)
C → NCommitWrite event
C ← NReceiptWrite success
C ← NErrorWrite error
C → NCloseUnsubscribe from subscription
C ← NClosedSubscription terminated
C ← NNoticeInformational message

Client → Node

MessageFormatDescription
QuerySame as POST / (Query), optional sub_idFirst valid Query subscribes; node adopts a caller-supplied sub_id or assigns one
CommitSame 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 Query carries no sub_id, the node generates a unique one and tags the subscription's frames with it.
  • Caller-supplied: if a Query includes a sub_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 with seq > c contiguously, then transition to live. The transition is signalled by EOSE and the live stream resumes from the first event committed after the last replayed seq. There MUST NOT be a gap.
  • Without a seq cursor: the subscription is live-only. The node MUST NOT replay an oldest-N window of stored events. Catch-up history is obtained via a separate POST / (Query) request with explicit pagination, then the client opens a subscribe with seq.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:

  1. Rejecting the subscribe with an explicit Closed reason (e.g., subscribe_replay_too_large with a hint to Pull history in chunks); OR
  2. Streaming events contiguously and asynchronously without buffering them all in memory; OR
  3. 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) to O(clients) + O(distinct live enclaves) (with).

Why this is wire-compatible. The hub is a pure sub_id router:

  • It forwards Query frames 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_id on 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 use sub_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:

  1. Query accepts a caller-supplied sub_id (used verbatim) and echoes it on Event / EOSE / Closed / Error frames. (Defined above under Caller-supplied.)
  2. Close { sub_id } removes only the named subscription, leaving other subscriptions on the same connection alive.
  3. 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.
  4. tags round-trip losslessly through storage. Plugins (dm:sent with ["to", ...], cross-enclave notice with ["enclave_id", ...]) depend on tags surviving backfill from the event store, not just live broadcast. (Normative requirement is in spec.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):

DirectionFrameBody
C → Ntext frameping
N → Ctext framepong
N → Ctext frameping
C → Ntext framepong

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>"
}
End of Stored Events:
{
  "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>"
}
Write Error:
{
  "type": "Error",
  "code": "<CODE>",
  "message": "<reason>"
}
Subscription Closed:
{
  "type": "Closed",
  "sub_id": "<string>",
  "reason": "<reason_code>"
}
Closed Reasons:
ReasonDescriptionClient Action
access_revokedLive 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_accessQuery 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_endedSubscription 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_expiredSession token expiredGenerate new session, re-subscribe
enclave_terminatedEnclave was terminatedPermanent; no recovery
enclave_pausedEnclave was pausedWait for Resume event, then re-subscribe
enclave_migratedEnclave migrated to new nodeQuery Registry for new node, re-subscribe there
upstream_closedEmitted 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.

Notice:
{
  "type": "Notice",
  "message": "<string>"
}

Node Processing

On Query:
  1. Verify session (same as HTTP)
  2. Authorize the query per Read Authorization — compute the requester's served seq set; reject with access_revoked (no readable intervals at all) or no_access (intervals exist but don't overlap the query) as defined there
  3. Adopt the Query's sub_id if present (non-empty string), else generate one; store subscription
  4. Send matching events as Event messages (encrypted), restricted to the served seq set
  5. Send EOSE message
  6. On new events matching filter AND inside the requester's open-ended interval → push Event to client; on RBAC change that closes the open-ended interval, emit Closed { reason: "live_access_ended" }
On Commit:

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:

  1. Compute the requester's per-reader-column access intervals in the target enclave (a list of disjoint seq ranges).
  2. Intersect with the query's seq / filter range to obtain the served seq set and reject if empty.
  3. 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.retentionreader.typeI(requester, reader)
"current" (default)State / trait[[0, ∞)] if requester's CURRENT bitmask contains reader.type; else [].
"snapshot"State / traitReplayed over CT as defined in Computing Snapshot Intervals below.
n/aSelf / 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.readers

with 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:

PhaseSeq rangeAuthorized iff
Historical[query_start, current_seq) at query openquery_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 the EOSE.
  • Both phases empty (rare: every reader is current and requester has nothing) → Closed { reason: "access_revoked" } at query open, before any EOSE.
  • 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 emits Closed { 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 with I = [[11, 401), [521, …)] gives [521, 600). Historical serves; the seq cap means there is no live phase, no live_access_ended ever fires.
  • Alice (kicked) asks for seq.start_after: 600, seq.end_before: 800: range is [601, 800). Empty intersection with I (her second interval starts at 521 and ended at 401 — wait no, scratch — assume here she's mid-gap with I = [[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 targeting requester in the CT — Move / Grant / Revoke / Transfer (typically < 10 per identity per enclave)
  • Q = events served (bounded by query.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
Multi-key support:
  • One connection, multiple identities (from)
  • Each identity has own session
  • Session expiry only affects that identity's subscriptions

HTTP vs WebSocket

AspectHTTPWebSocket
QueryOne-time responseSubscribe + live updates
sub_idN/ANode-assigned, or caller-supplied
CommitReceiptReceipt
SessionPer-requestCached per connection
StateStatelessSubscriptions


Proof Retrieval API

Endpoints for retrieving cryptographic proofs. Used by clients to verify events and state.

Access Control:
EndpointAccess
STHPublic
ConsistencyPublic
InclusionRequires R permission
StateRequires 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.

ParameterTypeDescription
fromuintEarlier tree size
touintLater tree size (omit for current)
Response (200 OK):
{
  "ts1": 500,
  "ts2": 1000,
  "p": ["<hex64>", ...]
}

See proof.md for verification algorithm.

Errors:
CodeHTTPDescription
INVALID_RANGE400from > to or invalid values
ENCLAVE_NOT_FOUND404Enclave 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>"
}
Content (plaintext):
{
  "session": "<hex136>",
  "leaf_index": 42
}
Response (200 OK):
{
  "type": "Response",
  "content": "<encrypted>"
}
Response Content (plaintext):
{
  "ts": 1000,
  "li": 42,
  "p": ["<hex64>", ...],
  "events_root": "<hex64>",
  "state_hash": "<hex64>"
}
FieldDescription
tsTree size when proof was generated
liLeaf index
pInclusion proof path
events_rootMerkle root of event IDs in bundle
state_hashSMT root after bundle

See proof.md for verification algorithm.

Errors:
CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
UNAUTHORIZED403No read permission
LEAF_NOT_FOUND404Leaf index out of range
ENCLAVE_NOT_FOUND404Enclave 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>"
}
Content (plaintext):
{
  "session": "<hex136>",
  "event_id": "<hex64>"
}
Response Content (plaintext):
{
  "leaf_index": 42,
  "ei": 2,
  "s": ["<hex64>", ...],
  "events_root": "<hex64>"
}
FieldDescription
leaf_indexBundle's position in CT tree
eiEvent index within bundle
sSiblings for bundle membership proof
events_rootMerkle root of event IDs in bundle

See proof.md for verification algorithm.

Errors:
CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
UNAUTHORIZED403No read permission
EVENT_NOT_FOUND404Event doesn't exist
ENCLAVE_NOT_FOUND404Enclave 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>"
}
Content (plaintext):
{
  "session": "<hex136>",
  "namespace": "rbac" | "event_status",
  "key": "<hex64>",
  "tree_size": 1000
}
FieldRequiredDescription
sessionYesSession token
namespaceYes"rbac" or "event_status"
keyYesIdentity public key (rbac) or event ID (event_status)
tree_sizeNoBundle index for historical state (omit for current)
Response Content (plaintext):
{
  "k": "<hex42>",
  "v": "<hex | null>",
  "b": "<hex42>",
  "s": ["<hex64>", ...],
  "state_hash": "<hex64>",
  "leaf_index": 999
}
FieldDescription
k, v, b, sSMT proof fields (see proof.md)
state_hashSMT root hash for verification
leaf_indexBundle index (0-based) containing this state
Verification Flow:

To fully verify a state proof is authentic and from the requested tree position:

  1. Verify SMT proof against state_hash (see proof.md)
  2. Request CT inclusion proof for leaf_index via POST /inclusion
  3. Verify CT inclusion: Recompute leaf as H(0x00, events_root, state_hash) and verify against signed CT root
  4. 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:
CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
INVALID_NAMESPACE400Unknown namespace
UNAUTHORIZED403No read permission
TREE_SIZE_NOT_FOUND404Historical state not available
ENCLAVE_NOT_FOUND404Enclave 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).

Request:
{
  "type": "State_Proof_Batch",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}
Content (plaintext):
{
  "session": "<hex136>",
  "namespace": "rbac" | "event_status",
  "keys": ["<hex42>", "<hex42>", ...],
  "tree_size": 1000
}
FieldRequiredDescription
sessionYesSession token
namespaceYesAll keys MUST share one namespace; cross-namespace batches MUST be rejected with INVALID_NAMESPACE
keysYesArray of <hex42> SMT keys to look up; max 1000 per request
tree_sizeNoBundle index for historical state (omit for current)
Limits:
FieldMax
keys1000

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.

Response Content (plaintext):
{
  "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>", ...] }
  ]
}
FieldDescription
state_hashSMT root hash; ONE root for the whole batch, MUST be the same root every per-key proof verifies against
leaf_indexBundle index (0-based) containing this state
proofsArray, 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:

  1. Same root for every proof. Every entry in proofs is an SMT proof against the SAME state_hash. The client verifies all entries against the single returned root.
  2. Order preservation. The response's proofs array MUST be the same length as the request's keys array, and proofs[i] MUST be the proof for keys[i]. Clients MAY rely on positional alignment.
Verification Flow:

Same as /state, applied once to the shared state_hash:

  1. For each proofs[i], verify SMT proof against state_hash (see proof.md).
  2. Request CT inclusion proof for leaf_index via POST /inclusion (ONCE; the same leaf_index covers every per-key proof in the batch).
  3. Verify CT inclusion as in the single-key flow.
  4. 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.

Errors:
CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
INVALID_NAMESPACE400Unknown namespace, or batch carried multiple namespaces
BATCH_TOO_LARGE400keys.length exceeds the 1000 limit
UNAUTHORIZED403No read permission
TREE_SIZE_NOT_FOUND404Historical state not available
ENCLAVE_NOT_FOUND404Enclave 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_seq per queue
  • All enclaves in a Push delivery share the node's seq_priv for encryption
Multiple Endpoints:

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.

Ordering Guarantee:

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.

Endpoint Transition Atomicity:

When a Grant changes the webhook URL (new Grant with the same trait, different URL):

  1. Old and new endpoints operate as separate queues (no events lost)
  2. Old endpoint receives events finalized before the Grant
  3. New endpoint receives events finalized after the Grant
  4. 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_seq

Push

Webhook delivery containing full events (P permission) and/or event IDs (N permission).

HTTP Request:
POST <url>
Content-Type: application/json
Body:
{
  "type": "Push",
  "from": "<hex64>",
  "to": "<hex64>",
  "url": "<string>",
  "content": "<encrypted>"
}
FieldTypeDescription
typestringAlways "Push"
fromhex64Sequencer public key
tohex64Recipient identity
urlstringWebhook URL
contentstringEncrypted payload
Content (plaintext):
{
  "push_seq": 130,
  "push": {
    "enclaves": [
      { "enclave": "<hex64>", "events": [Event, ...] }
    ]
  },
  "notify": {
    "enclaves": [
      { "enclave": "<hex64>", "seq": 150 }
    ]
  }
}
FieldTypeDescription
push_sequintSequence number per (identity, url)
pushobjectFull events for enclaves with P permission
push.enclaves[].enclavehex64Enclave ID
push.enclaves[].eventsarrayArray of Event objects
notifyobjectLatest seq for enclaves with N permission
notify.enclaves[].enclavehex64Enclave ID
notify.enclaves[].sequintLatest 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.

Content (plaintext):
{
  "session": "<hex136>",
  "url": "<string>",
  "push_seq": { "start_after": 5, "end_at": 7 },
  "enclave": "<hex64>"
}
FieldTypeRequiredDescription
sessionhex136YesSession token
urlstringYesRegistered webhook endpoint
push_sequint, [uint,.], or RangeYesBatch sequence(s) to retrieve
enclavehex64NoFilter results to single enclave
push_seq formats:
  • 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
Response:
{
  "type": "Response",
  "content": "<encrypted>"
}
Content (plaintext):
[
  {
    "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_at are inclusive/exclusive as documented
  • Results are ordered by push_seq ascending
  • If enclave filter is provided, only events from that enclave are included in each batch
  • If enclave is omitted, all enclaves in the original batch are included
Retry Policy:

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:
ParamTypeDescription
idhex64Enclave id
Request headers:
HeaderRequiredDescription
AuthorizationImplementation-definedMost production deployments require an auth token; a public snapshot endpoint is the exception, not the default.
Response (200 OK):
HeaderValue
Content-Typeapplication/octet-stream
Content-Length32 + payload_size + 32
Content-DispositionOPTIONAL attachment; filename="<enclave_id>.enc"

Body: the raw .enc bytes (header || payload || footer; see migration spec).

Errors:
CodeHTTPDescription
ENCLAVE_NOT_FOUND404This node does not host the requested enclave.
ENCLAVE_PAUSED409Enclave is paused; some implementations refuse snapshot of a paused enclave to avoid capturing transient state. Caller MAY retry after Resume.
SNAPSHOT_UNSUPPORTED501This 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:
ParamTypeDescription
idhex64Enclave id the snapshot will be restored as
Request headers:
HeaderRequiredDescription
Content-TypeYesapplication/octet-stream
Content-LengthYesTotal bytes; MUST equal 32 + payload_size + 32
AuthorizationImplementation-definedRestore 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>"
}
Errors:
CodeHTTPDescription
BAD_SNAPSHOT_MAGIC400Header magic ≠ b"ENC\x01".
UNKNOWN_LAYOUT_VERSION400layout_ver is not 1.
SNAPSHOT_FOOTER_MISMATCH400Computed sha256 footer does not match stored.
KERNEL_VERSION_MISMATCH400kernel_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_LARGE413payload_size exceeds the node's configured limit.
SELF_TEST_FAILED422Post-restore self-test rejected.
ENCLAVE_ALREADY_EXISTS409This node already hosts an enclave with this id; restoration would overwrite.
RESTORE_UNSUPPORTED501This 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

AspectBenefit
BatchingOne POST delivers events from many enclaves
Single sequenceOne push_seq for gap detection across all enclaves
Periodic aggregationNode batches events instead of instant push per event
Unified messagePush 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 enclaves
With unified push_seq:
Recipient tracks:
  - push_seq = 130
 
Recovery requires:
  - One Pull request with Range
  - Returns all missed batches
  - Single round trip
Gap detection is trivial:
  • 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 permission
  • notify.enclaves[] — latest seq for enclaves with N permission
pushnotify
ContentFull eventsLatest seq only
Use caseReal-time syncLightweight alerts

Permissions

PermissionWhat 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 events

If 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

  1. Receive Push with notify.enclaves[].seq
  2. Query events with { "seq": { "start_after": last_synced_seq } } (requires R permission)
  3. 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

ContextClient KeyNode KeyHKDF Label
Query requestsigner_privseq_pub"enc:query"
Query responsesigner_privseq_pub"enc:response"
Pull requestsigner_privseq_pub"enc:query"
Pull responsesigner_privseq_pub"enc:response"
WebSocket eventsigner_privseq_pub"enc:response"
Push deliveryto (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

PrimitiveSpecification
ECDHsecp256k1
HKDFHKDF-SHA-256
AEADXChaCha20-Poly1305
HKDF Parameters:
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)
XChaCha20-Poly1305 Nonce:
nonce = random 24 bytes, prepended to ciphertext
ciphertext_wire = nonce || ciphertext || tag

Recipient 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

HTTPCategory
400Client error (malformed request)
401Authentication error
403Authorization error
404Not found
409Conflict
410Gone
429Rate limited
500Internal server error
502Upstream unreachable
503Service temporarily unavailable

Error Response Format

{
  "type": "Error",
  "code": "<CODE>",
  "message": "<human readable>"
}

Error Codes

CodeHTTPDescription
INVALID_COMMIT400Malformed commit structure
INVALID_HASH400Hash doesn't match CBOR encoding
INVALID_SIGNATURE400Signature verification failed
INVALID_QUERY400Malformed query structure
INVALID_SESSION400Session token verification failed
INVALID_FILTER400Malformed filter
DECRYPT_FAILED400Cannot decrypt content
SESSION_EXPIRED401Session token expired
EXPIRED400Commit exp < current time
UNAUTHORIZED403Insufficient RBAC permissions
ENCLAVE_PAUSED403Enclave is paused
DUPLICATE409Commit hash already processed
NODE_NOT_FOUND404Node not registered
ENCLAVE_NOT_FOUND404Enclave not registered
IDENTITY_NOT_FOUND404Identity not registered
ENCLAVE_TERMINATED410Enclave is terminated
ENCLAVE_MIGRATED410Enclave has migrated to another node
RATE_LIMITED429Too many requests
INTERNAL_ERROR500Internal server error
WebSocket Errors:

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...
}
FieldRequiredDescription
typeYesAlways "Error"
codeYesMachine-readable error code (UPPER_SNAKE_CASE)
messageYesHuman-readable description
additionalNoError-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:
CodeContext FieldsDescription
UNAUTHORIZEDSender lacks required permission
STATE_MISMATCHexpected, actualMove target's current State does not match from
RANK_INSUFFICIENTOperator's rank is not strictly less than target's rank
INVALID_STATE_FOR_GRANTTarget's State is not in Grant/Revoke scope
INVALID_STATE_FOR_TRANSFERTarget's State is not in Transfer scope
INVALID_TRANSFER_TARGETTransfer target is the operator (self-transfer)
TRAIT_ALREADY_HELDTransfer target already holds the trait
INVALID_LIFECYCLE_STATELifecycle transition not valid from current state
AC_BUNDLE_FAILEDfailed_index, reasonAC_Bundle operation failed at index
EVENT_DELETEDTarget event has been deleted (Self
/D on deleted event)
ENCLAVE_PAUSEDEnclave is paused, only Resume/Terminate/Migrate accepted
ENCLAVE_TERMINATEDEnclave is terminated, no events accepted
ENCLAVE_MIGRATEDEnclave has migrated to another node
ENCLAVE_NOT_FOUNDEnclave ID not found on this node