SOURCE OF TRUTH

Prose Specification

The complete protocol specification in four documents. Every hash formula, every API endpoint, every proof algorithm. This is the normative reference for implementers.

4Documents
4,900Lines
45+Tables
170+Formulas
Protocol Specification

Protocol Specification

Core protocol: crypto, events, RBAC, enclave state, predefined events, registry, and migration.

ENC Protocol Specification


1. Overview

Protocol Summary

ENC (encode, encrypt, enclave) is a protocol for building log-based, verifiable, sovereign data structures with Role-Based Access Control (RBAC).

Key properties:

Trust Model

The ENC protocol defines two query paths with different trust properties:

Enclave (Source of Truth)

DataView (Convenience)

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:

MethodPermissionDescription
QueryR (Read)DataView queries the enclave directly as a member
PushPNode delivers full events to DataView proactively
NotifyNNode delivers lightweight notifications; DataView fetches as needed
Recommendation: Applications SHOULD use DataView for routine queries and reserve direct enclave queries for:

2. Cryptographic Primitives

Signature Scheme

The ENC protocol uses Schnorr signatures over the secp256k1 curve, following the BIP-340 specification.

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.

Hash Function

Hash Encoding

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:

Hash Prefix Registry

To prevent hash collisions between different data types, all hash operations use a unique single-byte prefix:

PrefixPurpose
0x00CT leaf (RFC 9162)
0x01CT internal node (RFC 9162)
0x10Commit hash
0x11Event hash
0x12Enclave ID
0x20SMT leaf
0x21SMT internal node

Prefixes 0x020x0F, 0x130x1F, and 0x220x2F are reserved for future use.

String Prefixes for Signatures:

Signature contexts use string prefixes (not byte prefixes) for domain separation:

ContextPrefix String
STH signature"enc:sth:"
Session token"enc:session:"

String prefixes are concatenated with data before hashing: sha256(prefix || data).

Wire Format

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:

TypeEncodingExample
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 bitmaskHex string with 0x prefix"0x100000002"
ContentUTF-8 string"hello world"
Binary in contentBase64 encoded"SGVsbG8="
IntegersJSON number1706000000
Consistency: All role bitmask values in JSON (event content, proofs, API responses) MUST use the 0x hex prefix format.

CBOR Encoding:

When serializing for hashing or binary transport:

TypeEncoding
Hash, public key, signatureCBOR byte string (major type 2)
Role bitmaskCBOR unsigned integer (major type 0)
Content, typeCBOR text string (major type 3)

3. Core Concepts

Enclave

An Enclave is a log-based, verifiable, sovereign data structure with Role-Based Access Control (RBAC).

An enclave maintains:

Node

A Node is a host for one or more enclaves.

A node is responsible for:

Sequencer Model

Each enclave has a single sequencer — the node responsible for ordering and finalizing commits.

Assignment:

Responsibilities:

Sequencer change:

Note: Multi-sequencer (consensus-based) models are out of scope for v1.

Client

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:

Identity Key

An Identity Key is a 256-bit public key (id_pub) used to represent an identity in the ENC protocol.


4. Event Lifecycle

Commit

A Commit is a client-generated, signed message proposing an event.

A commit:

Commit Structure

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

Commit Hash Construction

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

This ensures the hash matches what is transmitted and stored. Implementations MUST NOT decode base64 before hashing.

This establishes:

Note: 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.
Wire Format: The 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

Tags provide node-level metadata that instructs the node how to process an event.

Key distinction:

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.

