The complete protocol specification in four documents. Every hash formula, every API endpoint, every proof algorithm. This is the normative reference for implementers.
Crypto, events, RBAC, enclave state, predefined events, registry, migration.
REST, WebSocket, proof retrieval, webhooks, sessions, encryption.
CT inclusion, consistency, bundle membership, SMT state proofs.
Tree structure, namespaces, key construction, collision analysis.
Core protocol: crypto, events, RBAC, enclave state, predefined events, registry, and migration.
ENC (encode, encrypt, enclave) is a protocol for building log-based, verifiable, sovereign data structures with Role-Based Access Control (RBAC).
Key properties:
The ENC protocol defines two query paths with different trust properties:
A DataView is a separate service that indexes, aggregates, and transforms enclave data for efficient querying.
Data Access Methods:
A DataView can receive enclave data through three methods, each requiring a role assignment:
| Method | Permission | Description |
|---|---|---|
| Query | R (Read) | DataView queries the enclave directly as a member |
| Push | P | Node delivers full events to DataView proactively |
| Notify | N | Node delivers lightweight notifications; DataView fetches as needed |
The ENC protocol uses Schnorr signatures over the secp256k1 curve, following the BIP-340 specification.
schnorr(message, private_key) operations MUST use the secp256k1 elliptic curve.id_pub) are 32-byte x-only secp256k1 public keys as defined in BIP-340.Deterministic Signing:
All Schnorr signatures MUST be deterministic. Implementations MUST use the default nonce derivation specified in BIP-340, which derives the nonce from:
This ensures that signing the same message with the same key always produces the same signature, making event IDs deterministic and verifiable.
sha256() operations use SHA-256 as defined in FIPS 180-4.All hash pre-images MUST be serialized using CBOR (RFC 8949) with deterministic encoding before hashing.
Canonical Hash Function:
This specification defines:
H(fields...) = sha256(cbor_encode([fields...]))
All hash formulas in this document use H() implicitly. When you see:
sha256([0x10, enclave, from, type, content_hash, exp, tags])
it means:
sha256(cbor_encode([0x10, enclave, from, type, content_hash, exp, tags]))
CBOR Encoding Rules:
"" is encoded as CBOR text string with length 0 (0x60)To prevent hash collisions between different data types, all hash operations use a unique single-byte prefix:
| Prefix | Purpose |
|---|---|
0x00 | CT leaf (RFC 9162) |
0x01 | CT internal node (RFC 9162) |
0x10 | Commit hash |
0x11 | Event hash |
0x12 | Enclave ID |
0x20 | SMT leaf |
0x21 | SMT internal node |
Prefixes 0x02–0x0F, 0x13–0x1F, and 0x22–0x2F are reserved for future use.
String Prefixes for Signatures:
Signature contexts use string prefixes (not byte prefixes) for domain separation:
| Context | Prefix String |
|---|---|
| STH signature | "enc:sth:" |
| Session token | "enc:session:" |
String prefixes are concatenated with data before hashing: sha256(prefix || data).
Transport Encoding:
The normative wire format for v1 is JSON. CBOR is used only for hash computation, not transport.
JSON Encoding:
When serializing for transport or storage as JSON:
| Type | Encoding | Example |
|---|---|---|
| Hash (32 bytes) | Hex string, no prefix | "a1b2c3..." (64 chars) |
| Public key (32 bytes) | Hex string, no prefix | "a1b2c3..." (64 chars) |
| Signature (64 bytes) | Hex string, no prefix | "a1b2c3..." (128 chars) |
| Role bitmask | Hex string with 0x prefix | "0x100000002" |
| Content | UTF-8 string | "hello world" |
| Binary in content | Base64 encoded | "SGVsbG8=" |
| Integers | JSON number | 1706000000 |
0x hex prefix format.CBOR Encoding:
When serializing for hashing or binary transport:
| Type | Encoding |
|---|---|
| Hash, public key, signature | CBOR byte string (major type 2) |
| Role bitmask | CBOR unsigned integer (major type 0) |
| Content, type | CBOR text string (major type 3) |
An Enclave is a log-based, verifiable, sovereign data structure with Role-Based Access Control (RBAC).
An enclave maintains:
A Node is a host for one or more enclaves.
A node is responsible for:
Each enclave has a single sequencer — the node responsible for ordering and finalizing commits.
Assignment:
sequencer fieldReg_Enclave)Responsibilities:
seq, timestampseq_sigSequencer change:
A Client is a software agent that controls an Identity Key by holding (or having authorized access to) the corresponding private key id_priv, and can produce signatures or decrypt/encrypt on behalf of the identity id_pub according to the ENC protocol.
Notes:
id_priv may be held directly or accessed via a secure signer (e.g., OS keystore, HSM, enclave, wallet).An Identity Key is a 256-bit public key (id_pub) used to represent an identity in the ENC protocol.
A Commit is a client-generated, signed message proposing an event.
A commit:
{
hash: <hex64>,
enclave: <enclave id>,
from: <sender_id_pub>,
type: <string>,
content_hash: <hex64>,
content: <string>,
exp: <unix timestamp>,
tags: [ <name | str1 | str2>, ... ],
sig: <signature over hash>
}
Where:
"" for events with no payload like Pause/Resume; binary data SHOULD be base64 encoded; large binaries SHOULD use external resource URLs)from, proving authorshipThis formula applies to ALL event types (Manifest, Grant, Chat_Message, etc.):
_content_hash = sha256(utf8_bytes(content)) hash = H(0x10, enclave, from, type, _content_hash, exp, tags) sig = schnorr(hash, from_priv)
Where utf8_bytes() returns the raw UTF-8 byte sequence. No Unicode normalization (NFC/NFD) is performed at the protocol level. The content structure varies by event type, but the hash formula is identical.
Note: _content_hash uses raw SHA-256 on bytes, not H(), because content is already a byte string.
Binary Data in Content:
If content contains binary data (e.g., images, files), the data MUST be base64-encoded before inclusion. The content_hash is computed over the base64-encoded string, not the decoded binary bytes.
Example:
0x48656c6c6f (5 bytes, "Hello")"SGVsbG8="content_hash = sha256(utf8_bytes("SGVsbG8=")) — hash of 8 UTF-8 bytesThis ensures the hash matches what is transmitted and stored. Implementations MUST NOT decode base64 before hashing.
This establishes:
content_hash is computed for the commit hash but is not stored in the finalized event. The event stores only content; recipients can recompute the hash if needed for verification.content_hash field is NOT transmitted. The client sends content; the node computes content_hash = sha256(utf8_bytes(content)) for commit hash verification.Content Integrity:
The node MUST store and serve event.content exactly as received in the commit — byte-for-byte, with no normalization or transformation. Any modification would invalidate the commit hash.
Application-Level Canonicalization:
If applications treat content as structured data (e.g., JSON), they MUST define their own canonical encoding rules to ensure cross-client byte-identical hashing. The protocol treats content as an opaque byte string.
Tags provide node-level metadata that instructs the node how to process an event.
Unlike protocols where tags serve as query indexes, ENC data is already scoped to enclaves. Tags exist primarily to convey processing instructions to the node.
Structure:
Tags are an array of arrays. Each tag has:
All tag values MUST be strings. Numeric or other typed values are encoded as their string representation.
[ ["<name>", "<value>", "<additional>", ...], ... ]
Hash Encoding: When computing the commit hash, tags are encoded as a single CBOR text string using bracket notation: each tag becomes [name,val1,val2,...] and tags are comma-separated. For example, [["r","abc","reply"],["auto-delete","1706000000000"]] encodes as the text "[r,abc,reply],[auto-delete,1706000000000]". Empty tags [] encode as an empty text string "". The order of tags is significant for hash computation.
Empty Tags: An empty tags array [] is valid.
Example:
[ ["auto-delete", "1706000000000"], ["r", "abc123...", "reply"] ]
Predefined Tags:
| Tag | Description |
|---|---|
r | Reference — references another event. Format: ["r", "<event_id>", "<context>"]. See context values below. |
auto-delete | Auto-delete timestamp — node SHOULD delete the event content after this Unix timestamp (milliseconds, same as exp and timestamp) |
r Tag Context Values:
| Context | Meaning |
|---|---|
| (omitted) | General reference (default) |
"target" | Target for Update/Delete operations |
"reply" | Reply to referenced event |
"quote" | Quote/repost of referenced event |
"thread" | Part of a thread starting at referenced event |
Custom context values are allowed for application-specific semantics. Nodes treat unrecognized contexts as general references.
exp field (commit acceptance window) and auto-delete tag (event retention) serve different purposes. exp controls when a commit can be accepted; auto-delete controls when the finalized event should be removed from storage.Auto-delete vs Delete event:
| Aspect | Auto-delete (tag) | Delete (event) |
|---|---|---|
| Mechanism | Node silently removes content after timestamp | Explicit Delete event updates SMT |
| Verifiable | No — trust-based | Yes — auditable proof |
| SMT state | Unchanged (remains "active") | Updated to "deleted" |
| Audit trail | No | Yes (who, when, why) |
| Use case | Ephemeral content, disappearing messages | Compliance, moderation, user-initiated removal |
Relationship with exp:
The auto-delete timestamp MUST be greater than exp. The commit must be accepted before auto-delete takes effect. Nodes SHOULD reject commits where auto-delete <= exp as semantically invalid.
Auto-delete and SMT:
Auto-delete does NOT update the SMT. The event remains "active" in SMT state (no entry in Event Status namespace). Auto-delete only affects storage — the node removes content but event metadata may remain. This is intentional: auto-delete is trust-based, not verifiable via SMT proofs.
Additional predefined tags may be defined by the protocol or application schemas.
Upon receiving a valid commit, a node performs:
exp < current timehash was already processed (see Replay Protection)If accepted, the node finalizes the commit into an event by adding node-generated fields.
The node MUST reject commits with a hash that has already been processed for this enclave.
Implementation:
hash exists in the setexp + 60000 ms (60 seconds buffer for clock skew)This prevents replay attacks where an attacker resubmits a valid commit multiple times within the expiration window.
Expiration Window Limit:
Nodes MUST reject commits where the expiration is too far in the future:
exp - current_time > MAX_EXP_WINDOW → reject
The protocol defines MAX_EXP_WINDOW = 3600000 (1 hour in milliseconds). Implementations MAY use a shorter window.
This prevents storage DoS attacks where clients submit commits with extremely large exp values, forcing indefinite hash retention.
Clock Skew Tolerance:
The protocol tolerates bounded clock skew (±60 seconds) between client and sequencer:
| Component | Tolerance | Behavior |
|---|---|---|
| Commit expiration | ±60 seconds | Accept commits up to 60s "early" (client ahead) |
| Hash deduplication | +60 seconds | GC buffer after exp + 60000 ms |
| Bundle timeout | event timestamps | Uses event.timestamp, not wall clock |
| Session expiry | ±60 seconds | Node checks token expiry with tolerance |
Implementations SHOULD sync clocks via NTP and log warnings if skew exceeds 60 seconds.
An Event is the fundamental, immutable record within an enclave.
Conceptually, an event represents a user-authorized action that has been:
An event is derived from a Commit, after validation and sequencing by a node.
{
id: <hex64>,
hash: <hex64>,
enclave: <enclave id>,
from: <sender_id_pub>,
type: <string>,
content: <string>,
exp: <unix timestamp>,
tags: [ <name | str1 | str2>, ... ],
timestamp: <unix time>,
sequencer: <sequencer_id_pub>,
seq: <number>,
sig: <signature over hash>,
seq_sig: <signature over hash>,
}
Where:
seq)Sequencer Continuity:
For all events except Migrate (forced takeover), the sequencer field MUST match the current sequencer recorded in the Manifest. If a different key signs seq_sig, the event is invalid and clients MUST reject it. Exception: Migrate in forced takeover mode — the new sequencer finalizes the Migrate event (see Migration section).
Field inheritance from Commit:
The following fields are copied directly from the original Commit:
hash — the commit hash (Event.hash == Commit.hash)enclave, from, type, content, exp, tags, sigThe node adds: id, timestamp, sequencer, seq, seq_sig
The event's cryptographic commitments are constructed as follows:
_event_hash = H(0x11, timestamp, seq, sequencer, sig) seq_sig = schnorr(_event_hash, sequencer_priv) id = sha256(seq_sig)
The resulting id:
A Receipt is a node-signed acknowledgment proving that a commit has been accepted, sequenced, and finalized into an event.
It provides the client with the canonical event identifier and sequencing metadata, without including the event content.
{
id: <hex64>,
hash: <hex64>,
timestamp: <unix time>,
sequencer: <sequencer_id_pub>,
seq: <number>,
sig: <signature over hash>,
seq_sig: <signature over _event_hash>
}
Where:
The receipt cryptographically binds client intent and node ordering, and allows the client to verify successful finalization of its commit.
enclave field. The client already knows which enclave it submitted to, and omitting it provides privacy when receipts are broadcast or shared.RBAC (Role-Based Access Control) consists of two parts:
The Schema defines which roles are permitted to perform which events.
It can be represented as an Event–Role–CRUD matrix, for example:
| Event / Role | Owner | Admin | Member | Self | DataView |
|---|---|---|---|---|---|
| Grant | C (all) | C (Member) | R | N | |
| Revoke | C (all) | C (Member) | R | N | |
| Revoke_Self | C (Member) | N | |||
| Move | C (all) | C (Member) | R | N | |
| Force_Move | C | R | N | ||
| Transfer_Owner | C | R | N | ||
| Pause | C | R | N | ||
| Resume | C | R | N | ||
| Terminate | C | R | N | ||
| Migrate | C | R | N | ||
| Chat_Message | CR | UD | N |
Where C (Member) means Create permission with target_roles: ["Member"].
Each schema entry defines permissions for a (role, event type) pair:
{ "event": "<type>", "role": "<role>", "ops": ["<op>", ...], "target_roles": ["<role>", ...] }Fields:
| Field | Required | Description |
|---|---|---|
| event | Yes | Event type name, or "*" for wildcard |
| role | Yes | Role name this entry applies to |
| ops | Yes | Array of allowed operations |
| target_roles | Conditional | Restricts which roles can be granted/revoked/moved (see below) |
Operations (ops):
| Op | Meaning | Applies to |
|---|---|---|
| C | Create — can submit commits of this type | All events |
| R | Read — can query events of this type | All events |
| U | Update — can update events of this type | Content Events only |
| D | Delete — can delete events of this type | Content Events only |
| P | Push — receives full event delivery | All events |
| N | Notify — receives lightweight notifications | All events |
target_roles field:
Required for: Grant, Grant_Push, Revoke, Revoke_Self, Move. Ignored for other event types.
Schema entries for Grant and Grant_Push:
Grant and Grant_Push are independent event types requiring separate schema entries. An Admin with C permission for Grant does NOT automatically have C permission for Grant_Push.
Example:
{ "event": "Grant", "role": "Admin", "ops": ["C"], "target_roles": ["Member"] }
{ "event": "Grant_Push", "role": "Admin", "ops": ["C"], "target_roles": ["Member"] }If only Grant is defined for a role, that role cannot issue Grant_Push (and vice versa).
This field prevents privilege escalation:
{ "event": "Grant", "role": "Owner", "ops": ["C"], "target_roles": ["Admin", "Member"] }
{ "event": "Grant", "role": "Admin", "ops": ["C"], "target_roles": ["Member"] }Note: Force_Move bypasses target_roles restriction (see AC Event Authorization).
Wildcard Event Pattern:
The special event pattern * matches all event types. This is useful for:
Example:
{ "event": "*", "role": "Backup", "ops": ["P"] }Note: Wildcard patterns apply to both AC Events and Content Events.
Granting P (Push) with wildcard * gives the recipient full read access to all enclave data via push delivery. This is equivalent to granting R for all event types, but bypasses query authentication. Assign wildcard P only to trusted services (e.g., backup, authorized DataViews).
The ENC protocol defines the following reserved roles:
from field). This role is NOT stored in the RBAC state; it is evaluated dynamically at authorization time. For U/D operations, Self refers to the original Content Event's from field — this allows authors to update or delete their own events without explicit role assignment. When an event has been updated multiple times, Self always evaluates against the original author, not any intermediate Update event's author.initial_state. The owner may be different from the Manifest's from (creator), allowing one identity to create an enclave on behalf of another.Self, this role is NOT stored in RBAC state; it is evaluated dynamically by comparing the actor's identity with the enclave's current sequencer. In v1, a node's identity key (seq_pub) serves as both its registration identity and its sequencer identity. To rotate keys, use Migrate to the new identity. The Node role is reserved for future protocol extensions (e.g., node-initiated events). In v1, no predefined events require Node role for authorization.Any, no authentication is required for that operation. Restriction: The Any role cannot have P (Push) or N (Notify) permissions — P/N delivery requires an authenticated identity with a registered endpoint. Nodes MUST reject schemas that grant P or N to the Any role.The State represents the current role assignments of identities.
Conceptually, it answers the question:
> "Which identity keys are assigned to which roles?"
Representation
Roles are mapped to bit positions as follows:
Reserved roles (bits 0–31):
| Bit | Role | Description |
|---|---|---|
| 0 | Self | The event author (evaluated dynamically, not stored in SMT) |
| 1 | Owner | The enclave creator |
| 2 | Node | The sequencer of this enclave |
| 3 | Any | Anyone (no authentication required) |
| 4–31 | (reserved) | Reserved for future protocol use |
Custom roles (bits 32+):
Custom roles defined in the schema are assigned bit positions starting at bit 32, in the order they appear in the schema.
Example:
Schema defines: ["Admin", "Member", "Moderator"]
Resulting mapping:
An identity with Owner + Admin + Member has role binary: 2^1 + 2^32 + 2^33
Bitmask Encoding in JSON:
Role bitmasks in event content (e.g., Move, Force_Move) are encoded as hex strings with 0x prefix:
{ "from": "0x100000002", "to": "0x100000000" }This ensures arbitrary precision (JSON numbers lose precision beyond 2^53) and makes bit positions visually clear.
Bitmask Contexts:
| Context | Format | Example |
|---|---|---|
| Event content (Move, Force_Move) | 0x prefix, variable length | "0x100000002" |
| SMT proofs (RBAC namespace) | 64-char hex, 32-byte zero-padded | "0000...0100000002" |
| Error responses | 0x prefix, variable length | "0x100000002" |
In SMT, bitmasks are always stored as 32-byte big-endian values for consistent leaf hashing.
P (Push) and N (Notify) enable proactive delivery from nodes to external services or clients.
P (Push):
N (Notify):
Registration:
Transport:
Delivery Guarantees:
| Type | Guarantee | Behavior |
|---|---|---|
| P (Push) | At-least-once | Node retries with exponential backoff on failure. Recipient MUST handle duplicates (dedupe by event id). |
| N (Notify) | Best-effort | Fire and forget. N is a hint; recipients can poll for missed events. |
P (Push) Retry Policy:
Implementations MAY use different parameters but MUST implement retry with backoff. Nodes SHOULD NOT retry indefinitely to avoid resource exhaustion.
Grant_Push Endpoint Failure:
When P delivery to a Grant_Push endpoint fails persistently (max retries exhausted):
P/N Delivery Independence:
Each event's P/N delivery is independent. If delivery fails for one event, it does not affect delivery of other events. Events in the same bundle are delivered separately, each with its own retry cycle.
P (Push) Payload:
The P payload is the complete Event structure (see Event Structure). No wrapper is needed — the event already contains the enclave field.
N (Notify) Payload:
{
"enclave": "<enclave_id>",
"event_id": "<event_id>",
"type": "<event_type>",
"seq": 123,
"type_seq": 45,
"timestamp": 1706000000000
}Where:
Per-Type Sequence Counter:
Nodes MUST maintain a per-type sequence counter for each enclave. When an event of type T is finalized, the node increments the counter for T and assigns it as type_seq. This allows N recipients to detect missed notifications by checking continuity of type_seq.
type_seq Rules:
When a recipient detects a type_seq gap (e.g., received 5, then 8):
/query) to fetch missed events by type and sequence range. This is the preferred recovery method.A role MAY have both P and N permissions for the same event type. P takes precedence — if P delivery succeeds, N is not sent separately. If P fails (max retries exhausted), N MAY be sent as fallback notification.
U (Update) and D (Delete) are logical operations implemented as new events that reference prior events.
Semantics:
Event Status State:
The current status of each event (active, updated, deleted) is tracked in the Enclave State SMT alongside RBAC state (see Enclave State SMT).
When a U or D event is finalized:
Content Integrity Proofs:
| SMT Proof Result | Interpretation |
|---|---|
| Non-membership (null) | Event is Active OR never existed |
0x00 (1 byte) | Event was Deleted |
<32-byte id> | Event was Updated to this event ID |
Verification Flow:
POST /state with namespace: "event_status" and key: <event_id> → get SMT proof0x00 → event is Deleted (conclusive)POST /bundle with event_id → get bundle membership proof - If proof succeeds → event is Active - If proof fails (EVENT_NOT_FOUND) → event never existedClients MUST perform step 4 to distinguish Active from never-existed. The 1-byte vs 32-byte value length unambiguously distinguishes Deleted from Updated.
Content Handling:
When an event is updated or deleted:
Querying:
The enclave maintains a single Sparse Merkle Tree (SMT) that stores both:
The SMT uses:
Implementation details (depth, flag encoding, proof format) are specified in smt.md.
RBAC Entries:
id_pub (trimmed + flag)Event Status Entries:
event_id (trimmed + flag)Root Hash:
The SMT root hash represents the complete enclave state (both RBAC and event status). This single root can be used to verify any state query.
The enclave maintains an append-only Merkle tree over the event log, providing verifiable log integrity. This follows the Certificate Transparency (CT) specification defined in RFC 9162.
Properties:
Bundle Structure:
Events are grouped into bundles (see Bundle Configuration). Each bundle produces one CT leaf.
bundle = {
events: [event_id_0, event_id_1, ..., event_id_N],
state_hash: <SMT root after last event in bundle>
}
Initial State:
Before any events (including Manifest), the SMT is empty:
empty_state_hash = sha256("")
= 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The Manifest event is always in bundle 0. The bundle's state_hash is the SMT root AFTER Manifest's initial_state has been applied.
Leaf hash:
events_root = merkle_root(events) // binary Merkle tree of event IDs leaf_hash = H(0x00, events_root, state_hash)
Where:
events_root — Merkle root of event IDs in this bundlestate_hash — SMT root AFTER the last event in this bundle is appliedevents_root Construction:
For N events in a bundle:
events_root = event_ids[0] (no tree needed)event_id (raw 32-byte hash, no prefix) - Internal node: H(0x01, left, right) - If N is not a power of 2, right-pad with the last event_id until the next power of 2 - Example: 3 events → pad to 4 leaves: [e0, e1, e2, e2]Bundle Event Ordering:
Events within a bundle are ordered by their sequence number (seq). The first event has the lowest seq, the last has the highest. This ordering is deterministic and verifiable.
Bundle Index Assignment:
Bundles are numbered sequentially starting from 0. An event's bundle membership is determined by bundle boundaries:
bundle_0 contains events from seq=0 until size or timeout reachedbundle_N contains events from boundary[N-1] + 1 until size or timeout reachedboundary[N] = seq of last event in bundle NDetermining bundle from seq:
event.seq belongs to bundle_N where: boundary[N-1] < event.seq <= boundary[N] (boundary[-1] = -1 for the first bundle)
Deterministic reconstruction:
During log replay or migration, bundle boundaries are reconstructed by applying the same closing rules (size/timeout). If CT root matches after reconstruction, bundle assignment is correct.
Example:
Config: bundle.size = 3, timeout = 5000ms seq=0,1,2 (ts: 1000ms) → bundle_0, boundary[0]=2 seq=3,4,5 (ts: 3000ms) → bundle_1, boundary[1]=5 seq=6 (ts: 9000ms) → bundle_2, boundary[2]=6 (timeout hit) Query: which bundle is seq=4? Answer: bundle_1 (because 2 < 4 <= 5)
Internal node:
node_hash = H(0x01, left_child, right_child)
The 0x00 and 0x01 prefixes prevent second-preimage attacks by distinguishing leaf nodes from internal nodes.
CT Tree Construction:
The CT tree follows RFC 9162 Section 2.1 (Merkle Tree algorithm):
bundle_0, bundle_1, ...)leaf_hash until the next power of 2[b0, b1, b2, b3, b4, b4, b4, b4]CT root (8 leaves, 5 actual bundles):
root
/ \
h01 h23
/ \ / \
h0 h1 h2 h3
/ \ / \ / \ / \
b0 b1 b2 b3 b4 b4 b4 b4
↑ ↑ ↑ ↑ ↑
actual bundles (b4 repeated as padding)
This ensures deterministic CT roots across all implementations.
Proof Structure:
To prove an event exists and verify state:
With bundle.size = 1, the events_root equals the single event_id, and bundle membership proof is trivial.
State Binding:
The CT leaf binds each bundle to the enclave state after that bundle:
state_hash[bundle_0] = SMT root after all events in bundle 0 state_hash[bundle_N] = SMT root after all events in bundle N
Within a bundle, state changes are applied sequentially:
For events [e_0, e_1, ..., e_k] in bundle: state = apply(state, e_0) state = apply(state, e_1) ... state = apply(state, e_k) bundle.state_hash = state
State-changing events: Manifest, Grant, Grant_Push, Revoke, Revoke_Self, Move, Force_Move, Transfer_Owner, AC_Bundle, Update, Delete
Non-state-changing events: Content Events, Pause, Resume, Terminate, Migrate
Since state_hash is deterministic from the log, anyone can recompute and verify it. If a node provides incorrect state_hash values, the CT root will not match.
state_hash in CT reflects the state at bundle boundaries for proof purposes.State Query Semantics:
When clients query enclave state (RBAC or event status) mid-bundle, two modes are available:
| Mode | Returns | Verifiable | Fresh |
|---|---|---|---|
verified | state_hash from last finalized bundle | ✅ Yes (CT proof) | May be stale |
current | SMT root including pending events | ❌ No proof | ✅ Fresh |
Nodes SHOULD support both modes. The default mode is implementation-defined (SHOULD be documented).
Staleness Guidance:
With default bundle.timeout = 5000 ms, verified queries may be up to 5 seconds stale. Nodes SHOULD document their bundle configuration and expected staleness.
Use cases:
Proof serialization formats are specified in proof.md.
An enclave exists in one of four states, derived from the event log:
| State | Condition | Accepted Commits | Reads |
|---|---|---|---|
| Active | No Terminate/Migrate; last lifecycle event is not Pause | All (per RBAC) | Yes |
| Paused | No Terminate/Migrate; last lifecycle event is Pause | Resume, Terminate, Migrate only | Yes |
| Terminated | Terminate event exists | None | Until deleted* |
| Migrated | Migrate event exists | None | No obligation** |
*After Terminate, the node SHOULD delete enclave data. Reads MAY be allowed until deletion completes.
**After Migrate, the old node is not obligated to serve reads. Clients should query the new node.
State Derivation:
migrated = exists(Migrate) terminated = !migrated && exists(Terminate) paused = !migrated && !terminated && last_of(Pause, Resume).type == "Pause" active = !migrated && !terminated && !paused
Mutual Exclusion: Terminate and Migrate are mutually exclusive terminal states. The node MUST reject:
ENCLAVE_TERMINATEDENCLAVE_MIGRATEDThis prevents ambiguous state derivation. The first terminal event wins.
last_of() Semantics: last_of(Pause, Resume) returns the most recent event of either type. If neither exists, the result is null, and the paused condition evaluates to false.
The lifecycle state is not stored in the SMT. Nodes derive it from the event log (and MAY cache for performance). The lifecycle events themselves are recorded in the append-only log, providing an audit trail.
Lifecycle Events:
| Event | Transition | Reversible |
|---|---|---|
| Pause | Active → Paused | Yes |
| Resume | Paused → Active | Yes |
| Terminate | Active/Paused → Terminated | No |
| Migrate | Active/Paused → Migrated | No |
Behavior in Paused State:
P/N Delivery in Paused State:
Events finalized before Pause may have in-flight P/N deliveries — these SHOULD complete. No new events are finalized during Pause (except Resume/Terminate/Migrate), so no new P/N deliveries are initiated. P/N is not "paused" — there are simply no new events to trigger deliveries.
Predefined events are event types with special semantics understood and processed by the node.
| Category | Description | Can be U/D |
|---|---|---|
| AC Events | Modify RBAC state (Grant, Revoke, etc.) | No |
| Content Events | Application data with no state effect | Yes |
| Update / Delete | Modify event status of Content Events | No |
The node determines the category by event type — all AC event types and Update/Delete are predefined by the protocol.
| Type | Category | Modifies SMT | Description |
|---|---|---|---|
Manifest | Lifecycle | Yes (RBAC) | Initialize enclave |
Grant | AC | Yes (RBAC) | Grant role to identity |
Grant_Push | AC | Yes (RBAC) | Grant role + register webhook |
Revoke | AC | Yes (RBAC) | Revoke role from identity |
Revoke_Self | AC | Yes (RBAC) | Revoke own role |
Move | AC | Yes (RBAC) | Replace role assignment |
Force_Move | AC | Yes (RBAC) | Bypass target_roles |
Transfer_Owner | AC | Yes (RBAC) | Transfer Owner role |
AC_Bundle | AC | Yes (RBAC) | Atomic batch of AC ops |
Update | Event Mgmt | Yes (Status) | Supersede content event |
Delete | Event Mgmt | Yes (Status) | Delete content event |
Pause | Lifecycle | No | Pause finalization |
Resume | Lifecycle | No | Resume finalization |
Terminate | Lifecycle | No | Close enclave |
Migrate | Lifecycle | No | Transfer to new sequencer |
Content Events: Any type NOT in this registry is a Content Event (e.g., Chat_Message, File_Upload). Content Events do not modify SMT state.
A Manifest is a predefined event type used to initialize an enclave.
The acceptance and finalization of a Manifest event marks the creation of the enclave and establishes its initial configuration.
Type: Manifest
{
"enc_v": 1,
"RBAC": {
"use_temp": "none",
"schema": [
{ "event": "Grant", "role": "Owner", "ops": ["C"], "target_roles": ["Admin", "Member"] },
{ "event": "Grant", "role": "Admin", "ops": ["C"], "target_roles": ["Member"] },
{ "event": "Grant", "role": "Member", "ops": ["R"] },
{ "event": "Revoke", "role": "Owner", "ops": ["C"], "target_roles": ["Admin", "Member"] },
{ "event": "Revoke", "role": "Admin", "ops": ["C"], "target_roles": ["Member"] },
{ "event": "Revoke", "role": "Member", "ops": ["R"] },
{ "event": "Revoke_Self", "role": "Member", "ops": ["C"], "target_roles": ["Member"] },
{ "event": "Terminate", "role": "Owner", "ops": ["C"] },
{ "event": "Terminate", "role": "Member", "ops": ["R"] },
{ "event": "Chat_Message", "role": "Member", "ops": ["C", "R"] },
{ "event": "Chat_Message", "role": "Self", "ops": ["U", "D"] }
],
"initial_state": {
"Owner": [id_owner],
"Admin": [id1, ...],
"Member": [id1, id2, ...]
}
},
"meta": { "description": "a simple group" },
"bundle": {
"size": 256,
"timeout": 5000
}
}
Note: This is a simplified example. Production schemas should include additional AC events (Move, Force_Move, Transfer_Owner, Pause, Resume, Migrate) as needed for the enclave's requirements.
Manifest Content Canonicalization:
Unlike Content Events, the node parses Manifest content as JSON for validation. For commit hash verification:
content as UTF-8 JSON stringcontent byte-for-byte (does not re-serialize)Clients SHOULD use deterministic JSON serialization (sorted keys, no extra whitespace) for reproducibility, but this is not enforced by the protocol.
schema field is ignored.{}), creating an enclave with no initial roles; roles must then be granted explicitly via Grant after Manifest.meta field (as JSON) MUST NOT exceed 4 KB (4096 bytes). Nodes MUST reject Manifests with larger meta fields.Unused Schema Roles:
A schema MAY define roles that are never assigned in initial_state. This is valid and common in modular systems. Unused roles do not affect protocol behavior — they simply define permissions that can be granted later.
Schema Immutability:
The RBAC schema is immutable after the Manifest is finalized. To change the schema, a new enclave must be created.
This ensures:
No Schema Version Field:
The schema has no explicit version field. The enclave ID is derived from Manifest content (which includes the schema), so any schema change would produce a different enclave ID. This is intentional — changing the schema requires creating a new enclave.
Bundle Configuration:
The bundle field controls how events are grouped for CT and SMT efficiency.
| Field | Type | Default | Description |
|---|---|---|---|
size | number | 256 | Max events per bundle |
timeout | number | 5000 | Max milliseconds before closing bundle |
Bundle closing rules:
size events accumulated, ORtimeout ms passed since bundle openedTimeout Clock:
Timeout is measured using event timestamps, not wall clock. The bundle opens when the first event's timestamp is recorded. The bundle closes when a new event arrives with timestamp >= first_event.timestamp + timeout.
Determinism: Bundle boundaries depend only on the finalized event sequence (seq order) and timestamps, which are immutable once sequenced. Out-of-order network delivery does NOT affect bundle boundaries — replaying the same log always produces identical bundles.
Idle Bundles:
If an enclave receives no new events, the current bundle remains open (no timeout trigger). This is intentional: bundles are only closed when needed for new events. An open bundle has no negative effect — CT/SMT are still current in-memory. The bundle closes immediately when the next event arrives (if timeout elapsed).
Concurrent Events:
The sequencer processes commits serially. "Simultaneous" arrival is resolved by the sequencer's internal ordering. Bundle boundaries are deterministic given the final event sequence.
Semantics:
state_hash (SMT root after last event in bundle)size: 1 for per-event state verification (no bundling)state_hash Computation Timing:
The state_hash is computed when the bundle closes:
seq orderstate_hashImmutability:
Bundle configuration is immutable after the Manifest is finalized, like the RBAC schema.
The enclave identifier is derived from the Manifest commit:
enclave = H(0x12, from, type, content_hash, tags)
For a Manifest commit, the client MUST:
enclave using the formula aboveenclave field in the commit to this valuehash (which includes enclave)Derivation Order (critical for implementation):
1. content_hash = sha256(utf8_bytes(content)) 2. enclave_id = H(0x12, from, "Manifest", content_hash, tags) 3. commit.enclave = enclave_id 4. commit_hash = H(0x10, enclave_id, from, "Manifest", content_hash, exp, tags) 5. sig = schnorr_sign(commit_hash, id_priv)
This two-step derivation ensures the enclave ID is self-referential and deterministic.
from, content, and tags produce the same enclave ID. This is intentional — identical inputs represent the same enclave intent. To create distinct enclaves with similar configurations, include a unique value in meta (e.g., a UUID or timestamp).Enclave Identity and Collision:
The enclave ID is deterministic and independent of which node hosts it. If two nodes receive identical Manifest commits, they compute the same enclave ID. However, only one enclave with that ID should exist in the Registry at a time. Clients SHOULD verify the sequencer via Registry.
Collision Handling:
If a node receives a Manifest commit for an enclave ID that already exists on that node, the node MUST reject the commit. This is detected by checking if the enclave already has a seq=0 event.
If different nodes independently create enclaves with the same ID, both enclaves are technically valid on their respective nodes. The Registry determines which is canonical for discovery purposes — clients discovering via Registry will only see one sequencer.
Registry Conflict Resolution:
If two nodes attempt to register the same enclave ID in Registry:
seq in Registry)enclave_id are rejected (not superseded)This differs from normal Reg_Enclave superseding behavior — enclave ID conflicts are errors, not updates.
Manifest exp Field:
The exp field in a Manifest commit has the same semantics as other commits: it defines the deadline by which the node must accept the commit. After enclave creation, the Manifest's exp has no ongoing effect.
Validation Order:
The node MUST reject a Manifest commit if:
enc_v is not a supported protocol versionRBAC.use_temp is not "none" and not a recognized template nameRBAC.schema is invalid (when use_temp is "none"): - Not an array - Contains entries with missing required fields - Contains invalid ops valuesRBAC.initial_state does not include exactly one identity with the Owner role (validation occurs at Manifest acceptance time; multiple Owners is invalid; zero Owners is invalid; Owner-only initial_state is valid)initial_state is not a valid 32-byte public keyinitial_state is not defined in schema and not Owner (Owner is required; Self, Node, Any are dynamic and cannot be assigned)initial_state includes Self, Node, or Any (these are dynamic roles, not assignable)If a node accepts the Manifest commit and returns a valid receipt, the client can conclude that:
An Update event replaces the content of a previously finalized event.
Type: Update
| Field | Value |
|---|---|
| type | Update |
| content | The replacement content |
| tags | MUST include ["r", "<target_event_id>"] |
r tag) is marked as updated in the Enclave State SMTSMT Update Tracking:
When an Update event is finalized:
r tagSMT[target_event_id] = update_event_idThe SMT always stores the most recent Update event ID for each target. Clients can follow the chain by querying the Update event, which contains the new content.
Update Lookup:
All Update events target the original Content Event, not previous Updates. The SMT stores only one entry per original event, always pointing to the most recent Update. No chain following is needed — one SMT lookup returns the latest content.
Concurrent Updates in Bundle:
If multiple Updates target the same original event within one bundle, they are processed serially by seq order. Each Update overwrites the previous SMT entry. Only the last Update's event_id is stored in SMT after the bundle closes.
Empty Content:
An Update event with content: "" (empty string) is valid. This clears the content while preserving the update chain. Use case: author wants to retract content but preserve the event record.
The node checks U permission against the target event's type, not Update. For example, if the schema grants Self the U permission for Chat_Message, only the original author can update their own messages.
The node MUST reject Update if:
Updating an Already-Updated Event:
Updating an event that was previously updated is allowed. The new Update supersedes the previous one — the SMT entry is overwritten with the new Update's event_id. The target MUST always be the original Content Event, not any intermediate Update event.
A Delete event marks a previously finalized event as logically deleted.
Type: Delete
| Field | Value |
|---|---|
| type | Delete |
| content | JSON object (see below) |
| tags | MUST include ["r", "<target_event_id>"] |
content field in events is always a UTF-8 string. The JSON structure shown is the content when parsed. The actual event stores: content: "{\"reason\":\"author\",\"note\":\"...\"}"Content fields:
| Field | Required | Description |
|---|---|---|
| reason | Yes | "author" (self-deletion) or "moderator" (admin/role deletion) |
| note | No | Optional explanation (e.g., "policy violation") |
r tag) is marked as deleted in the Enclave State SMTThe node checks D permission against the target event's type, not Delete. For example, if the schema grants Self the D permission for Chat_Message, only the original author can delete their own messages.
Self Evaluation Example:
If schema defines:
{ "event": "Chat_Message", "role": "Self", "ops": ["D"] }Alice (id_alice) can delete a Chat_Message only if target_event.from == id_alice. The Self role matches the target event's author, not the Delete event's sender (which should be the same for this to succeed).
If the schema also grants Admin the D permission:
{ "event": "Chat_Message", "role": "Admin", "ops": ["D"] }An Admin can delete any Chat_Message regardless of authorship. The Self entry only applies when the actor's identity matches the target event's from.
The node MUST reject Delete if:
Deleting an Already-Updated Event:
Deleting an event that was previously updated is allowed. Delete targets the original Content Event, and the SMT status changes from "updated" to "deleted". All associated Update events become orphaned — they remain in the log but reference a deleted event. Clients querying the original event will see "deleted" status.
AC (Access Control) events modify the RBAC state or lifecycle of an enclave. All AC event types are predefined by the ENC protocol and understood by the node.
Events that modify role assignments (stored in SMT):
| Type | Effect | Content Structure |
|---|---|---|
| Manifest | Declare the AC schema and initialize the AC state | (see above) |
| Grant | Grant role to an identity (pull model) | { "role": "<name>", "identity": "<id_pub>" } |
| Grant_Push | Grant role with push delivery (push model) | { "role": "<name>", "identity": "<id_pub>", "endpoint": "<url>" } |
| Revoke | Revoke role from an identity | { "role": "<name>", "identity": "<id_pub>" } |
| Revoke_Self | Remove role from self (identity implicit from from) | { "role": "<name>" } |
| Move | Atomically change an identity's role set | { "identity": "<id_pub>", "from": "<bitmask>", "to": "<bitmask>" } |
| Force_Move | Privileged role change (bypasses target_roles) | { "identity": "<id_pub>", "from": "<bitmask>", "to": "<bitmask>" } |
| Transfer_Owner | Transfer ownership to another identity | { "new_owner": "<id_pub>" } |
| AC_Bundle | Atomic bundle of RBAC operations (all-or-nothing) | { "operations": [...] } |
Events that control enclave state (see Enclave Lifecycle):
| Type | Effect | Content Structure |
|---|---|---|
| Pause | Transition to Paused state | {} |
| Resume | Transition to Active state | {} |
| Terminate | Transition to Terminated state | {} |
| Migrate | Transition to Migrated state; authorize new sequencer | See Migration |
Lifecycle state is derived from the event log, not stored in SMT.
For Grant, Grant_Push, Revoke, Revoke_Self:
content.role is in target_roles for that schema entryThis prevents privilege escalation — an Admin with target_roles: ["Member"] cannot grant the Admin role.
Idempotent behavior:
Revoke vs Revoke_Self:
Revoke — actor revokes a role from another identity; requires C permission for Revoke with matching target_rolesRevoke_Self — actor revokes a role from themselves; requires C permission for Revoke_Self; identity is implicit (taken from from field)Revoke_Self exists to allow members to leave voluntarily without needing permission to revoke others. An Admin with Revoke permission could also use Revoke targeting themselves, but Revoke_Self provides a more limited permission for self-removal only.
Owner Cannot Self-Revoke:
Revoke_Self MUST NOT target the Owner role. The node MUST reject Revoke_Self where content.role = "Owner". An enclave must always have an Owner for governance. Use Transfer_Owner to change ownership, or Terminate to end the enclave.
Error Response:
{ "type": "Error", "code": "OWNER_SELF_REVOKE_FORBIDDEN", "message": "Owner role cannot be self-revoked" }Revoke_Self Last Role:
If Revoke_Self removes the identity's last role, the SMT leaf is removed (bitmask = 0). Any active P/N delivery registered via Grant_Push stops. The identity becomes "not in tree" (same as if never granted).
Grant_Push specifics:
Grant) AND registers the endpoint for P/N deliveryGrant_Push for this role, the new endpoint supersedes the previousRevoke to remove the role and stop push deliveryGrant_Push to Existing Role Holder:
If the identity already has the role (from Grant or previous Grant_Push):
Endpoint Storage and Migration:
Node maintains two internal data structures:
(identity, role) → endpoint - Maps role assignments to webhook endpoints - Reconstructed during migration by replaying Grant_Push events - Only the most recent Grant_Push for each (identity, role) pair is active(identity, url) → event queue - Aggregates events from all enclaves where identity has P/N permission at this endpoint - Each queue has its own push_seq counter - See node-api.md for delivery semanticsThe lookup table determines WHERE to deliver; the queue aggregates WHAT to deliver.
Endpoint Supersession Semantics:
When Grant_Push registers a new endpoint for an already-held role:
(identity, role) → new_endpointThe switch is immediate upon finalization — no grace period. Recipients MUST be idempotent and use push_seq to detect gaps during endpoint transitions.
Endpoint URL Requirements:
https or wss (no plaintext HTTP/WS)INVALID_ENDPOINT_URLGrant_Push Atomicity:
Grant_Push is atomic: if endpoint URL validation fails, the entire operation fails (no RBAC change). The role is only granted if the endpoint is successfully registered.
For Move:
Movecontent.from (reject if mismatch)content.to are within actor's target_rolesThe from field in Move content records the previous state, enabling role history tracking and restoration.
Move to Zero Bitmask:
Move with content.to = 0x0 is allowed. This atomically removes all roles from the identity (equivalent to revoking all roles at once). The SMT leaf is removed. Use case: demote an identity to no roles in a single operation.
Bitmask Mismatch Error:
If Move or Force_Move fails due to bitmask mismatch, the error response includes both values:
{ "type": "Error", "code": "BITMASK_MISMATCH", "expected": "0x...", "actual": "0x..." }For Force_Move:
Force_Move (typically Owner only)content.from (reject if mismatch)content.from nor content.to includes the Owner bit (cannot touch Owner role)target_roles restriction — can move to any non-Owner roleIf step 3 fails, reject with: { "type": "Error", "code": "OWNER_BIT_PROTECTED", "message": "Owner role cannot be modified by Force_Move" }
Force_Move is intended for emergency situations where normal RBAC hierarchy is insufficient.
For Transfer_Owner:
new_ownerTransfer_Owner is irreversible — the new owner would need to transfer back.
Self-Transfer:
If Transfer_Owner specifies new_owner equal to the current Owner, the operation succeeds as a no-op (idempotent). No SMT changes occur.
New Owner with Existing Roles:
If new_owner already has roles (e.g., Admin + Member), Transfer_Owner adds the Owner bit to their existing roles. The operation is: new_owner.roles |= Owner. Existing roles are preserved.
Transfer_Owner vs Migrate:
Transfer_Owner changes WHO can issue Migrate, Pause, etc. It does NOT change the sequencer node. The sequencer public key remains the same; only the Owner role is reassigned. No Registry update is required.
Contrast with Migrate: changes the sequencer node, DOES require Reg_Enclave update.
{
"operations": [
{ "type": "Grant", "role": "Member", "identity": "<id_pub_1>" },
{ "type": "Grant", "role": "Member", "identity": "<id_pub_2>" },
{ "type": "Revoke", "role": "Member", "identity": "<id_pub_3>" }
]
}Allowed operation types:
Operation Fields by Type:
| Type | Fields |
|---|---|
| Grant | { "type": "Grant", "role": "<name>", "identity": "<id_pub>" } |
| Grant_Push | { "type": "Grant_Push", "role": "<name>", "identity": "<id_pub>", "endpoint": "<url>" } |
| Revoke | { "type": "Revoke", "role": "<name>", "identity": "<id_pub>" } |
| Revoke_Self | { "type": "Revoke_Self", "role": "<name>" } |
| Move | { "type": "Move", "identity": "<id_pub>", "from": "<bitmask>", "to": "<bitmask>" } |
| Force_Move | { "type": "Force_Move", "identity": "<id_pub>", "from": "<bitmask>", "to": "<bitmask>" } |
Not allowed in AC_Bundle:
Size Limit:
The protocol does not define a maximum number of operations per AC_Bundle. Nodes MAY impose implementation-defined limits for resource protection (e.g., 1000 operations). Clients SHOULD handle rejection gracefully and split large bundles if needed.
Processing:
Authorization Timing:
Each operation in an AC_Bundle is authorized against the state AFTER all preceding operations in the bundle have been applied. This enables dependent operations:
{
"operations": [
{ "type": "Grant", "role": "Member", "identity": "<id>" },
{ "type": "Move", "identity": "<id>", "from": "0x200000000", "to": "0x300000000" }
]
}Here, the Move operation sees the state after Grant, so it can move the identity that was just granted a role.
Atomicity (Simulated Sequential Validation):
AC_Bundle uses simulated sequential validation:
If any operation fails:
{ "type": "Error", "code": "AC_BUNDLE_FAILED", "failed_index": N, "reason": "..." }Concurrency Note (TOCTOU):
Between client simulation and node application, other commits may change RBAC state. The protocol does NOT guarantee that a successfully-simulated AC_Bundle will succeed when submitted. Clients MUST:
Wildcard Validation:
Operations in AC_Bundle MUST specify explicit event types (Grant, Revoke, etc.). Wildcard patterns (*) are NOT allowed in operation type fields — wildcards apply only to schema entries for P/N delivery.
Content Events are application-defined event types that carry data without affecting enclave state.
Characteristics:
Chat_Message, Post, Comment, ReactionIdentifying Content Events:
An event is a Content Event if and only if its type is NOT one of the predefined AC event types:
Manifest, Grant, Grant_Push, Revoke, Revoke_Self, Move, Force_Move, Transfer_Owner, AC_BundlePause, Resume, Terminate, MigrateUpdate, DeleteAny other type value (e.g., Chat_Message, Post) is a Content Event. This is determined by string comparison against the predefined list — no schema lookup is needed.
Schema example:
{ "event": "Chat_Message", "role": "Member", "ops": ["C", "R"] },
{ "event": "Chat_Message", "role": "Self", "ops": ["U", "D"] }The Registry is a special enclave that maps Enclave IDs to the Node endpoints that host them, and may optionally attach descriptive metadata.
enclave_id → node(s)id_pub → enclave(s)A registry entry MAY include:
enclave_id — canonical enclave identifiernodes — hosting node endpoints or identifiersapp (optional) — application that created or uses the enclavecreator (optional) — identity key that initialized the enclavedesc (optional) — human-readable descriptionmeta (optional) — application-defined metadataReg_Node, Reg_Enclave, and Reg_Identity are Content Events within the Registry enclave. They can be Updated (to change metadata) or Deleted (to deregister) per the Registry's RBAC schema.
The Registry maintains one active entry per resource:
seq_pubenclave_idid_pubA new submission supersedes any existing entry for the same key. The old entry is automatically marked as updated in SMT (no explicit Update event needed).
Registry-Specific Behavior:
This implicit update is a Registry-only exception. The Registry node internally marks old entries as updated in SMT when a new entry for the same key is submitted. Normal enclaves require explicit Update events to modify event status.
SMT Update Entry:
When a new Reg_Node, Reg_Enclave, or Reg_Identity supersedes an existing entry:
SMT[old_event_id] = new_event_idClients can query the old event ID and discover it was superseded, then follow the chain to the current entry.
Implementation Note:
The Registry node maintains a secondary index: key → current_event_id. On new submission:
SMT[old_event_id] = new_event_idThis is internal to Registry operation — the SMT update follows normal semantics.
Querying Registry:
Clients query Registry by key (seq_pub for nodes, enclave_id for enclaves, id_pub for identities). The Registry returns the latest active entry for that key. Historical entries are available in the event log but superseded entries are marked as updated in SMT.
The Registry uses a minimal RBAC schema:
{
"enc_v": 1,
"RBAC": {
"use_temp": "none",
"schema": [
{ "event": "*", "role": "Any", "ops": ["R"] },
{ "event": "*", "role": "DataView", "ops": ["P"] },
{ "event": "Reg_Node", "role": "Any", "ops": ["C"] },
{ "event": "Reg_Node", "role": "Self", "ops": ["U", "D"] },
{ "event": "Reg_Enclave", "role": "Any", "ops": ["C"] },
{ "event": "Reg_Enclave", "role": "Self", "ops": ["U", "D"] },
{ "event": "Reg_Identity", "role": "Any", "ops": ["C"] },
{ "event": "Reg_Identity", "role": "Self", "ops": ["U", "D"] },
{ "event": "Grant_Push", "role": "Owner", "ops": ["C"], "target_roles": ["DataView"] },
{ "event": "Revoke", "role": "Owner", "ops": ["C"], "target_roles": ["DataView"] },
{ "event": "Transfer_Owner", "role": "Owner", "ops": ["C"] },
{ "event": "Pause", "role": "Owner", "ops": ["C"] },
{ "event": "Resume", "role": "Owner", "ops": ["C"] },
{ "event": "Terminate", "role": "Owner", "ops": ["C"] }
],
"initial_state": {
"Owner": ["<registry_owner_id>"],
"DataView": [{ "identity": "<dataview_identity>", "endpoint": "<dataview_url>" }]
}
}
}Authorization rules:
* wildcard R)* wildcard P) to serve the REST APIseq_pub)id_pub)Self for Reg_Node is evaluated against content.seq_pubSelf for Reg_Enclave is evaluated against content.manifest_event.fromSelf for Reg_Identity is evaluated against content.id_pubStrict Self-Authorization:
For Reg_Node: the commit's from field MUST equal content.seq_pub. One identity cannot register another node.
For Reg_Enclave: the commit's from field MUST equal content.manifest_event.from. One identity cannot register another's enclave.
For Reg_Identity: the commit's from field MUST equal content.id_pub. One identity cannot register enclaves for another.
Reg_Enclave and Ownership Transfer:
The Registry tracks the original creator (manifest_event.from) as the default authorized identity. However, after Transfer_Owner, the new Owner can update the Registry by providing an owner_proof.
Update Scenarios:
| Scenario | from field | owner_proof |
|---|---|---|
| Original creator updates | manifest_event.from | Not required |
| New Owner updates (no migration) | New Owner's key | Required (SMT proof of Owner bit) |
| New Owner updates (after migration) | New Owner's key | Required (SMT proof + migrate_event) |
See the Authorization section in Reg_Enclave for verification details.
Registry Owner:
The registry_owner_id is the identity operating the Registry service. In the current centralized design:
Transfer_Owner, Pause, Resume, TerminateSelf identitiesNotes:
id_pub → enclaves. The commit must be signed by id_pub. This is optional but enables discovery of an identity's enclavesType: Reg_Node
Purpose: Register a node in the Registry for discovery.
{
"seq_pub": "<node's sequencer public key>",
"endpoints": [
{
"uri": "https://node.example.com: 443",
"priority": 1
},
{
"uri": "https://1.2.3.4: 443",
"priority": 2
}
],
"protocols": ["https", "wss"],
"enc_v": 1
}| Field | Required | Description |
|---|---|---|
| seq_pub | Yes | Node's sequencer public key |
| endpoints | Yes | Array of endpoints, ordered by priority (lower = preferred) |
| endpoints[].uri | Yes | Full URI including protocol and port |
| endpoints[].priority | No | Resolution order (default: array index) |
| protocols | No | Supported transport protocols |
| enc_v | Yes | ENC protocol version |
| Field | Max |
|---|---|
| endpoints array | 10 entries |
| endpoints[].uri | 2048 characters |
| protocols array | 10 entries |
Nodes MUST reject Reg_Node commits exceeding these limits.
seq_pub (proves ownership)from field MUST equal seq_pubType: Reg_Enclave
Purpose: Register an enclave in the Registry for discovery.
{
"manifest_event": {
"id": "<event_id>",
"hash": "<commit_hash>",
"enclave": "<enclave_id>",
"from": "<creator_id_pub>",
"type": "Manifest",
"content": "...",
"exp": 1706000000000,
"tags": [],
"timestamp": 1706000000000,
"sequencer": "<sequencer_id_pub>",
"seq": 0,
"sig": "<creator_signature>",
"seq_sig": "<sequencer_signature>"
},
"owner_proof": {
"sth": {
"t": 1706000000000,
"ts": 500,
"r": "<ct_root_hex>",
"sig": "<sth_signature_hex>"
},
"ct_proof": {
"ts": 500,
"li": 499,
"p": ["<hex64>", ...]
},
"state_hash": "<hex64>",
"events_root": "<hex64>",
"smt_proof": {
"k": "<hex42>",
"v": "<hex64>",
"b": "<hex42>",
"s": ["<hex64>", ...]
}
},
"app": "my-chat-app",
"desc": "A group chat for project X",
"meta": {}
}| Field | Required | Description |
|---|---|---|
| manifest_event | Yes | The finalized Manifest event (full event structure) |
| owner_proof | No | Proof of current Owner status (required if from ≠ manifest_event.from) |
| app | No | Application identifier |
| desc | No | Human-readable description |
| meta | No | Application-defined metadata |
The owner_proof field allows the current Owner to submit Reg_Enclave even if they are not the original creator. This is required after Transfer_Owner when the new Owner needs to update the Registry.
| Field | Required | Description |
|---|---|---|
| sth | Yes | Signed Tree Head from the enclave's sequencer |
| ct_proof | Yes | CT inclusion proof binding state_hash to the signed root |
| state_hash | Yes | SMT root hash at the proven tree position |
| events_root | Yes | Merkle root of event IDs in the bundle |
| smt_proof | Yes | SMT membership proof showing from has Owner bit |
| migrate_event | No | Required if sequencer changed since Manifest (proves sequencer transition) |
The Reg_Enclave commit can be authorized in two ways:
Path 1: Original Creator (no owner_proof)
from field MUST equal manifest_event.frommanifest_event.sig is validPath 2: Current Owner (with owner_proof)
from field MAY differ from manifest_event.fromowner_proof field MUST be present and validManifest Signature Verification (both paths):
The manifest_event.sig signs the Manifest commit hash:
_content_hash = sha256(utf8_bytes(manifest_event.content)) commit_hash = H(0x10, enclave_id, from, "Manifest", _content_hash, exp, tags) verify: schnorr_verify(commit_hash, manifest_event.sig, manifest_event.from)
Owner Proof Verification (Path 2 only):
message = "enc:sth:" || be64(sth.t) || be64(sth.ts) || hex_decode(sth.r) verify: schnorr_verify(sha256(message), sth.sig, manifest_event.sequencer)
H(0x00, events_root, state_hash) - Verify CT inclusion proof against sth.r using RFC 9162 algorithm - This binds state_hash to the signed tree0x00 || sha256(from)[0:160 bits] (RBAC namespace, 21 bytes total) - Verify smt_proof.k equals expected key - Verify SMT proof against state_hash - Verify smt_proof.v has Owner bit (bit 1) setmanifest_event.enclave MUST match the enclave ID in Registry lookup - This prevents using an owner proof from a different enclavePost-Migration Updates:
After migration, the sequencer changes. To update Registry after migration:
migrate_event field in owner_proof: {
"owner_proof": {
"migrate_event": {
"id": "<migrate_event_id>",
"type": "Migrate",
"content": { "new_sequencer": "<new_sequencer_pub>", ... },
"sequencer": "<new_sequencer_pub>",
"seq_sig": "<new_sequencer_signature>",
...
},
"sth": { ... },
"ct_proof": { ... },
...
}
}migrate_event.content.new_sequencer = new sequencer public key - migrate_event.seq_sig is valid signature by new sequencer - migrate_event.enclave matches manifest_event.enclavemigrate_event.sequencer (new sequencer)Chained Migrations:
If multiple migrations occurred, only the most recent migrate_event is needed. The new sequencer's STH authenticates the current state, which includes the full history.
manifest_event provides enclave identity and original creatormigrate_event (if present) proves sequencer transitionRegistry extracts:
enclave_id = manifest_event.enclavesequencer = owner_proof.migrate_event.content.new_sequencer if migrate_event is present, otherwise manifest_event.sequencercreator = manifest_event.fromType: Reg_Identity
Purpose: Register an identity's enclaves in the Registry for discovery.
{
"id_pub": "a1b2c3...",
"enclaves": {
"personal": "<enclave_id>",
"dm": "<enclave_id>"
}
}| Field | Required | Description |
|---|---|---|
| id_pub | Yes | Identity public key |
| enclaves | No | Map of label → enclave_id (named enclave references) |
The enclaves field is a free-form map of string keys to enclave IDs. Applications use this to associate named enclaves with an identity.
Common keys:
personal — the identity's personal enclave (see Appendix A)The map is application-defined; the Registry stores but does not interpret the keys.
| Field | Max |
|---|---|
| enclaves map | 256 entries |
| enclaves key | 64 characters |
Nodes MUST reject Reg_Identity commits exceeding these limits.
id_pub (proves key ownership)from field MUST equal id_pubid_pub (replaces previous)Migration transfers an enclave from one node to another while preserving the full event history and enclave identity.
Type: Migrate
Content Structure:
{
"new_sequencer": "<new_seq_pub>",
"prev_seq": 1234,
"ct_root": "<ct_root_at_prev_seq>"
}Fields:
| Field | Required | Description |
|---|---|---|
| new_sequencer | Yes | Public key of the new sequencer node |
| prev_seq | Yes | Sequence number of the last event BEFORE the Migrate event (Migrate will have seq = prev_seq + 1) |
| ct_root | Yes | CT root at prev_seq (proves log and state before Migrate) |
Authorization:
Migration Barrier:
Once a node accepts a Migrate commit:
Bundle Handling:
The Migrate event does NOT need to be alone in its bundle. Events accepted before the Migrate commit may be in the same bundle. The bundle closes immediately after Migrate is finalized, regardless of bundle.size or bundle.timeout configuration.
Example with bundle.size = 10:
This ensures a clean handoff with no ambiguity about which events belong to which sequencer.
Peaceful Handoff (old node online):
Migrate commit to old nodect_rootprev_seq + 2 (Migrate event was prev_seq + 1)Reg_Enclave)Forced Takeover (old node offline):
Migrate commit (unfinalized)Reg_Enclave)In forced mode, the sequencer field of the Migrate event will be the NEW node, not the old one. This is the only event type where sequencer discontinuity is allowed.
Forced Takeover Verification:
Before finalizing Migrate, the new node MUST rebuild state from scratch:
state_hash in final bundlefrom field of the Migrate commit has Owner bit set in the computed SMT (RBAC namespace)ct_root in Migrate commitsig) is valid for the computed commit hashprev_seq equals the last event's sequence number in the logIf any check fails, the new node MUST reject the migration. This full replay ensures the new node has a correct, verified copy of enclave state.
The ct_root field serves as a checkpoint:
state_hash in leaves)After migration:
Client Recovery after Migrate:
If a client queries the old node after Migrate:
ENCLAVE_NOT_FOUND on commitNo explicit "migrated" error is required on reads; normal operation continues until the client syncs with Registry.
To enable forced takeover, ensure someone has the full event log:
Option 1: Owner maintains backup
Option 2: Dedicated Backup role
Schema example:
{ "event": "*", "role": "Backup", "ops": ["P"] }The wildcard * means Backup receives push for ALL event types. This enables disaster recovery if the node goes offline.
TODO: Endpoints and request formats assigned to Tomo
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) |
Defined Error Codes:
| Code | Context Fields | Description |
|---|---|---|
OWNER_SELF_REVOKE_FORBIDDEN | — | Revoke_Self cannot target Owner role |
OWNER_BIT_PROTECTED | — | Force_Move cannot modify Owner bit |
BITMASK_MISMATCH | expected, actual | Move/Force_Move from bitmask doesn't match current state |
AC_BUNDLE_FAILED | failed_index, reason | AC_Bundle operation failed at index |
INVALID_ENDPOINT_URL | — | Grant_Push endpoint URL is invalid |
COMMIT_EXPIRED | — | Commit exp time has passed |
DUPLICATE_COMMIT | — | Commit hash already processed |
UNAUTHORIZED | — | Sender lacks required permission |
ENCLAVE_PAUSED | — | Enclave is paused, only Resume/Terminate/Migrate accepted |
ENCLAVE_NOT_FOUND | — | Enclave ID not found on this node |
Predefined RBAC templates for common enclave patterns.
When use_temp is set in Manifest content, the node uses the referenced template instead of the explicit schema field.
| Template | Description |
|---|---|
none | No template; use explicit schema |
v1 Scope:
In ENC v1, only none is supported. Additional templates (e.g., chat, forum, personal) are planned for future versions. Nodes MUST reject Manifests with unrecognized use_temp values. For the recommended personal enclave schema using explicit none template, see Appendix A.
This section is non-normative. It describes common usage patterns, not protocol requirements.
A Shared Enclave is an enclave whose data and access-control are intended to be jointly used by multiple identities.
Typical characteristics
Examples
A Personal Enclave is an enclave whose data is logically owned and controlled by a single identity, even if many others can read or contribute under permission.
Typical characteristics
Examples
The RBAC schema is fixed at Manifest creation. Rather than defining a specific event type per content type (which would require a new enclave to add content types), the schema groups event types by access pattern — who can write and who can read. New content types (e.g., adding bookmark or reaction) only require defining a new kind under the appropriate access pattern, not modifying the enclave's RBAC schema.
Three access patterns cover all personal enclave use cases:
| Event Type | Writer | Reader | Purpose |
|---|---|---|---|
public | Owner | Anyone | Posts, profile, follows |
private | Owner | Owner | Config, blocks, mutes |
inbox | Anyone (C only) | Owner (R, D) | DM requests, incoming messages |
Full schema:
{
"enc_v": 1,
"RBAC": {
"use_temp": "none",
"schema": [
{ "event": "public", "role": "Owner", "ops": ["C", "U", "D"] },
{ "event": "public", "role": "Any", "ops": ["R"] },
{ "event": "public", "role": "DataView", "ops": ["P"] },
{ "event": "private", "role": "Owner", "ops": ["C", "R", "U", "D"] },
{ "event": "inbox", "role": "Any", "ops": ["C"] },
{ "event": "inbox", "role": "Owner", "ops": ["R", "D"] },
{ "event": "inbox", "role": "DataView", "ops": ["P"] },
{ "event": "Grant", "role": "Owner", "ops": ["C"], "target_roles": ["DataView"] },
{ "event": "Grant_Push", "role": "Owner", "ops": ["C"], "target_roles": ["DataView"] },
{ "event": "Revoke", "role": "Owner", "ops": ["C"], "target_roles": ["DataView"] },
{ "event": "Transfer_Owner", "role": "Owner", "ops": ["C"] },
{ "event": "Terminate", "role": "Owner", "ops": ["C"] },
{ "event": "Pause", "role": "Owner", "ops": ["C"] },
{ "event": "Resume", "role": "Owner", "ops": ["C"] }
]
}
}Design notes:
public events are readable by anyone — suitable for posts, profile, and follow listsprivate events are readable only by the Owner — suitable for configuration, block lists, and muted usersinbox events are writable by anyone but readable only by the Owner — suitable for DM requests and incoming messages. The Owner can Delete inbox events to clean upkind convention (below), not by modifying the schemakind ConventionSince the RBAC schema defines only 3 broad event types, applications distinguish content subtypes using a kind field inside content:
{
"kind": "post",
"text": "Hello world",
"created_at": 1706000000000
}Recommended kinds by event type:
| Event Type | kind | Description |
|---|---|---|
public | post | A public post or note |
public | profile | Profile metadata (name, bio, avatar URL) |
public | follow | Follow declaration |
private | config | Application configuration |
private | block | Block list entry |
inbox | dm_request | Direct message or DM request |
The kind field is application-defined — the node does not validate it. Applications MAY define additional kinds as needed.
After creating a personal enclave, the owner SHOULD register their identity via Reg_Identity (see Section 8) with the enclaves.personal field pointing to the personal enclave. This enables other users to discover the enclave by public key:
id_pub → Reg_Identity → enclaves.personal → enclave_id → Reg_Enclave → node
Use Shared when:
Use Personal when:
DM is a messaging pattern built on personal enclaves, not a shared conversation enclave.
Each identity maintains a personal mailbox enclave that receives incoming messages.
How it works
Properties
Implication
DM is modeled as:
> "write into the recipient's enclave, optionally mirror into the sender's enclave"
—not:
> "append to a shared conversation log"
There is no mandatory "type" field. You MAY include a hint in metadata (e.g., meta.enclave_kind = "shared" | "personal"), but clients must not rely on it for security decisions.
Machine-readable registry of predefined event types. Content Events are any type NOT in this list.
{
"version": 1,
"predefined_types": {
"ac_events": [
"Manifest",
"Grant",
"Grant_Push",
"Revoke",
"Revoke_Self",
"Move",
"Force_Move",
"Transfer_Owner",
"AC_Bundle"
],
"lifecycle_events": [
"Pause",
"Resume",
"Terminate",
"Migrate"
],
"mutation_events": [
"Update",
"Delete"
],
"registry_events": [
"Reg_Node",
"Reg_Enclave",
"Reg_Identity"
]
}
}Usage:
To determine if an event is a Content Event:
is_content_event = type NOT IN predefined_types.ac_events
AND type NOT IN predefined_types.lifecycle_events
AND type NOT IN predefined_types.mutation_events
registry_events (Reg_Node, Reg_Enclave, Reg_Identity) ARE Content Events within the Registry enclave. They can be Updated or Deleted per the Registry's RBAC schema. They are listed separately in the registry for documentation purposes, not because they have special protocol behavior.This registry is authoritative for ENC v1. Future protocol versions may extend this list.
REST and WebSocket endpoints, proof retrieval, webhook delivery, sessions, filters, encryption.
REST and WebSocket API for ENC protocol nodes.
API
Appendix
https://<node_host>/
All requests and responses use application/json.
| Operation | Method |
|---|---|
| Commit | Schnorr signature over commit hash |
| Query | Session token (see Session) |
| Pull | Session token |
| WebSocket Query | Session token |
| WebSocket Commit | Schnorr signature |
Enclave API (all enclaves):
| Method | Path | Description |
|---|---|---|
| POST | / | Submit commit, query, or pull request |
| WS | / | Real-time subscriptions |
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 |
Registry DataView API (Registry enclave only):
| Method | Path | Description |
|---|---|---|
| GET | /nodes/:seq_pub | Resolve node by public key |
| GET | /enclaves/:enclave_id | Resolve enclave to node |
| GET | /resolve/:enclave_id | Combined enclave + node lookup |
| GET | /identity/:id_pub | Resolve identity by public key |
Submit a commit to the enclave.
Detection: Request contains exp field.
Request:
{
"hash": "<hex64>",
"enclave": "<hex64>",
"from": "<hex64>",
"type": "<string>",
"content": "<any>",
"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 | any | Yes | Event content (type-specific) |
| exp | uint | Yes | Expiration timestamp (Unix milliseconds) |
| tags | array | No | Array of [key, value] pairs |
| sig | hex128 | Yes | Schnorr signature over hash |
content_hash is NOT transmitted. The node computes content_hash = sha256(utf8_bytes(content)) for commit hash verification (see spec.md).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 |
enclave for privacy — client already knows which enclave it submitted to.Errors:
| 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 |
Query events from the enclave.
Detection: Request contains type: "Query" field.
Request:
{
"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) |
Content (plaintext):
{
"session": "<hex136>",
"filter": { ... }
}| Field | Type | Required | Description |
|---|---|---|---|
| session | hex136 | Yes | Session token (see Session) |
| filter | object | Yes | Query filter (see Filter) |
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>" },
...
]
}| 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 |
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 |
Real-time pub/sub for event subscriptions.
Endpoint: wss://<node_host>/
Subscription is automatic: First valid Query on a connection creates a subscription. Node assigns sub_id and begins streaming events.
| Direction | Type | Description |
|---|---|---|
| C → N | Query | First valid Query subscribes, node assigns sub_id |
| 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 |
| Message | Format | Description |
|---|---|---|
| Query | Same as POST / (Query) | First valid Query subscribes, node assigns sub_id |
| Commit | Same as POST / (Commit) | Write event, returns Receipt |
| Close | { "type": "Close", "sub_id": "<string>" } | Unsubscribe from subscription |
Close unsubscribes from a single subscription. To close the entire WebSocket connection, close the WebSocket transport directly. Closing the transport terminates all active subscriptions.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:
| Reason | Description | Client Action |
|---|---|---|
access_revoked | Identity's read permission was revoked | Permanent; re-subscribe if permission restored |
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 |
Notice:
{
"type": "Notice",
"message": "<string>"
}On Query:
sub_id, store subscriptionEvent messages (encrypted)EOSE messageEvent to clientOn Commit:
Same as HTTP, returns Receipt.
On Close:
Remove subscription. Terminate connection if none remain.
Termination conditions:
Multi-key support:
from)| Aspect | HTTP | WebSocket |
|---|---|---|
| Query | One-time response | Subscribe + live updates |
| sub_id | N/A | Node assigns |
| Commit | Receipt | Receipt |
| Session | Per-request | Cached per connection |
| State | Stateless | Subscriptions |
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 |
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.
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) |
Response (200 OK):
{
"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 |
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>"
}| 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 |
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>"
}| 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 |
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
}| 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) |
Response Content (plaintext):
{
"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 |
Verification Flow:
To fully verify a state proof is authentic and from the requested tree position:
state_hash (see proof.md)leaf_index via POST /inclusionH(0x00, events_root, state_hash) and verify against signed CT rootThis 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 |
Node delivers Push messages via HTTPS POST to registered endpoints. See Appendix: Push/Notify for design rationale.
Grants a role and registers webhook endpoint. See spec.md for event structure and content fields.
Node maintains queue per (identity, url):
push_seq per queueseq_priv for encryptionMultiple Endpoints:
An identity MAY register multiple webhook endpoints via separate Grant_Push events. 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_Push 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_Push changes the webhook URL (new Grant_Push with same role, different URL):
1. Enclave submits Grant_Push event (grants role + registers url) 2. Node adds enclave to (identity, url) queue if not exists 3. On new event, node checks role's P/N permissions 4. Node aggregates events into (identity, url) queue 5. Node periodically POSTs Push to url 6. Node increments push_seq
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>"
}| Field | Type | Description |
|---|---|---|
| type | string | Always "Push" |
| from | hex64 | Sequencer public key |
| to | hex64 | Recipient identity |
| url | string | Webhook URL |
| content | string | Encrypted payload |
Content (plaintext):
{
"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 may be omitted if empty.
Encryption: See Encryption.
Delivery semantics:
event.id (for push) or track last synced seq (for notify)Expected response: 200 OK
If webhook delivery fails, recipient can pull missed batches.
Request:
{
"type": "Pull",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}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>"
}| 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 |
push_seq formats:
6 — returns batch 6[6, 7, 8] — returns batches 6, 7, 8{ "start_after": 5, "end_at": 7 } — returns batches 6, 7Response:
{
"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 documentedpush_seq ascendingenclave filter is provided, only events from that enclave are included in each batchenclave is omitted, all enclaves in the original batch are includedRetry 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.
The Registry is an enclave with a DataView server that provides discovery endpoints. These endpoints are served by the Registry's DataView, not by the generic Enclave API.
Base URL: Registry node endpoint (discovered via bootstrap)
Resolve node by sequencer public key.
Path Parameters:
| Param | Type | Description |
|---|---|---|
| seq_pub | hex64 | Sequencer public key |
Request:
GET /nodes/a1b2c3...
Response (200 OK):
{
"seq_pub": "<hex64>",
"endpoints": [
{ "uri": "https://node.example.com", "priority": 1 },
{ "uri": "https://backup.example.com", "priority": 2 }
],
"protocols": ["https", "wss"],
"enc_v": 1
}| Field | Type | Description |
|---|---|---|
| seq_pub | hex64 | Sequencer public key |
| endpoints | array | Endpoints sorted by priority (1 = highest) |
| endpoints[].uri | string | Endpoint URI |
| endpoints[].priority | uint | Priority (lower = preferred) |
| protocols | array | Supported protocols |
| enc_v | uint | ENC protocol version |
Errors:
| Code | HTTP | Description |
|---|---|---|
NODE_NOT_FOUND | 404 | Node not registered |
Resolve enclave to hosting node.
Path Parameters:
| Param | Type | Description |
|---|---|---|
| enclave_id | hex64 | Enclave identifier |
Request:
GET /enclaves/d4e5f6...
Response (200 OK):
{
"enclave_id": "<hex64>",
"sequencer": "<hex64>",
"creator": "<hex64>",
"created_at": 1706000000000,
"app": "chat",
"desc": "Team chat",
"meta": {}
}| Field | Type | Required | Description |
|---|---|---|---|
| enclave_id | hex64 | Yes | Enclave identifier |
| sequencer | hex64 | Yes | Current sequencer public key |
| creator | hex64 | No | Creator's identity key |
| created_at | uint | No | Creation timestamp (Unix milliseconds) |
| app | string | No | Application identifier |
| desc | string | No | Human-readable description |
| meta | object | No | Application-defined metadata |
Errors:
| Code | HTTP | Description |
|---|---|---|
ENCLAVE_NOT_FOUND | 404 | Enclave not registered |
Combined lookup: enclave → node (convenience endpoint).
Path Parameters:
| Param | Type | Description |
|---|---|---|
| enclave_id | hex64 | Enclave identifier |
Request:
GET /resolve/d4e5f6...
Response (200 OK):
{
"enclave": {
"enclave_id": "<hex64>",
"sequencer": "<hex64>",
"creator": "<hex64>",
"created_at": 1706000000000,
"app": "chat",
"desc": "Team chat",
"meta": {}
},
"node": {
"seq_pub": "<hex64>",
"endpoints": [
{ "uri": "https://node.example.com", "priority": 1 }
],
"protocols": ["https", "wss"],
"enc_v": 1
}
}Errors:
| Code | HTTP | Description |
|---|---|---|
ENCLAVE_NOT_FOUND | 404 | Enclave not registered |
NODE_NOT_FOUND | 404 | Sequencer node not registered |
Resolve identity by public key. Returns the identity's registered enclaves.
Path Parameters:
| Param | Type | Description |
|---|---|---|
| id_pub | hex64 | Identity public key |
Request:
GET /identity/a1b2c3...
Response (200 OK):
{
"id_pub": "<hex64>",
"enclaves": {
"personal": "<enclave_id>",
"dm": "<enclave_id>"
}
}| Field | Type | Description |
|---|---|---|
| id_pub | hex64 | Identity public key |
| enclaves | object | Map of label → enclave_id |
Errors:
| Code | HTTP | Description |
|---|---|---|
IDENTITY_NOT_FOUND | 404 | Identity not registered |
Session tokens provide stateless authentication for queries.
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)
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))
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.
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_pub)signer_pub) is enclave-specificThis is intentional and provides defense-in-depth.
| 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.
| 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 |
Query filter for event retrieval.
{
"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.
{
"start_at": 100, // >= 100
"start_after": 100, // > 100
"end_at": 200, // <= 200
"end_before": 200 // < 200
}| Pattern | Meaning |
|---|---|
| Top-level fields | AND |
| Array values | OR |
| Omitted field | Match all |
| Field | Max |
|---|---|
id[] | 100 |
seq[] | 100 |
type[] | 20 |
from[] | 100 |
tags keys | 10 |
tags values per key | 20 |
limit | 1000 |
By type:
{ "type": "Chat_Message" }By authors:
{ "type": "Chat_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": "Chat_Message", "reverse": true, "limit": 20 }Client-node communication is encrypted using ECDH + XChaCha20Poly1305.
| 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" |
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)
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)
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)
| Primitive | Specification |
|---|---|
| ECDH | secp256k1 |
| HKDF | HKDF-SHA256 |
| AEAD | XChaCha20Poly1305 |
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)
XChaCha20Poly1305 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.
| 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 |
{
"type": "Error",
"code": "<CODE>",
"message": "<human readable>"
}| 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:
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.
A DataView server may have P/N permissions across hundreds of enclaves on the same node. Naive approach — one HTTP request per event per enclave — creates massive overhead.
Node aggregates events into a single queue per (identity, url) pair.
Enclave A ──┐ Enclave B ──┼──► Queue (identity, url) ──► Single POST to url Enclave C ──┘
| 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 |
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:
push_seq: { "start_after": 5, "end_at": 7 } returns both batches in one requestThis is why the queue is per (identity, url) not per enclave — it enables O(1) state tracking regardless of how many enclaves you're subscribed to.
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 |
| 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 |
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 you only have N (no R), you know new events exist but cannot read their content. This is useful for:
{ "seq": { "start_after": last_synced_seq } } (requires R permission)Key: (identity, url)
Value: { push_seq, enclaves[] }
For each enclave, node tracks which events to include based on role permissions.
CT inclusion, consistency, bundle membership, SMT state proofs — verification algorithms and wire formats.
This document specifies proof formats for the ENC protocol.
The ENC protocol uses two types of Merkle proofs:
| Proof Type | Purpose | Tree |
|---|---|---|
| CT Inclusion | Prove event exists at position N in log | Certificate Transparency |
| CT Consistency | Prove earlier log is prefix of current log | Certificate Transparency |
| SMT Membership | Prove key-value pair exists in state | Sparse Merkle Tree |
| SMT Non-membership | Prove key does not exist in state | Sparse Merkle Tree |
Proves that an event is part of a specific bundle.
Structure:
{
event_id: <32 bytes>,
bundle_index: <number>,
siblings: [<hash>, ...]
}
Where:
Verification:
hash = event_id, index = bundle_indexs in siblings array (from leaf toward root): - If index is even (LSB = 0): hash = H(0x01, hash, s) — current is left child - If index is odd (LSB = 1): hash = H(0x01, s, hash) — current is right child - index = index >> 1 (shift right by 1)hash == events_rootAlgorithm Notes:
bundle_index is the event's 0-indexed position within the bundleindex determines left (0) or right (1) at each levelindex right to get the parent's positionExample:
Bundle with 4 events, verifying event at bundle_index = 2:
events_root
/ \
h01 h23
/ \ / \
e0 e1 e2 e3 ← bundle_index: 0, 1, 2, 3
hash = e2, index = 2 (binary: 10)hash = H(0x01, hash, siblings[0]) where siblings[0] = e3index = 1hash = H(0x01, siblings[1], hash) where siblings[1] = h01hash should equal events_rootWith bundle.size = 1, the bundle contains one event, so siblings is empty and events_root = event_id.
Proves that a bundle exists at a specific position in the log.
Structure:
{
tree_size: <uint64>,
leaf_index: <uint64>,
path: [<hash>, ...]
}
Where:
Leaf Hash:
leaf_hash = H(0x00, events_root, state_hash)
Where events_root is the Merkle root of event IDs in the bundle, and state_hash is the SMT root after the bundle.
Verification (RFC 9162 Section 2.1.3.2):
fn = leaf_index, sn = tree_size - 1, r = leaf_hashp in path: - a. If sn == 0: FAIL (proof too long) - b. If LSB(fn) == 1 or fn == sn: - r = H(0x01, p, r) - While LSB(fn) == 0 and fn != 0: fn >>= 1; sn >>= 1 - c. Else: - r = H(0x01, r, p) - d. fn >>= 1; sn >>= 1sn == 0 and r == expected_rootTest Vector:
Tree size: 7, Leaf index: 5 Initial: fn=5, sn=6 Step 1 (p[0]): LSB(5)=1 → r=H(0x01,p[0],r), shift → fn=2, sn=3 Step 2 (p[1]): LSB(2)=0, fn≠sn → r=H(0x01,r,p[1]), shift → fn=1, sn=1 Step 3 (p[2]): fn==sn → r=H(0x01,p[2],r), shift → fn=0, sn=0 Final: sn=0 ✓, compare r to expected_root
Edge Cases:
fn = 0, sn = 0 - path is empty (no siblings) - Skip the loop and verify r == expected_root directlyfn == sn conditionProves that an earlier log state is a prefix of the current state.
Structure:
{
tree_size_1: <uint64>,
tree_size_2: <uint64>,
path: [<hash>, ...]
}
Where:
tree_size_1 <= tree_size_2. If tree_size_1 > tree_size_2, reject with INVALID_RANGE error immediately.Verification:
tree_size_1 == tree_size_2: verify path has 1 element equal to both rootstree_size_1 is a power of 2: prepend first_hash to pathfn = tree_size_1 - 1, sn = tree_size_2 - 1LSB(fn) == 0: shift both fn and sn right by 1fr = path[0], sr = path[0]c in path[1:]: - If sn == 0: FAIL - If LSB(fn) == 1 or fn == sn: set fr = H(0x01, c, fr), sr = H(0x01, c, sr), then while LSB(fn) == 0 and fn != 0: shift both right by 1 - Else: set sr = H(0x01, sr, c) - Shift both fn and sn right by 1fr == first_hash, sr == second_hash, and sn == 0Based on RFC 9162 Section 2.1.4.
The sequencer signs the CT root periodically to create a checkpoint.
Structure:
{
t: <timestamp>,
ts: <tree_size>,
r: <root_hash>,
sig: <signature>
}
Where:
Signature:
message = "enc:sth:" || be64(t) || be64(ts) || r sig = schnorr_sign(sha256(message), seq_priv)
Where r is the raw 32-byte root hash (NOT hex-encoded). The message is binary concatenation:
"enc:sth:" = 8 bytes UTF-8be64(t) = 8 bytes big-endianbe64(ts) = 8 bytes big-endianr = 32 bytes rawTotal: 56 bytes before SHA256.
{
"t": 1706000000000,
"ts": 1000,
"r": "<hex64>",
"sig": "<hex128>"
}Verification:
"enc:sth:" || be64(t) || be64(ts) || hex_decode(r)schnorr_verify(sha256(message), sig, seq_pub)To fully prove an event exists and verify its state:
This two-level structure allows efficient bundling while maintaining per-event verifiability.
SMT proofs verify state claims (RBAC assignments, event status) against the state_hash.
{
key: <21 bytes>,
value: <bytes | null>,
bitmap: <21 bytes>,
siblings: [<hash>, ...]
}
Where:
Empty siblings are omitted; verifier uses empty_hash for missing slots.
Bitmap Bit Ordering (LSB-first):
Bit N corresponds to depth N in the tree, where depth 0 is closest to the root and depth 167 is the leaf level.
Bit numbering within bytes: LSB-first. Bit 0 is the least significant bit (rightmost). Bit 7 is the most significant bit (leftmost).
Bitmap Example:
For a proof with non-empty siblings at depths 0, 10, and 167:
Serialized as 21 bytes (168 bits), with bit 0 = LSB of byte 0. The siblings array contains exactly 3 hashes, in depth order (0, 10, 167).
Bit-to-Byte Mapping:
Depth D maps to: byte[D / 8], bit (D % 8) where bit 0 is LSB.
Example: depth 10 → byte[1], bit 2 (since 10 / 8 = 1, 10 % 8 = 2)
Hex Serialization:
The 21-byte bitmap is serialized as a hex string in standard byte order:
Example: Depths 0, 10, 167 have siblings:
0x01 (bit 0 set)0x04 (bit 10 = bit 2 of byte 1)0x80 (bit 167 = bit 7 of byte 20)"010400...80" (42 chars total)H(0x20, key, value) (or empty_hash if value is null)empty_hash - Compute: H(0x21, left, right)state_hash)A non-membership proof proves that a key does not exist in the SMT.
Verification:
value is nullempty_hash (the key has no value)state_hash)If the computed root matches and the path is valid with an empty leaf, the key does not exist in the tree.
empty_hash = sha256("")
= 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Hardcoded constant — do not compute at runtime.
The normative wire format for v1 is JSON.
{
"k": "<hex>",
"v": "<hex | null>",
"b": "<hex>",
"s": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| k | Hex string, 42 chars (21 bytes) |
| v | Hex string or JSON null (see Value Encoding below) |
| b | Hex string, 42 chars (21 bytes) |
| s | Array of hex strings, 64 chars each (32 bytes) |
Value Encoding by Proof Type:
| Proof Type | v Field |
|---|---|
| RBAC membership | Hex string, 64 chars (32-byte padded bitmask) |
| Event Status (deleted) | "00" (1 byte) |
| Event Status (updated) | Hex string, 64 chars (32-byte update_event_id) |
| Non-membership | null (JSON null, not string) |
{
"ts": 1000,
"li": 42,
"p": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| ts | Integer (tree_size) |
| li | Integer (leaf_index) |
| p | Array of hex strings, 64 chars each (32 bytes) |
{
"ts1": 500,
"ts2": 1000,
"p": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| ts1 | Integer (tree_size_1) |
| ts2 | Integer (tree_size_2) |
| p | Array of hex strings, 64 chars each (32 bytes) |
{
"ei": 2,
"s": ["<hex>", ...]
}| Field | Encoding |
|---|---|
| ei | Integer (event index within bundle, 0-indexed) |
| s | Array of hex strings, 64 chars each (32 bytes) |
bundle.size = 1, the bundle contains one event, so s is empty and ei is 0.event_id as the verifier already knows the event being proven from request context. For self-contained proofs (e.g., archival), include event_id separately.Tree structure, namespaces, key construction, hash functions, collision analysis, state binding.
This document specifies the implementation details for the Enclave State SMT referenced in the main protocol spec.
| Property | Value |
|---|---|
| Total depth | 168 bits (21 bytes) |
| Namespace prefix | 8 bits (1 byte) |
| Entry path | 160 bits (20 bytes) |
| Hash function | SHA256 |
| Empty value | sha256("") |
The 160-bit entry path matches Ethereum address length, providing the same collision safety properties.
| Namespace | Hex | Purpose |
|---|---|---|
| RBAC | 0x00 | Role assignments |
| Event Status | 0x01 | Update/Delete tracking |
| Reserved | 0x02–0xFF | Future use |
RBAC key:
path = 0x00 || sha256(id_pub)[0:160 bits]
Where id_pub is the raw 32-byte secp256k1 x-only public key. Always compute sha256(id_pub) even though id_pub is already 32 bytes; this ensures uniform distribution across the tree.
Event Status key:
path = 0x01 || sha256(event_id)[0:160 bits]
Notation: sha256(x)[0:160 bits] means the first 160 bits (20 bytes) of the SHA256 hash. The full key is 21 bytes: 1-byte namespace prefix + 20-byte truncated hash.
API Key Construction:
When calling the State Proof API, clients provide the 32-byte raw key (id_pub or event_id). The node internally constructs the 21-byte SMT key by:
namespace parametersha256(key)[0:160 bits]namespace_byte || truncated_hashThe response k field contains the full 21-byte SMT key for verification.
RBAC:
value = <role bitmask as 256-bit integer>
CBOR Encoding:
Role bitmasks are encoded as 32-byte big-endian byte strings for CBOR hashing:
0x100000002 → 0x0000...00100000002 (32 bytes)The 32-byte padding is applied during CBOR encoding for SMT leaf hash computation (leaf_hash = H(0x20, key, value)). In JSON proofs (see proof.md), the same 32-byte value is hex-encoded as 64 characters. The padding is persistent — it represents the actual leaf value stored in SMT.
Zero Bitmask:
When all roles are revoked (bitmask = 0), the leaf is removed from the SMT. An identity with no roles is semantically equivalent to "not in tree". This follows the same pattern as Event Status where "(not in tree) = Active".
Event Status:
| Value | Meaning |
|---|---|
| (not in tree) | Active |
0x00 (1 byte) | Deleted |
<update_event_id> (32 bytes) | Updated |
Encoding: Deleted status is a single zero byte (0x00). Updated status is the 32-byte update_event_id. This unambiguously distinguishes the two cases.
0x00) and the 32-byte update_event_id are distinguishable by their length prefix:0x40 0x00 (1-byte bytestring) or 0x00 (integer zero)0x58 0x20 <32 bytes> (32-byte bytestring)This prevents any collision between deleted status and an update_event_id that happens to start with zeros.
Proof Interpretation:
| Proof Type | SMT Proof Result | Interpretation |
|---|---|---|
| Event Status | Non-membership (null) | Active OR never existed |
| Event Status | Membership (0x00) | Deleted |
| Event Status | Membership (32 bytes) | Updated to this event |
| RBAC | Non-membership (null) | No roles (or never had roles) |
| RBAC | Membership (32 bytes) | Has these roles (bitmask) |
To distinguish "Active" from "never existed" for events, combine with CT inclusion proof. To prove historical role membership, use CT inclusion proof for the Grant event.
Distinguishing Event Outcomes:
For Event Status proofs, if you receive a non-membership proof (null):
For RBAC proofs, a non-membership proof means:
leaf_hash = H(0x20, key, value)
node_hash = H(0x21, left_child, right_child)
empty_hash = sha256("")
= 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Hardcoded constant — do not compute at runtime.
SMT proof structure and verification are defined in proof.md.
| Entries | Collision probability |
|---|---|
| 1 billion | ~10^-30 |
| 1 trillion | ~10^-24 |
| 100 trillion | ~10^-20 |
Same security margin as Ethereum addresses.
| Operation | Cost |
|---|---|
| Single SHA256 | ~1 μs |
| SMT update | 168 hashes = ~170 μs |
| Proof verification | 168 hashes = ~170 μs |
| Throughput | ~6,000 updates/sec |
Note: Computation is always O(168) regardless of tree sparsity. Empty siblings are still hashed.
Only certain events modify the SMT.
RBAC Namespace (0x00):
| Event | SMT Update |
|---|---|
| Manifest | Initialize leaves from initial_state |
| Grant | Set role bit(s) for target identity |
| Grant_Push | Set role bit(s) for target identity (same as Grant) |
| Revoke | Clear role bit(s) for target identity |
| Revoke_Self | Clear role bit(s) for sender |
| Move | Set role bitmask for target identity |
| Force_Move | Set role bitmask for target identity (bypasses target_roles) |
| Transfer_Owner | Clear Owner bit from old owner, set Owner bit on new owner |
| AC_Bundle | Batch updates (atomic) |
Event Status Namespace (0x01):
| Event | SMT Update |
|---|---|
| Update | SMT[target_event_id] = update_event_id |
| Delete | SMT[target_event_id] = 0 |
Update Chaining:
Multiple Updates to the same event are allowed. Each Update overwrites the previous value:
SMT[event_A] = update_event_BSMT[event_A] = update_event_CSMT[event_A] = update_event_CThe SMT always stores the latest update_event_id. To trace the full Update history, use CT inclusion proofs to find all Update events referencing the original target_event_id.
Events that do NOT modify SMT:
Chat_Message)Lifecycle state is derived from the event log, not stored in SMT.
The SMT root (state_hash) is bound to the event log via the Certificate Transparency (CT) structure, not via the event hash itself.
CT Leaf Hash:
leaf_hash = H(0x00, events_root, state_hash)
Where:
events_root — Merkle root of event IDs in this bundle (see spec.md for bundle structure)state_hash — SMT root AFTER the last event in this bundle is appliedbundle.size = 1, events_root equals the single event_id.The CT root proves both log integrity AND state integrity. To verify a state claim, the client needs:
state_hash at position NVerification Flow:
leaf_hash = H(0x00, events_root, state_hash) to CT rootstate_hash from step 3Unified Checkpoint:
The CT root alone is sufficient to prove both:
This simplifies migration and audit: a single ct_root value commits to the entire enclave history and state.