JSON
[
  ["<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:

JSON
[
  ["auto-delete", "1706000000000"],
  ["r", "abc123...", "reply"]
]

Predefined Tags:

TagDescription
rReference — references another event. Format: ["r", "<event_id>", "<context>"]. See context values below.
auto-deleteAuto-delete timestamp — node SHOULD delete the event content after this Unix timestamp (milliseconds, same as exp and timestamp)

r Tag Context Values:

ContextMeaning
(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.

Note: The 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:

AspectAuto-delete (tag)Delete (event)
MechanismNode silently removes content after timestampExplicit Delete event updates SMT
VerifiableNo — trust-basedYes — auditable proof
SMT stateUnchanged (remains "active")Updated to "deleted"
Audit trailNoYes (who, when, why)
Use caseEphemeral content, disappearing messagesCompliance, moderation, user-initiated removal
Important: Auto-delete is trust-based. Clients trust the node to honor the timestamp. A malicious node could delete content early without detection. For verifiable deletion with audit trail, use Delete events instead.

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.

Event Finalization

Upon receiving a valid commit, a node performs:

  1. Expiration check — reject if exp < current time
  2. Deduplication check — reject if commit hash was already processed (see Replay Protection)
  3. RBAC authorization check — verify sender has C permission for this event type
  4. Sequencing and timestamp assignment

If accepted, the node finalizes the commit into an event by adding node-generated fields.

Replay Protection

The node MUST reject commits with a hash that has already been processed for this enclave.

Implementation:

Note: Rejected commits are NOT added to the deduplication set. A commit that was rejected for authorization failure can be resubmitted (e.g., after the sender is granted the required role).

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:

ComponentToleranceBehavior
Commit expiration±60 secondsAccept commits up to 60s "early" (client ahead)
Hash deduplication+60 secondsGC buffer after exp + 60000 ms
Bundle timeoutevent timestampsUses event.timestamp, not wall clock
Session expiry±60 secondsNode checks token expiry with tolerance

Implementations SHOULD sync clocks via NTP and log warnings if skew exceeds 60 seconds.

Event

An Event is the fundamental, immutable record within an enclave.

Conceptually, an event represents a user-authorized action that has been:

  1. Authored and signed by a client, and
  2. Accepted, ordered, and finalized by a node.

An event is derived from a Commit, after validation and sequencing by a node.

Event Structure

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

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:

The node adds: id, timestamp, sequencer, seq, seq_sig

Event Hash Chain

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:

Receipt

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.

Structure

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

Note: The Receipt intentionally omits the enclave field. The client already knows which enclave it submitted to, and omitting it provides privacy when receipts are broadcast or shared.

5. RBAC

RBAC (Role-Based Access Control) consists of two parts:

Schema

The Schema defines which roles are permitted to perform which events.

It can be represented as an Event–Role–CRUD matrix, for example:

Event / RoleOwnerAdminMemberSelfDataView
GrantC (all)C (Member)RN
RevokeC (all)C (Member)RN
Revoke_SelfC (Member)N
MoveC (all)C (Member)RN
Force_MoveCRN
Transfer_OwnerCRN
PauseCRN
ResumeCRN
TerminateCRN
MigrateCRN
Chat_MessageCRUDN

Where C (Member) means Create permission with target_roles: ["Member"].

Schema Entry Structure

Each schema entry defines permissions for a (role, event type) pair:

JSON
{ "event": "<type>", "role": "<role>", "ops": ["<op>", ...], "target_roles": ["<role>", ...] }

Fields:

FieldRequiredDescription
eventYesEvent type name, or "*" for wildcard
roleYesRole name this entry applies to
opsYesArray of allowed operations
target_rolesConditionalRestricts which roles can be granted/revoked/moved (see below)

Operations (ops):

OpMeaningApplies to
CCreate — can submit commits of this typeAll events
RRead — can query events of this typeAll events
UUpdate — can update events of this typeContent Events only
DDelete — can delete events of this typeContent Events only
PPush — receives full event deliveryAll events
NNotify — receives lightweight notificationsAll 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:

JSON
{ "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:

JSON
{ "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:

JSON
{ "event": "*", "role": "Backup", "ops": ["P"] }

Note: Wildcard patterns apply to both AC Events and Content Events.

Security Consideration:

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

Reserved Roles

The ENC protocol defines the following reserved roles:

State

The State represents the current role assignments of identities.

Conceptually, it answers the question:

> "Which identity keys are assigned to which roles?"

Representation

Role Binary Encoding

Roles are mapped to bit positions as follows:

Reserved roles (bits 0–31):

BitRoleDescription
0SelfThe event author (evaluated dynamically, not stored in SMT)
1OwnerThe enclave creator
2NodeThe sequencer of this enclave
3AnyAnyone (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:

JSON
{ "from": "0x100000002", "to": "0x100000000" }

This ensures arbitrary precision (JSON numbers lose precision beyond 2^53) and makes bit positions visually clear.

Bitmask Contexts:

ContextFormatExample
Event content (Move, Force_Move)0x prefix, variable length"0x100000002"
SMT proofs (RBAC namespace)64-char hex, 32-byte zero-padded"0000...0100000002"
Error responses0x 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)

P (Push) and N (Notify) enable proactive delivery from nodes to external services or clients.

P (Push):

N (Notify):

Registration:

Transport:

Delivery Guarantees:

TypeGuaranteeBehavior
P (Push)At-least-onceNode retries with exponential backoff on failure. Recipient MUST handle duplicates (dedupe by event id).
N (Notify)Best-effortFire 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:

JSON
{
  "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:

Gap Recovery:

When a recipient detects a type_seq gap (e.g., received 5, then 8):

DataView with P+N:

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)

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:

  1. The node updates the SMT entry for the target event
  2. The SMT root reflects the new state
  3. Clients can verify event status via SMT proof

Content Integrity Proofs:

SMT Proof ResultInterpretation
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:

  1. Call POST /state with namespace: "event_status" and key: <event_id> → get SMT proof
  2. If proof value = 0x00 → event is Deleted (conclusive)
  3. If proof value = 32-byte ID → event is Updated to that ID (conclusive)
  4. If proof value = null (non-membership): - Call POST /bundle with event_id → get bundle membership proof - If proof succeeds → event is Active - If proof fails (EVENT_NOT_FOUND) → event never existed

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


6. Enclave State

Enclave State SMT

The enclave maintains a single Sparse Merkle Tree (SMT) that stores both:

Design:

The SMT uses:

Implementation details (depth, flag encoding, proof format) are specified in smt.md.

RBAC Entries:

Event Status Entries:

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.

Structural Proof (Certificate Transparency)

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

For N events in a bundle:

  1. If N = 1: events_root = event_ids[0] (no tree needed)
  2. If N > 1: Build a binary Merkle tree over event IDs: - Leaf: 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:

Determining 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):

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:

  1. Bundle membership proof — proves event_id is in the bundle's events_root
  2. CT inclusion proof — proves bundle is in the CT tree

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.

Note: State changes take effect immediately for authorization (in-memory). The 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:

ModeReturnsVerifiableFresh
verifiedstate_hash from last finalized bundle✅ Yes (CT proof)May be stale
currentSMT 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.

Enclave Lifecycle

An enclave exists in one of four states, derived from the event log:

StateConditionAccepted CommitsReads
ActiveNo Terminate/Migrate; last lifecycle event is not PauseAll (per RBAC)Yes
PausedNo Terminate/Migrate; last lifecycle event is PauseResume, Terminate, Migrate onlyYes
TerminatedTerminate event existsNoneUntil deleted*
MigratedMigrate event existsNoneNo 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:

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

EventTransitionReversible
PauseActive → PausedYes
ResumePaused → ActiveYes
TerminateActive/Paused → TerminatedNo
MigrateActive/Paused → MigratedNo

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.


7. Predefined Events

Predefined events are event types with special semantics understood and processed by the node.

Event Categories

CategoryDescriptionCan be U/D
AC EventsModify RBAC state (Grant, Revoke, etc.)No
Content EventsApplication data with no state effectYes
Update / DeleteModify event status of Content EventsNo
Key rules:

The node determines the category by event type — all AC event types and Update/Delete are predefined by the protocol.

Predefined Event Type Registry

TypeCategoryModifies SMTDescription
ManifestLifecycleYes (RBAC)Initialize enclave
GrantACYes (RBAC)Grant role to identity
Grant_PushACYes (RBAC)Grant role + register webhook
RevokeACYes (RBAC)Revoke role from identity
Revoke_SelfACYes (RBAC)Revoke own role
MoveACYes (RBAC)Replace role assignment
Force_MoveACYes (RBAC)Bypass target_roles
Transfer_OwnerACYes (RBAC)Transfer Owner role
AC_BundleACYes (RBAC)Atomic batch of AC ops
UpdateEvent MgmtYes (Status)Supersede content event
DeleteEvent MgmtYes (Status)Delete content event
PauseLifecycleNoPause finalization
ResumeLifecycleNoResume finalization
TerminateLifecycleNoClose enclave
MigrateLifecycleNoTransfer 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.

Manifest

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

Manifest Commit Content

{
  "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:

Clients SHOULD use deterministic JSON serialization (sorted keys, no extra whitespace) for reproducibility, but this is not enforced by the protocol.

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.

FieldTypeDefaultDescription
sizenumber256Max events per bundle
timeoutnumber5000Max milliseconds before closing bundle

Bundle closing rules:

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

Note: Timeout is only checked when a new event arrives. If no events arrive, the bundle remains open indefinitely. A bundle MUST contain at least one event (empty bundles are not valid).

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 Computation Timing:

The state_hash is computed when the bundle closes:

  1. Process all events in bundle sequentially by seq order
  2. Apply each state-changing event to SMT (RBAC updates, Event Status updates)
  3. After last event is applied, capture current SMT root
  4. This root becomes the bundle's state_hash

Immutability:

Bundle configuration is immutable after the Manifest is finalized, like the RBAC schema.

Enclave Identifier

The enclave identifier is derived from the Manifest commit:

enclave = H(0x12, from, type, content_hash, tags)

For a Manifest commit, the client MUST:

  1. Compute enclave using the formula above
  2. Set the enclave field in the commit to this value
  3. Compute the commit hash (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.

Note: Two Manifests with identical 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:

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.

Manifest Validation

Validation Order:

  1. Verify commit structure (fields present, types correct)
  2. Verify commit hash and signature
  3. Verify enclave ID derivation matches
  4. Parse and validate content JSON
  5. Apply content-specific rules below

The node MUST reject a Manifest commit if:

  1. enc_v is not a supported protocol version
  2. RBAC.use_temp is not "none" and not a recognized template name
  3. RBAC.schema is invalid (when use_temp is "none"): - Not an array - Contains entries with missing required fields - Contains invalid ops values
  4. RBAC.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)
  5. Any identity in initial_state is not a valid 32-byte public key
  6. Any role in initial_state is not defined in schema and not Owner (Owner is required; Self, Node, Any are dynamic and cannot be assigned)
  7. Any custom role name conflicts with reserved names (Self, Owner, Node, Any)
  8. initial_state includes Self, Node, or Any (these are dynamic roles, not assignable)

Initialization Semantics

If a node accepts the Manifest commit and returns a valid receipt, the client can conclude that:

Update

An Update event replaces the content of a previously finalized event.

Type: Update

Structure

FieldValue
typeUpdate
contentThe replacement content
tagsMUST include ["r", "<target_event_id>"]

Semantics

SMT Update Tracking:

When an Update event is finalized:

  1. Node looks up the target event ID from the r tag
  2. Node writes SMT[target_event_id] = update_event_id
  3. If a subsequent Update targets the same original event, the SMT entry is overwritten

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

Authorization

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.

Update Target Validation

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.

Delete

A Delete event marks a previously finalized event as logically deleted.

Type: Delete

Structure

FieldValue
typeDelete
contentJSON object (see below)
tagsMUST include ["r", "<target_event_id>"]
Note: The 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:

FieldRequiredDescription
reasonYes"author" (self-deletion) or "moderator" (admin/role deletion)
noteNoOptional explanation (e.g., "policy violation")

Semantics

Authorization

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

JSON
{ "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:

JSON
{ "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.

Delete Target Validation

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 Events

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.

RBAC Events

Events that modify role assignments (stored in SMT):

TypeEffectContent Structure
ManifestDeclare the AC schema and initialize the AC state(see above)
GrantGrant role to an identity (pull model){ "role": "<name>", "identity": "<id_pub>" }
Grant_PushGrant role with push delivery (push model){ "role": "<name>", "identity": "<id_pub>", "endpoint": "<url>" }
RevokeRevoke role from an identity{ "role": "<name>", "identity": "<id_pub>" }
Revoke_SelfRemove role from self (identity implicit from from){ "role": "<name>" }
MoveAtomically change an identity's role set{ "identity": "<id_pub>", "from": "<bitmask>", "to": "<bitmask>" }
Force_MovePrivileged role change (bypasses target_roles){ "identity": "<id_pub>", "from": "<bitmask>", "to": "<bitmask>" }
Transfer_OwnerTransfer ownership to another identity{ "new_owner": "<id_pub>" }
AC_BundleAtomic bundle of RBAC operations (all-or-nothing){ "operations": [...] }

Lifecycle Events

Events that control enclave state (see Enclave Lifecycle):

TypeEffectContent Structure
PauseTransition to Paused state{}
ResumeTransition to Active state{}
TerminateTransition to Terminated state{}
MigrateTransition to Migrated state; authorize new sequencerSee Migration

Lifecycle state is derived from the event log, not stored in SMT.

AC Event Authorization

For Grant, Grant_Push, Revoke, Revoke_Self:

  1. Check actor has C permission for the event type
  2. Check content.role is in target_roles for that schema entry

This prevents privilege escalation — an Admin with target_roles: ["Member"] cannot grant the Admin role.

Idempotent behavior:

Revoke vs Revoke_Self:

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:

JSON
{ "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_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:

  1. Endpoint lookup table: (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
  1. Delivery queue: (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 semantics

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

  1. Node immediately updates lookup table: (identity, role) → new_endpoint
  2. Events finalized AFTER the Grant_Push use the new endpoint
  3. Events already queued for old endpoint — deliver to old endpoint (not redirected)
  4. In-flight deliveries complete independently; failures are not retried to new endpoint

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

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

  1. Check actor has C permission for Move
  2. Check target identity's current bitmask equals content.from (reject if mismatch)
  3. Check all roles in content.to are within actor's target_roles

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

JSON
{ "type": "Error", "code": "BITMASK_MISMATCH", "expected": "0x...", "actual": "0x..." }

For Force_Move:

  1. Check actor has C permission for Force_Move (typically Owner only)
  2. Check target identity's current bitmask equals content.from (reject if mismatch)
  3. Check neither content.from nor content.to includes the Owner bit (cannot touch Owner role)
  4. No target_roles restriction — can move to any non-Owner role

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

  1. Check actor is current Owner
  2. Atomically: remove Owner role from actor, grant Owner role to new_owner

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

AC_Bundle Content

JSON
{
  "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:

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

JSON
{
  "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:

  1. Node creates a copy of current SMT state
  2. For each operation in order: authorize against simulated state, then apply to simulated state
  3. If all operations pass, apply all changes to real SMT atomically

If any operation fails:

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

Content Events are application-defined event types that carry data without affecting enclave state.

Characteristics:

Identifying Content Events:

An event is a Content Event if and only if its type is NOT one of the predefined AC event types:

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

JSON
{ "event": "Chat_Message", "role": "Member", "ops": ["C", "R"] },
{ "event": "Chat_Message", "role": "Self", "ops": ["U", "D"] }

8. Registry

The Registry is a special enclave that maps Enclave IDs to the Node endpoints that host them, and may optionally attach descriptive metadata.

Purpose

Registry Record

A registry entry MAY include:

Trust Model

Event Category

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

Update Mechanism

The Registry maintains one active entry per resource:

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

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

  1. Look up existing event for this key
  2. If exists, write SMT[old_event_id] = new_event_id
  3. Update index to point to new event

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

Registry Schema

The Registry uses a minimal RBAC schema:

JSON
{
  "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:

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

Scenariofrom fieldowner_proof
Original creator updatesmanifest_event.fromNot required
New Owner updates (no migration)New Owner's keyRequired (SMT proof of Owner bit)
New Owner updates (after migration)New Owner's keyRequired (SMT proof + migrate_event)

See the Authorization section in Reg_Enclave for verification details.

Registry Governance

Registry Owner:

The registry_owner_id is the identity operating the Registry service. In the current centralized design:

Notes:

The Process of Registry

  1. Node Register: The node should register seq_pub and domain / IP on the registry with Reg_Node. If both domain and Ip is set, first resolve IP then the domain
  2. Create Enclave: The client send manifest to the node, and get receipt with sequencer, then the client knows who is hosting the enclave
  3. Enclave Register: Then the client can send the finalized event as content of the register commit (event type predefined as Reg_Enclave). And the registry will check the sig = event.sig in content. If it comes from the same signer, registry will accept this register
  4. Identity Register: The client registers its enclaves via Reg_Identity, mapping id_pub → enclaves. The commit must be signed by id_pub. This is optional but enables discovery of an identity's enclaves

Reg_Node

Type: Reg_Node

Purpose: Register a node in the Registry for discovery.

Content Structure

JSON
{
  "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
}

Fields

FieldRequiredDescription
seq_pubYesNode's sequencer public key
endpointsYesArray of endpoints, ordered by priority (lower = preferred)
endpoints[].uriYesFull URI including protocol and port
endpoints[].priorityNoResolution order (default: array index)
protocolsNoSupported transport protocols
enc_vYesENC protocol version

Validation Limits

FieldMax
endpoints array10 entries
endpoints[].uri2048 characters
protocols array10 entries

Nodes MUST reject Reg_Node commits exceeding these limits.

Authorization

Update/Deregister

Reg_Enclave

Type: Reg_Enclave

Purpose: Register an enclave in the Registry for discovery.

Content Structure

JSON
{
  "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": {}
}

Fields

FieldRequiredDescription
manifest_eventYesThe finalized Manifest event (full event structure)
owner_proofNoProof of current Owner status (required if frommanifest_event.from)
appNoApplication identifier
descNoHuman-readable description
metaNoApplication-defined metadata

Owner Proof Structure

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.

FieldRequiredDescription
sthYesSigned Tree Head from the enclave's sequencer
ct_proofYesCT inclusion proof binding state_hash to the signed root
state_hashYesSMT root hash at the proven tree position
events_rootYesMerkle root of event IDs in the bundle
smt_proofYesSMT membership proof showing from has Owner bit
migrate_eventNoRequired if sequencer changed since Manifest (proves sequencer transition)

Authorization

The Reg_Enclave commit can be authorized in two ways:

Path 1: Original Creator (no owner_proof)

Path 2: Current Owner (with owner_proof)

Manifest 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):

  1. Verify STH signature:
   message = "enc:sth:" || be64(sth.t) || be64(sth.ts) || hex_decode(sth.r)
   verify: schnorr_verify(sha256(message), sth.sig, manifest_event.sequencer)
  1. Verify CT inclusion: - Compute leaf hash: H(0x00, events_root, state_hash) - Verify CT inclusion proof against sth.r using RFC 9162 algorithm - This binds state_hash to the signed tree
  1. Verify SMT proof: - Compute expected key: 0x00 || 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) set
  1. Verify enclave binding: - The manifest_event.enclave MUST match the enclave ID in Registry lookup - This prevents using an owner proof from a different enclave

Post-Migration Updates:

After migration, the sequencer changes. To update Registry after migration:

  1. Include the migrate_event field in owner_proof:
JSON
   {
     "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": { ... },
       ...
     }
   }
  1. Registry verifies the Migrate event: - 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.enclave
  1. STH signature is verified against migrate_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.

Security Notes:

Derived Fields

Registry extracts:

Update/Deregister

Reg_Identity

Type: Reg_Identity

Purpose: Register an identity's enclaves in the Registry for discovery.

Content Structure

JSON
{
  "id_pub": "a1b2c3...",
  "enclaves": {
    "personal": "<enclave_id>",
    "dm": "<enclave_id>"
  }
}

Fields

FieldRequiredDescription
id_pubYesIdentity public key
enclavesNoMap of label → enclave_id (named enclave references)

Enclaves Map

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:

The map is application-defined; the Registry stores but does not interpret the keys.

Validation Limits

FieldMax
enclaves map256 entries
enclaves key64 characters

Nodes MUST reject Reg_Identity commits exceeding these limits.

Authorization

Update/Deregister


9. Migration

Migration transfers an enclave from one node to another while preserving the full event history and enclave identity.

Migrate Event

Type: Migrate

Content Structure:

JSON
{
  "new_sequencer": "<new_seq_pub>",
  "prev_seq": 1234,
  "ct_root": "<ct_root_at_prev_seq>"
}

Fields:

FieldRequiredDescription
new_sequencerYesPublic key of the new sequencer node
prev_seqYesSequence number of the last event BEFORE the Migrate event (Migrate will have seq = prev_seq + 1)
ct_rootYesCT 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.

Migration Modes

Peaceful Handoff (old node online):

  1. Owner submits Migrate commit to old node
  2. Old node finalizes Migrate as the last event
  3. Old node transfers full event log to new node
  4. New node verifies log matches ct_root
  5. New node continues sequencing; next event will be seq = prev_seq + 2 (Migrate event was prev_seq + 1)
  6. Owner updates Registry (Reg_Enclave)

Forced Takeover (old node offline):

  1. Owner or Backup has a copy of the event log
  2. Owner signs Migrate commit (unfinalized)
  3. Owner submits log + unfinalized commit to new node
  4. New node verifies log integrity (see verification below)
  5. New node finalizes the Migrate event (special case)
  6. New node becomes the sequencer
  7. Owner updates Registry (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:

  1. Replay all events from the log, computing SMT state after each state-changing event
  2. Verify computed SMT root matches state_hash in final bundle
  3. Verify the from field of the Migrate commit has Owner bit set in the computed SMT (RBAC namespace)
  4. Recompute CT root — MUST match ct_root in Migrate commit
  5. Verify commit signature (sig) is valid for the computed commit hash
  6. Verify prev_seq equals the last event's sequence number in the log

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

Checkpoint Verification

The ct_root field serves as a checkpoint:

Split-Brain Prevention

After migration:

Client Recovery after Migrate:

If a client queries the old node after Migrate:

  1. Old node MAY still serve read requests (CT proofs, state proofs) — data is valid
  2. Old node MUST reject new commits (new sequencer handles writes)
  3. Client discovers migration via Registry lookup or ENCLAVE_NOT_FOUND on commit
  4. Client migrates to new node for subsequent operations

No explicit "migrated" error is required on reads; normal operation continues until the client syncs with Registry.

Backup Pattern

To enable forced takeover, ensure someone has the full event log:

Option 1: Owner maintains backup

Option 2: Dedicated Backup role

Schema example:

JSON
{ "event": "*", "role": "Backup", "ops": ["P"] }

The wildcard * means Backup receives push for ALL event types. This enables disaster recovery if the node goes offline.

Important: Forced takeover requires Owner signature on the Migrate commit. If the Owner is offline and cannot sign:

10. Node API

TODO: Endpoints and request formats assigned to Tomo

Error Response Format

When a node rejects a commit or request, it MUST return an error response:

JSON
{
  "type": "Error",
  "code": "<ERROR_CODE>",
  "message": "<human-readable description>",
  ...additional fields specific to the error...
}
FieldRequiredDescription
typeYesAlways "Error"
codeYesMachine-readable error code (UPPER_SNAKE_CASE)
messageYesHuman-readable description
additionalNoError-specific context (see below)

Defined Error Codes:

CodeContext FieldsDescription
OWNER_SELF_REVOKE_FORBIDDENRevoke_Self cannot target Owner role
OWNER_BIT_PROTECTEDForce_Move cannot modify Owner bit
BITMASK_MISMATCHexpected, actualMove/Force_Move from bitmask doesn't match current state
AC_BUNDLE_FAILEDfailed_index, reasonAC_Bundle operation failed at index
INVALID_ENDPOINT_URLGrant_Push endpoint URL is invalid
COMMIT_EXPIREDCommit exp time has passed
DUPLICATE_COMMITCommit hash already processed
UNAUTHORIZEDSender lacks required permission
ENCLAVE_PAUSEDEnclave is paused, only Resume/Terminate/Migrate accepted
ENCLAVE_NOT_FOUNDEnclave ID not found on this node

11. Templates

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.

TemplateDescription
noneNo 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.


Appendix A: Design Patterns

This section is non-normative. It describes common usage patterns, not protocol requirements.

Shared Enclave

A Shared Enclave is an enclave whose data and access-control are intended to be jointly used by multiple identities.

Typical characteristics

Examples

Personal Enclave

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 TypeWriterReaderPurpose
publicOwnerAnyonePosts, profile, follows
privateOwnerOwnerConfig, blocks, mutes
inboxAnyone (C only)Owner (R, D)DM requests, incoming messages

Full schema:

JSON
{
  "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:

Content kind Convention

Since the RBAC schema defines only 3 broad event types, applications distinguish content subtypes using a kind field inside content:

JSON
{
  "kind": "post",
  "text": "Hello world",
  "created_at": 1706000000000
}

Recommended kinds by event type:

Event TypekindDescription
publicpostA public post or note
publicprofileProfile metadata (name, bio, avatar URL)
publicfollowFollow declaration
privateconfigApplication configuration
privateblockBlock list entry
inboxdm_requestDirect message or DM request

The kind field is application-defined — the node does not validate it. Applications MAY define additional kinds as needed.

Registration

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

How to Choose

Use Shared when:

Use Personal when:

DM (Direct Message)

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"

Important Note

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.


Appendix B: Event Type Registry

Machine-readable registry of predefined event types. Content Events are any type NOT in this list.

JSON
{
  "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
Note: 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.


Node API

Node API

REST and WebSocket endpoints, proof retrieval, webhook delivery, sessions, filters, encryption.

Node API

REST and WebSocket API for ENC protocol nodes.


Table of Contents

API

  1. Overview
  2. Enclave API
  3. WebSocket API
  4. Proof Retrieval API
  5. Webhook Delivery
  6. Registry DataView API

Appendix


Overview

Base URL

https://<node_host>/

Content Type

All requests and responses use application/json.

Authentication

OperationMethod
CommitSchnorr signature over commit hash
QuerySession token (see Session)
PullSession token
WebSocket QuerySession token
WebSocket CommitSchnorr signature

Endpoints Summary

Enclave API (all enclaves):

MethodPathDescription
POST/Submit commit, query, or pull request
WS/Real-time subscriptions

Request types: Commit, Query, Pull

Proof Retrieval API:

MethodPathAccessDescription
GET/:enclave/sthPublicCurrent signed tree head
GET/:enclave/consistencyPublicCT consistency proof
POST/inclusionRCT inclusion proof
POST/bundleRBundle membership proof
POST/stateRSMT state proof

Registry DataView API (Registry enclave only):

MethodPathDescription
GET/nodes/:seq_pubResolve node by public key
GET/enclaves/:enclave_idResolve enclave to node
GET/resolve/:enclave_idCombined enclave + node lookup
GET/identity/:id_pubResolve identity by public key

Enclave API


POST / (Commit)

Submit a commit to the enclave.

Detection: Request contains exp field.

Request:

JSON
{
  "hash": "<hex64>",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "type": "<string>",
  "content": "<any>",
  "exp": 1706000000000,
  "tags": [["key", "value"]],
  "sig": "<hex128>"
}
FieldTypeRequiredDescription
hashhex64YesCBOR hash of commit (see spec.md)
enclavehex64YesTarget enclave ID
fromhex64YesSender's identity public key
typestringYesEvent type
contentanyYesEvent content (type-specific)
expuintYesExpiration timestamp (Unix milliseconds)
tagsarrayNoArray of [key, value] pairs
sighex128YesSchnorr signature over hash
Note: 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

JSON
{
  "type": "Receipt",
  "id": "<hex64>",
  "hash": "<hex64>",
  "timestamp": 1706000000000,
  "sequencer": "<hex64>",
  "seq": 42,
  "sig": "<hex128>",
  "seq_sig": "<hex128>"
}
FieldTypeDescription
typestringAlways "Receipt"
idhex64Event ID
hashhex64Original commit hash
timestampuintSequencer timestamp (Unix milliseconds) — recorded when sequencer finalizes the event, not client submission time
sequencerhex64Sequencer public key
sequintSequence number
sighex128Client's signature (from commit)
seq_sighex128Sequencer's signature over event
Note: Receipt omits enclave for privacy — client already knows which enclave it submitted to.

Errors:

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

POST / (Query)

Query events from the enclave.

Detection: Request contains type: "Query" field.

Request:

JSON
{
  "type": "Query",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}
FieldTypeRequiredDescription
typestringYesMust be "Query"
enclavehex64YesTarget enclave ID (plaintext for routing)
fromhex64YesRequester's identity public key
contentstringYesEncrypted payload (see Encryption)

Content (plaintext):

JSON
{
  "session": "<hex136>",
  "filter": { ... }
}
FieldTypeRequiredDescription
sessionhex136YesSession token (see Session)
filterobjectYesQuery filter (see Filter)
Note: enclave is plaintext for routing — node needs it before decryption.

Response (200 OK):

JSON
{
  "type": "Response",
  "content": "<encrypted>"
}

Response Content (plaintext):

JSON
{
  "events": [
    { "event": Event, "status": "active" },
    { "event": Event, "status": "updated", "updated_by": "<hex64>" },
    ...
  ]
}
FieldDescription
eventThe event object
status"active" — event is current; "updated" — superseded by Update event
updated_by(Present when status: "updated") Event ID of the superseding Update event
Note: Deleted events are NOT returned. To query deleted event IDs, use the SMT Event Status proof.

Errors:

CodeHTTPDescription
INVALID_QUERY400Malformed query structure
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
DECRYPT_FAILED400Cannot decrypt content
INVALID_FILTER400Malformed filter
UNAUTHORIZED403No read permission
ENCLAVE_NOT_FOUND404Enclave doesn't exist
RATE_LIMITED429Too many requests

WebSocket API

Real-time pub/sub for event subscriptions.

Endpoint: wss://<node_host>/

Subscription is automatic: First valid Query on a connection creates a subscription. Node assigns sub_id and begins streaming events.


Connection Model

DirectionTypeDescription
C → NQueryFirst valid Query subscribes, node assigns sub_id
C ← NEventStored events (encrypted)
C ← NEOSEEnd of stored events
C ← NEventLive updates (encrypted)
C → NCommitWrite event
C ← NReceiptWrite success
C ← NErrorWrite error
C → NCloseUnsubscribe from subscription
C ← NClosedSubscription terminated
C ← NNoticeInformational message

Client → Node

MessageFormatDescription
QuerySame as POST / (Query)First valid Query subscribes, node assigns sub_id
CommitSame as POST / (Commit)Write event, returns Receipt
Close{ "type": "Close", "sub_id": "<string>" }Unsubscribe from subscription
Note: Close unsubscribes from a single subscription. To close the entire WebSocket connection, close the WebSocket transport directly. Closing the transport terminates all active subscriptions.

Node → Client

Event (stored or live):

JSON
{
  "type": "Event",
  "sub_id": "<string>",
  "event": "<encrypted>"
}

End of Stored Events:

JSON
{
  "type": "EOSE",
  "sub_id": "<string>"
}

Write Success: Receipt (same as HTTP)

JSON
{
  "type": "Receipt",
  "id": "<hex64>",
  "hash": "<hex64>",
  "timestamp": 1706000000000,
  "sequencer": "<hex64>",
  "seq": 42,
  "sig": "<hex128>",
  "seq_sig": "<hex128>"
}

Write Error:

JSON
{
  "type": "Error",
  "code": "<CODE>",
  "message": "<reason>"
}

Subscription Closed:

JSON
{
  "type": "Closed",
  "sub_id": "<string>",
  "reason": "<reason_code>"
}

Closed Reasons:

ReasonDescriptionClient Action
access_revokedIdentity's read permission was revokedPermanent; re-subscribe if permission restored
session_expiredSession token expiredGenerate new session, re-subscribe
enclave_terminatedEnclave was terminatedPermanent; no recovery
enclave_pausedEnclave was pausedWait for Resume event, then re-subscribe
enclave_migratedEnclave migrated to new nodeQuery Registry for new node, re-subscribe there

Notice:

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

Node Processing

On Query:

  1. Verify session (same as HTTP)
  2. Check AC (read permission)
  3. Generate sub_id, store subscription
  4. Send matching events as Event messages (encrypted)
  5. Send EOSE message
  6. On new events matching filter → push Event to client

On Commit:

Same as HTTP, returns Receipt.

On Close:

Remove subscription. Terminate connection if none remain.


Connection Lifecycle

Termination conditions:

Multi-key support:


HTTP vs WebSocket

AspectHTTPWebSocket
QueryOne-time responseSubscribe + live updates
sub_idN/ANode assigns
CommitReceiptReceipt
SessionPer-requestCached per connection
StateStatelessSubscriptions

Proof Retrieval API

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

Access Control:

EndpointAccess
STHPublic
ConsistencyPublic
InclusionRequires R permission
StateRequires R permission

STH (Signed Tree Head)

GET /:enclave/sth

Returns the current signed tree head. Public endpoint for auditing.

Response (200 OK):

JSON
{
  "t": 1706000000000,
  "ts": 1000,
  "r": "<hex64>",
  "sig": "<hex128>"
}

See proof.md for STH structure and verification.


Consistency Proof

GET /:enclave/consistency?from=<tree_size>&to=<tree_size>

Returns consistency proof between two tree sizes. Public endpoint for auditing.

ParameterTypeDescription
fromuintEarlier tree size
touintLater tree size (omit for current)

Response (200 OK):

JSON
{
  "ts1": 500,
  "ts2": 1000,
  "p": ["<hex64>", ...]
}

See proof.md for verification algorithm.

Errors:

CodeHTTPDescription
INVALID_RANGE400from > to or invalid values
ENCLAVE_NOT_FOUND404Enclave doesn't exist

Inclusion Proof

POST /inclusion

Returns inclusion proof for a bundle. Requires R permission.

Request:

JSON
{
  "type": "Inclusion_Proof",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}

Content (plaintext):

JSON
{
  "session": "<hex136>",
  "leaf_index": 42
}

Response (200 OK):

JSON
{
  "type": "Response",
  "content": "<encrypted>"
}

Response Content (plaintext):

JSON
{
  "ts": 1000,
  "li": 42,
  "p": ["<hex64>", ...],
  "events_root": "<hex64>",
  "state_hash": "<hex64>"
}
FieldDescription
tsTree size when proof was generated
liLeaf index
pInclusion proof path
events_rootMerkle root of event IDs in bundle
state_hashSMT root after bundle

See proof.md for verification algorithm.

Errors:

CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
UNAUTHORIZED403No read permission
LEAF_NOT_FOUND404Leaf index out of range
ENCLAVE_NOT_FOUND404Enclave doesn't exist

Bundle Membership Proof

POST /bundle

Returns bundle membership proof for an event. Requires R permission.

Request:

JSON
{
  "type": "Bundle_Proof",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}

Content (plaintext):

JSON
{
  "session": "<hex136>",
  "event_id": "<hex64>"
}

Response Content (plaintext):

JSON
{
  "leaf_index": 42,
  "ei": 2,
  "s": ["<hex64>", ...],
  "events_root": "<hex64>"
}
FieldDescription
leaf_indexBundle's position in CT tree
eiEvent index within bundle
sSiblings for bundle membership proof
events_rootMerkle root of event IDs in bundle

See proof.md for verification algorithm.

Errors:

CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
UNAUTHORIZED403No read permission
EVENT_NOT_FOUND404Event doesn't exist
ENCLAVE_NOT_FOUND404Enclave doesn't exist

State Proof

POST /state

Returns SMT proof for a key. Requires R permission.

Request:

JSON
{
  "type": "State_Proof",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}

Content (plaintext):

JSON
{
  "session": "<hex136>",
  "namespace": "rbac" | "event_status",
  "key": "<hex64>",
  "tree_size": 1000
}
FieldRequiredDescription
sessionYesSession token
namespaceYes"rbac" or "event_status"
keyYesIdentity public key (rbac) or event ID (event_status)
tree_sizeNoBundle index for historical state (omit for current)

Response Content (plaintext):

JSON
{
  "k": "<hex42>",
  "v": "<hex | null>",
  "b": "<hex42>",
  "s": ["<hex64>", ...],
  "state_hash": "<hex64>",
  "leaf_index": 999
}
FieldDescription
k, v, b, sSMT proof fields (see proof.md)
state_hashSMT root hash for verification
leaf_indexBundle index (0-based) containing this state

Verification Flow:

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

  1. Verify SMT proof against state_hash (see proof.md)
  2. Request CT inclusion proof for leaf_index via POST /inclusion
  3. Verify CT inclusion: Recompute leaf as H(0x00, events_root, state_hash) and verify against signed CT root
  4. Verify STH signature to authenticate the CT root

This binds the SMT state to a specific, signed tree checkpoint. See proof.md for detailed algorithms.

Errors:

CodeHTTPDescription
INVALID_SESSION400Session token verification failed
SESSION_EXPIRED401Session token expired
INVALID_NAMESPACE400Unknown namespace
UNAUTHORIZED403No read permission
TREE_SIZE_NOT_FOUND404Historical state not available
ENCLAVE_NOT_FOUND404Enclave doesn't exist

Webhook Delivery

Node delivers Push messages via HTTPS POST to registered endpoints. See Appendix: Push/Notify for design rationale.


Grant_Push Event

Grants a role and registers webhook endpoint. See spec.md for event structure and content fields.

Node maintains queue per (identity, url):

Multiple 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. Old and new endpoints operate as separate queues (no events lost)
  2. Old endpoint receives events finalized before the Grant_Push
  3. New endpoint receives events finalized after the Grant_Push
  4. To stop delivery to old endpoint, explicitly Revoke the role

Delivery Flow

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

Push

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

HTTP Request:

POST <url>
Content-Type: application/json

Body:

JSON
{
  "type": "Push",
  "from": "<hex64>",
  "to": "<hex64>",
  "url": "<string>",
  "content": "<encrypted>"
}
FieldTypeDescription
typestringAlways "Push"
fromhex64Sequencer public key
tohex64Recipient identity
urlstringWebhook URL
contentstringEncrypted payload

Content (plaintext):

JSON
{
  "push_seq": 130,
  "push": {
    "enclaves": [
      { "enclave": "<hex64>", "events": [Event, ...] }
    ]
  },
  "notify": {
    "enclaves": [
      { "enclave": "<hex64>", "seq": 150 }
    ]
  }
}
FieldTypeDescription
push_sequintSequence number per (identity, url)
pushobjectFull events for enclaves with P permission
push.enclaves[].enclavehex64Enclave ID
push.enclaves[].eventsarrayArray of Event objects
notifyobjectLatest seq for enclaves with N permission
notify.enclaves[].enclavehex64Enclave ID
notify.enclaves[].sequintLatest sequence number in this enclave

Either push or notify may be omitted if empty.

Encryption: See Encryption.

Delivery semantics:

Expected response: 200 OK


Pull Fallback

If webhook delivery fails, recipient can pull missed batches.

Request:

JSON
{
  "type": "Pull",
  "enclave": "<hex64>",
  "from": "<hex64>",
  "content": "<encrypted>"
}
Note: enclave is required in the outer request for signer key derivation during decryption. Any enclave on the node where the identity has a registered webhook can be used.

Content (plaintext):

JSON
{
  "session": "<hex136>",
  "url": "<string>",
  "push_seq": { "start_after": 5, "end_at": 7 },
  "enclave": "<hex64>"
}
FieldTypeRequiredDescription
sessionhex136YesSession token
urlstringYesRegistered webhook endpoint
push_sequint, [uint, ...], or RangeYesBatch sequence(s) to retrieve
enclavehex64NoFilter results to single enclave

push_seq formats:

Response:

JSON
{
  "type": "Response",
  "content": "<encrypted>"
}

Content (plaintext):

JSON
[
  {
    "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:

Retry Policy:

Node retries webhook delivery with exponential backoff per spec.md. After max retries exhausted, batch is moved to dead-letter queue. Recipient can recover via Pull fallback.

Encryption: Same as Query. See Encryption.


Registry DataView API

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)


GET /nodes/:seq_pub

Resolve node by sequencer public key.

Path Parameters:

ParamTypeDescription
seq_pubhex64Sequencer public key

Request:

GET /nodes/a1b2c3...

Response (200 OK):

JSON
{
  "seq_pub": "<hex64>",
  "endpoints": [
    { "uri": "https://node.example.com", "priority": 1 },
    { "uri": "https://backup.example.com", "priority": 2 }
  ],
  "protocols": ["https", "wss"],
  "enc_v": 1
}
FieldTypeDescription
seq_pubhex64Sequencer public key
endpointsarrayEndpoints sorted by priority (1 = highest)
endpoints[].uristringEndpoint URI
endpoints[].priorityuintPriority (lower = preferred)
protocolsarraySupported protocols
enc_vuintENC protocol version

Errors:

CodeHTTPDescription
NODE_NOT_FOUND404Node not registered

GET /enclaves/:enclave_id

Resolve enclave to hosting node.

Path Parameters:

ParamTypeDescription
enclave_idhex64Enclave identifier

Request:

GET /enclaves/d4e5f6...

Response (200 OK):

JSON
{
  "enclave_id": "<hex64>",
  "sequencer": "<hex64>",
  "creator": "<hex64>",
  "created_at": 1706000000000,
  "app": "chat",
  "desc": "Team chat",
  "meta": {}
}
FieldTypeRequiredDescription
enclave_idhex64YesEnclave identifier
sequencerhex64YesCurrent sequencer public key
creatorhex64NoCreator's identity key
created_atuintNoCreation timestamp (Unix milliseconds)
appstringNoApplication identifier
descstringNoHuman-readable description
metaobjectNoApplication-defined metadata

Errors:

CodeHTTPDescription
ENCLAVE_NOT_FOUND404Enclave not registered

GET /resolve/:enclave_id

Combined lookup: enclave → node (convenience endpoint).

Path Parameters:

ParamTypeDescription
enclave_idhex64Enclave identifier

Request:

GET /resolve/d4e5f6...

Response (200 OK):

JSON
{
  "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:

CodeHTTPDescription
ENCLAVE_NOT_FOUND404Enclave not registered
NODE_NOT_FOUND404Sequencer node not registered

GET /identity/:id_pub

Resolve identity by public key. Returns the identity's registered enclaves.

Path Parameters:

ParamTypeDescription
id_pubhex64Identity public key

Request:

GET /identity/a1b2c3...

Response (200 OK):

JSON
{
  "id_pub": "<hex64>",
  "enclaves": {
    "personal": "<enclave_id>",
    "dm": "<enclave_id>"
  }
}
FieldTypeDescription
id_pubhex64Identity public key
enclavesobjectMap of label → enclave_id

Errors:

CodeHTTPDescription
IDENTITY_NOT_FOUND404Identity not registered

Appendix


Session

Session tokens provide stateless authentication for queries.


Token Format

136 hex characters = 68 bytes

Bytes 0-31:   r          (Schnorr signature R value)
Bytes 32-63:  session_pub (x-only public key)
Bytes 64-67:  expires    (big-endian uint32, Unix seconds)

Client Derivation

1. expires = now + duration (max 7200 seconds)
2. message = "enc:session:" || be32(expires)
3. sig = schnorr_sign(sha256(message), id_priv)
4. r = sig[0:32]
5. s = sig[32:64]
6. session_priv = s
7. session_pub = point(s)
8. session = hex(r || session_pub || be32(expires))

Node Verification

O(1) EC math — no signature verification.

1. Parse: r, session_pub, expires from token
2. Check: expires > now - 60 (allow 60s clock skew)
3. Check: expires ≤ now + 7200 + 60 (allow 60s clock skew)
4. message = "enc:session:" || be32(expires)
5. expected = r + sha256(r || from || message) * from
6. Verify: session_pub == expected

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

Curve Parameters:

All EC arithmetic uses secp256k1. The curve order is:

n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

All scalar operations (addition, multiplication) are performed modulo n.


Signer Derivation

Per-node signer for ECDH.

t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
signer_pub = session_pub + t * G

Design Rationale:

The enclave ID is included in the signer derivation so that the same session_pub produces different signer keys for different enclaves. This provides per-enclave key isolation:

This is intentional and provides defense-in-depth.

Security Properties:
PropertyProtected?Reason
Cross-session reuseDifferent session_pub → different t → different signer
Cross-enclave reuseDifferent enclave → different t → different signer
Cross-node reuseDifferent seq_pub → different t → different signer
Same session + enclaveSame signerIntentional — enables session continuity

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


Session Properties

PropertyValue
Max expiry7200 seconds (2 hours)
Timestamp unitSeconds (for uint32 compactness; API timestamps use milliseconds)
ReusableYes, until expiry
Per-node signerYes (different ECDH per node)
Multi-keyOne connection, multiple sessions

Filter

Query filter for event retrieval.


Structure

JSON
{
  "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:

Default Sort Order:

Events are sorted by sequence number ascending unless reverse: true. This is the canonical enclave order.


Range

JSON
{
  "start_at": 100,      // >= 100
  "start_after": 100,   // > 100
  "end_at": 200,        // <= 200
  "end_before": 200     // < 200
}

Semantics

PatternMeaning
Top-level fieldsAND
Array valuesOR
Omitted fieldMatch all

Limits

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

Examples

By type:

JSON
{ "type": "Chat_Message" }

By authors:

JSON
{ "type": "Chat_Message", "from": ["abc...", "def..."] }

Time range:

JSON
{ "timestamp": { "start_at": 1704067200000, "end_before": 1704153600000 } }

Resume from seq:

JSON
{ "seq": { "start_after": 150 }, "limit": 100 }

Newest first:

JSON
{ "type": "Chat_Message", "reverse": true, "limit": 20 }

Encryption

Client-node communication is encrypted using ECDH + XChaCha20Poly1305.


Overview

ContextClient KeyNode KeyHKDF Label
Query requestsigner_privseq_pub"enc:query"
Query responsesigner_privseq_pub"enc:response"
Pull requestsigner_privseq_pub"enc:query"
Pull responsesigner_privseq_pub"enc:response"
WebSocket eventsigner_privseq_pub"enc:response"
Push deliveryto (recipient)seq_priv"enc:push"

Query Encryption (Client → Node)

Client encrypts query content:

1. Derive signer from session:
   t = sha256(session_pub || seq_pub || enclave)
   signer_priv = session_priv + t (mod n)

2. Compute shared secret:
   shared = ECDH(signer_priv, seq_pub)

3. Derive key and encrypt:
   key = HKDF(shared, "enc:query")
   ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)

Node decrypts:

1. Derive signer_pub from session_pub:
   t = sha256(session_pub || seq_pub || enclave)
   signer_pub = session_pub + t * G

2. Compute shared secret:
   shared = ECDH(seq_priv, signer_pub)

3. Decrypt:
   key = HKDF(shared, "enc:query")
   plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)

Response Encryption (Node → Client)

Node encrypts response (HTTP and WebSocket):

shared = ECDH(seq_priv, signer_pub)
key = HKDF(shared, "enc:response")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)

Client decrypts:

shared = ECDH(signer_priv, seq_pub)
key = HKDF(shared, "enc:response")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)

Push Encryption (Node → Webhook)

Node encrypts webhook payload:

shared = ECDH(seq_priv, to)
key = HKDF(shared, "enc:push")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)

Recipient decrypts:

shared = ECDH(recipient_priv, seq_pub)
key = HKDF(shared, "enc:push")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)

Primitives

PrimitiveSpecification
ECDHsecp256k1
HKDFHKDF-SHA256
AEADXChaCha20Poly1305

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.


Error Codes

HTTP Status Mapping

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

Error Response Format

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

Error Codes

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

WebSocket Errors:

WebSocket errors use the same JSON format as HTTP errors. HTTP status codes do not apply to WebSocket; use the application-level code field instead.


Push/Notify

Problem

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.

Solution

Node aggregates events into a single queue per (identity, url) pair.

Enclave A ──┐
Enclave B ──┼──► Queue (identity, url) ──► Single POST to url
Enclave C ──┘

Why This Is Efficient

AspectBenefit
BatchingOne POST delivers events from many enclaves
Single sequenceOne push_seq for gap detection across all enclaves
Periodic aggregationNode batches events instead of instant push per event
Unified messagePush and Notify combined in single delivery

Pull Fallback Efficiency

When webhook delivery fails, recipient uses Pull to recover. The single sequence number makes this extremely efficient:

Without unified sequence (naive):

Recipient must track:
  - Enclave A: last_seq = 42
  - Enclave B: last_seq = 17
  - Enclave C: last_seq = 103
  ...hundreds of enclaves...

Recovery requires:
  - One Query per enclave
  - Complex state management
  - N round trips for N enclaves

With unified push_seq:

Recipient tracks:
  - push_seq = 130

Recovery requires:
  - One Pull request with Range
  - Returns all missed batches
  - Single round trip

Gap detection is trivial:

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

Push vs Notify (within same message)

A single Push delivery contains both:

pushnotify
ContentFull eventsLatest seq only
Use caseReal-time syncLightweight alerts

Permissions

PermissionWhat it grants
P (Push)Receive full event content in push.enclaves[]
N (Notify)Receive latest seq in notify.enclaves[]
R (Read)Query full event content from enclave
Important: P delivers full content directly. N only delivers the latest seq — to fetch full events, you need R permission on that enclave.

Example:

Identity has:
  - Enclave A: P permission → full events in push.enclaves[]
  - Enclave B: N permission → seq in notify.enclaves[]
  - Enclave C: N + R permissions → seq in notify.enclaves[], can Query full events

If you only have N (no R), you know new events exist but cannot read their content. This is useful for:

Typical Pattern

  1. Receive Push with notify.enclaves[].seq
  2. Query events with { "seq": { "start_after": last_synced_seq } } (requires R permission)
  3. Full events arrive directly in push.enclaves[] if you have P permission

Node Internal Table

Key:   (identity, url)
Value: { push_seq, enclaves[] }

For each enclave, node tracks which events to include based on role permissions.


Cryptographic Proofs

Cryptographic Proofs

CT inclusion, consistency, bundle membership, SMT state proofs — verification algorithms and wire formats.

Cryptographic Proofs

This document specifies proof formats for the ENC protocol.


Overview

The ENC protocol uses two types of Merkle proofs:

Proof TypePurposeTree
CT InclusionProve event exists at position N in logCertificate Transparency
CT ConsistencyProve earlier log is prefix of current logCertificate Transparency
SMT MembershipProve key-value pair exists in stateSparse Merkle Tree
SMT Non-membershipProve key does not exist in stateSparse Merkle Tree

CT Proofs

Bundle Membership Proof

Proves that an event is part of a specific bundle.

Structure:

{
  event_id: <32 bytes>,
  bundle_index: <number>,
  siblings: [<hash>, ...]
}

Where:

Verification:

  1. Set hash = event_id, index = bundle_index
  2. For each sibling s 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)
  3. Verify hash == events_root

Algorithm Notes:

Example:

Bundle with 4 events, verifying event at bundle_index = 2:

        events_root
           /    \
         h01    h23
        /  \   /  \
       e0  e1 e2  e3   ← bundle_index: 0, 1, 2, 3

With bundle.size = 1, the bundle contains one event, so siblings is empty and events_root = event_id.

CT Inclusion Proof

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

  1. Set fn = leaf_index, sn = tree_size - 1, r = leaf_hash
  2. For each p 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 >>= 1
  3. Verify sn == 0 and r == expected_root

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

  1. Single-element tree (tree_size = 1, leaf_index = 0): - Initial: fn = 0, sn = 0 - path is empty (no siblings) - Skip the loop and verify r == expected_root directly
  1. Leaf at last position (leaf_index = tree_size - 1): - Valid case; algorithm handles via fn == sn condition

CT Consistency Proof

Proves that an earlier log state is a prefix of the current state.

Structure:

{
  tree_size_1: <uint64>,
  tree_size_2: <uint64>,
  path: [<hash>, ...]
}

Where:

Precondition: tree_size_1 <= tree_size_2. If tree_size_1 > tree_size_2, reject with INVALID_RANGE error immediately.

Verification:

  1. If tree_size_1 == tree_size_2: verify path has 1 element equal to both roots
  2. If tree_size_1 is a power of 2: prepend first_hash to path
  3. Set fn = tree_size_1 - 1, sn = tree_size_2 - 1
  4. While LSB(fn) == 0: shift both fn and sn right by 1
  5. Set fr = path[0], sr = path[0]
  6. For each 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 1
  7. Verify fr == first_hash, sr == second_hash, and sn == 0

Based on RFC 9162 Section 2.1.4.

Signed Tree Head (STH)

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:

Total: 56 bytes before SHA256.

Wire Format (JSON):
JSON
{
  "t": 1706000000000,
  "ts": 1000,
  "r": "<hex64>",
  "sig": "<hex128>"
}

Verification:

  1. Reconstruct message: "enc:sth:" || be64(t) || be64(ts) || hex_decode(r)
  2. Verify: schnorr_verify(sha256(message), sig, seq_pub)

Full Event Proof

To fully prove an event exists and verify its state:

  1. Bundle membership proof — proves event_id is in bundle's events_root
  2. CT inclusion proof — proves bundle is in CT tree
  3. SMT proof — proves state claim against bundle's state_hash

This two-level structure allows efficient bundling while maintaining per-event verifiability.


SMT Proofs

SMT proofs verify state claims (RBAC assignments, event status) against the state_hash.

Proof Structure

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

Verification

  1. Compute leaf hash: H(0x20, key, value) (or empty_hash if value is null)
  2. For each depth from 167 to 0: - If bitmap bit is 1: use next sibling from array - If bitmap bit is 0: use empty_hash - Compute: H(0x21, left, right)
  3. Compare with expected root (state_hash)

Non-Membership Verification

A non-membership proof proves that a key does not exist in the SMT.

Verification:

  1. Verify that value is null
  2. Compute the expected leaf hash as empty_hash (the key has no value)
  3. Follow the same path computation as membership verification
  4. Compare result with expected root (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 Node Hash

empty_hash = sha256("")
           = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Hardcoded constant — do not compute at runtime.


Wire Format (JSON)

The normative wire format for v1 is JSON.

SMT Proof

JSON
{
  "k": "<hex>",
  "v": "<hex | null>",
  "b": "<hex>",
  "s": ["<hex>", ...]
}
FieldEncoding
kHex string, 42 chars (21 bytes)
vHex string or JSON null (see Value Encoding below)
bHex string, 42 chars (21 bytes)
sArray of hex strings, 64 chars each (32 bytes)

Value Encoding by Proof Type:

Proof Typev Field
RBAC membershipHex 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-membershipnull (JSON null, not string)

CT Inclusion Proof

JSON
{
  "ts": 1000,
  "li": 42,
  "p": ["<hex>", ...]
}
FieldEncoding
tsInteger (tree_size)
liInteger (leaf_index)
pArray of hex strings, 64 chars each (32 bytes)

CT Consistency Proof

JSON
{
  "ts1": 500,
  "ts2": 1000,
  "p": ["<hex>", ...]
}
FieldEncoding
ts1Integer (tree_size_1)
ts2Integer (tree_size_2)
pArray of hex strings, 64 chars each (32 bytes)

Bundle Membership Proof

JSON
{
  "ei": 2,
  "s": ["<hex>", ...]
}
FieldEncoding
eiInteger (event index within bundle, 0-indexed)
sArray of hex strings, 64 chars each (32 bytes)
Note: With bundle.size = 1, the bundle contains one event, so s is empty and ei is 0.
Note: Wire format omits 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.

Sparse Merkle Tree

Sparse Merkle Tree

Tree structure, namespaces, key construction, hash functions, collision analysis, state binding.

Sparse Merkle Tree (SMT)

This document specifies the implementation details for the Enclave State SMT referenced in the main protocol spec.


Tree Structure

PropertyValue
Total depth168 bits (21 bytes)
Namespace prefix8 bits (1 byte)
Entry path160 bits (20 bytes)
Hash functionSHA256
Empty valuesha256("")

The 160-bit entry path matches Ethereum address length, providing the same collision safety properties.


Namespaces

NamespaceHexPurpose
RBAC0x00Role assignments
Event Status0x01Update/Delete tracking
Reserved0x02–0xFFFuture use

Key Construction

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:

  1. Selecting namespace based on namespace parameter
  2. Computing sha256(key)[0:160 bits]
  3. Concatenating: namespace_byte || truncated_hash

The response k field contains the full 21-byte SMT key for verification.


Leaf Values

RBAC:

value = <role bitmask as 256-bit integer>

CBOR Encoding:

Role bitmasks are encoded as 32-byte big-endian byte strings for CBOR hashing:

Padding Scope:

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

Historical Membership (Intentional Design): A non-membership proof for an identity means "currently has no roles" — it CANNOT and DOES NOT distinguish between "never had roles" and "had roles, all revoked." This is intentional: the SMT tracks current state only, not history. To prove historical membership, use a CT inclusion proof for the Grant event that assigned the role.

Event Status:

ValueMeaning
(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.

Wire Format Disambiguation: In CBOR encoding, the 1-byte deleted marker (0x00) and the 32-byte update_event_id are distinguishable by their length prefix:

This prevents any collision between deleted status and an update_event_id that happens to start with zeros.

Proof Interpretation:

Proof TypeSMT Proof ResultInterpretation
Event StatusNon-membership (null)Active OR never existed
Event StatusMembership (0x00)Deleted
Event StatusMembership (32 bytes)Updated to this event
RBACNon-membership (null)No roles (or never had roles)
RBACMembership (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:


Hash Construction

Leaf Hash

leaf_hash = H(0x20, key, value)

Internal Node Hash

node_hash = H(0x21, left_child, right_child)

Empty Node

empty_hash = sha256("")
         = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Hardcoded constant — do not compute at runtime.


Proofs

SMT proof structure and verification are defined in proof.md.


Collision Analysis

EntriesCollision probability
1 billion~10^-30
1 trillion~10^-24
100 trillion~10^-20

Same security margin as Ethereum addresses.


Performance

OperationCost
Single SHA256~1 μs
SMT update168 hashes = ~170 μs
Proof verification168 hashes = ~170 μs
Throughput~6,000 updates/sec

Note: Computation is always O(168) regardless of tree sparsity. Empty siblings are still hashed.


State-Changing Events

Only certain events modify the SMT.

RBAC Namespace (0x00):

EventSMT Update
ManifestInitialize leaves from initial_state
GrantSet role bit(s) for target identity
Grant_PushSet role bit(s) for target identity (same as Grant)
RevokeClear role bit(s) for target identity
Revoke_SelfClear role bit(s) for sender
MoveSet role bitmask for target identity
Force_MoveSet role bitmask for target identity (bypasses target_roles)
Transfer_OwnerClear Owner bit from old owner, set Owner bit on new owner
AC_BundleBatch updates (atomic)

Event Status Namespace (0x01):

EventSMT Update
UpdateSMT[target_event_id] = update_event_id
DeleteSMT[target_event_id] = 0

Update Chaining:

Multiple Updates to the same event are allowed. Each Update overwrites the previous value:

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

Lifecycle state is derived from the event log, not stored in SMT.


State Binding

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:

Note: With bundling, state_hash is recorded per bundle, not per event. For bundle.size = 1, events_root equals the single event_id.

Proof Binding (CT + SMT)

The CT root proves both log integrity AND state integrity. To verify a state claim, the client needs:

  1. CT inclusion proof — proves the event exists at position N in the log
  2. SMT proof — proves the state against state_hash at position N

Verification Flow:

  1. Obtain the event, bundle membership proof, and CT inclusion proof from the node
  2. Verify bundle membership: confirm event_id is in the bundle's events_root
  3. Verify CT inclusion proof: recompute path from leaf_hash = H(0x00, events_root, state_hash) to CT root
  4. Verify SMT proof against the state_hash from step 3

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


Storage