ENC Protocol Specification
ENC (Encode, Encrypt, Enclave) is a protocol for building log-based, verifiable, sovereign data structures with Role-Based Access Control. This document specifies the core protocol: cryptographic primitives, the Commit / Event / Receipt wire protocol, event semantics (P / N / U / D), and the enclave lifecycle (Manifest / Update / Delete / Pause / Resume / Terminate). Companion docs cover RBAC v2, the storage layer (smt.md, ct.md, zk.md), enclave migration, the node API, and the enclave profiles / confidentiality plugins.
Table of Contents
- Protocol Summary
- Trust Model
- Enclave
- Node
- Client
- Signature Schemes
- Hash Function
- Hash Encoding
- Hash Prefix Registry
- Identity Key
- Delegated Sub-keys
- Wire Format
- Commit
- Event Finalization
- Event
- Receipt
- P (Push) and N (Notify)
- U (Update) and D (Delete)
- Event Categories
- Predefined Event Type Registry
- AC Events
- Content Events
- Event Type Registry (Appendix)
- Enclave Lifecycle
- Manifest
- Update
- Delete
- Templates
- Migration
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:
- Append-only event log — immutable sequence of finalized events
- Verifiable state — Sparse Merkle Tree (SMT) for RBAC and event status
- Cryptographic proofs — Certificate Transparency (CT) for log integrity
- Single sequencer — one node orders and finalizes events per enclave
Trust Model
The ENC protocol defines two query paths with different trust properties:
Enclave (Source of Truth)
- Queries to the enclave node return verifiable proofs
- Clients can verify RBAC state, event existence, and event status (U/D) against the SMT root
- The enclave is the final arbiter when disputes arise
DataView (Convenience)
A DataView is a separate service that indexes, aggregates, and transforms enclave data for efficient querying.
- Clients trust DataView responses without cryptographic verification
- DataView is optimized for performance and flexibility, not provability
- If a client suspects incorrect data, they SHOULD verify against the enclave directly.
A DataView can receive enclave data through three methods, each requiring a State or trait assignment:
| Method | Permission | Description |
|---|---|---|
| Query | R (Read) | DataView queries the enclave directly as a member |
| Push | P | Node delivers full events to DataView proactively |
| Notify | N | Node delivers lightweight notifications; DataView fetches as needed |
Recommendation: Applications SHOULD use DataView for routine queries and reserve direct enclave queries for dispute resolution, high-value transactions, and audit trails.
Enclave
An Enclave is a log-based, verifiable, sovereign data structure with Role-Based Access Control (RBAC).
An enclave maintains:
- An append-only event log — immutable sequence of finalized events
- A verifiable state (SMT) — unified tree storing RBAC state and event status
- A structural proof — append-only Merkle tree proving log integrity (see
ct.md)
Node
A Node is a host for one or more enclaves.
A node is responsible for:
- Storing data for enclave
- Serving queries
- Producing cryptographic proofs
- Sequencing commits into events (if acting as sequencer)
Sequencer Model
Each enclave has a single sequencer — the node responsible for ordering and finalizing commits.
Assignment:- The node that accepts and finalizes the Manifest event becomes the sequencer for that enclave.
- The sequencer's identity key is recorded in the Manifest event's
sequencerfield. - Sequencer discovery is out of scope for the core protocol. The Registry enclave is one discovery mechanism (see
enclaves/registry.md); other mechanisms (DNS, well-known URLs, bootstrap config) are equally valid.
- Accept valid commits and assign
seq,timestamp. - Sign events with
seq_sig. - Maintain the append-only Merkle tree.
- Maintain the Enclave State SMT.
- Deliver P (Push) and N (Notify) to registered recipients.
- See
migration.mdfor the sequencer handoff protocol.
Note: Multi-sequencer (consensus-based) models are out of scope for v2.
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:
- A client can be an app, service, or device component.
id_privcan be held directly or accessed via a secure signer (e.g., OS keystore, HSM, enclave, wallet).
Identity Custody
An identity has a custody descriptor naming how id_priv is reached by the client. Two values are defined:
local — the client holds `id_priv` directly in memory (or in an
equivalent process-local store accessible synchronously
and without consent gating).
delegated — the client has no direct access to `id_priv`. Sign /
encrypt / decrypt are exposed as an oracle by an
external signer (NIP-07 / NIP-46, WebAuthn, OS keystore
with biometric consent gating, KMS / HSM, threshold
signer, hardware wallet, …). Every operation that needs
`id_priv` is one round trip through the oracle.Custody is orthogonal to the signature scheme and to the ciphersuite. A local-custody client and a delegated-custody client running the same signature scheme produce byte-identical signatures; the same plugin running the same suite produces byte-identical ciphertexts under either custody. The visible difference is operational: under delegated custody every id_priv-using operation requires an online round-trip to the signer, and the signer MAY refuse (consent gating).
Signer oracle interface
When custody is delegated, the signer exposes exactly these four operations:
| Operation | Input | Output | Note |
|---|---|---|---|
getPublicKey() | — | 32-byte id_pub | Returned per-identity; the signer MAY manage many identities. |
sign(message) | message bytes | signature (per alg) | One online round-trip; the signer MAY require user consent. |
encrypt(peer, plaintext, suite) | peer pubkey, plaintext bytes, suite id | ciphertext per the suite's wire framing | Encrypts under the suite's KDF chain rooted in ECDH(id_priv, peer). One round-trip per ciphertext. |
decrypt(peer, ciphertext, suite) | peer pubkey, ciphertext, suite id | plaintext bytes | One round-trip per ciphertext; the signer MAY refuse or rate-limit. |
A signer that does not expose all four operations is treated as local-only for the missing operations — typically a hardware signer that exposes sign but not encrypt/decrypt cannot serve a delegated-custody encrypted enclave at all.
Custody-conditional invariants
The plugin catalog's "CT-replayable from identity_priv" guarantee (see plugins.md §Capability matrix) is custody-conditional:
- Under
localcustody: every plugin in the catalog is CT-replayable fromidentity_privoffline. The "NO" column on thePost-compromise security vs. identity_privrow reflects the protocol's accepted trust-root tradeoff. - Under
delegatedcustody:identity_privis unreachable, so offline bulk re-derivation is impossible. Recovery is decrypt-on-demand: one signer round-trip per ciphertext, consent-gated by the oracle. Plugins in the catalog MUST NOT assume offline bulk re-derivation.
The Multi-device via identity_priv row (capability axis 7) and the Post-compromise security vs. identity_priv row (capability axis 10) are likewise custody-conditional: their guarantees, as stated in the matrix, apply only under local custody. A delegated client that wants to share decryption across devices does so by sharing access to the signer oracle, NOT by exporting id_priv.
Authentication of custody
Custody is NOT advertised on the wire, NOT carried in commit signatures, and NOT inferable from the ciphertext. It is a client-local property — the receiver cannot tell from a ciphertext whether the sender's id_priv is local or delegated. The wire shape is the same under both custodies, so a bridge or a custody migration is invisible to peers.
The signer interface MAY change over an identity's lifetime (a fresh wallet may start local and later move to a hardware signer; a Nostr-bridged identity is delegated from the start). Plugins MUST NOT key any of their on-the-wire framing on custody.
Signature Schemes
The ENC protocol supports multiple signature schemes over the secp256k1 curve. The scheme is declared by the alg field on commits and events.
Supported Algorithms
alg | Scheme | Specification | Signature Format | Note |
|---|---|---|---|---|
"schnorr" | Schnorr | BIP-340 | 64 bytes (R | |
"ecdsa" | ECDSA | SEC 1 v2 §4.1 + RFC 6979 | 64 bytes (r |
When the alg field is absent, "schnorr" is assumed. Nodes MUST reject commits where alg is present but not one of the supported values. Nodes MUST NOT skip signature verification for any alg value.
Identity Key
Identity keys (id_pub) are always 32-byte x-only secp256k1 public keys (BIP-340 format), regardless of which signature algorithm is used. This is the canonical identity.
To verify an ECDSA signature against an identity key, the node derives the compressed ECDSA public key: 0x02 || id_pub. This works because BIP-340 x-only keys always have even y-coordinate, so the compressed prefix is always 0x02.
Schnorr (BIP-340)
- All
schnorr(message, private_key)operations MUST use the secp256k1 elliptic curve. - Signatures MUST conform to BIP-340 (32-byte x-only public keys, 64-byte signatures).
All Schnorr signatures MUST be deterministic. Implementations MUST use the default nonce derivation specified in BIP-340, which derives the nonce from:
- The private key
- The message being signed
- Auxiliary randomness MUST be set to 32 zero bytes. Implementations MUST NOT use random auxiliary data.
ECDSA (secp256k1)
- All
ecdsa(message, private_key)operations MUST use the secp256k1 elliptic curve. - Signatures MUST use compact encoding: raw
r || s(32 bytes each, big-endian, total 64 bytes). NOT DER encoding. - Both
randsMUST be in the range[1, n-1]wherenis the secp256k1 curve order. - Signatures MUST use low-s normalization (s ≤ n/2) per BIP-62 / BIP-146.
- Signing MUST be deterministic per RFC 6979.
- The
fromfield still contains the BIP-340 x-only public key (32 bytes), not the ECDSA compressed key. - Implementations MUST use the BIP-340-adjusted private key (negated if the public point has odd y) for ECDSA signing, ensuring consistency with the
0x02 || id_pubderivation.
Determinism
Both schemes produce deterministic signatures: the same message signed with the same key always produces the same signature. This ensures event IDs are deterministic and verifiable.
Verification
Verifiers MUST check the alg field and use the corresponding algorithm. Verifiers MUST NOT attempt multiple algorithms as a fallback. The verification path is always deterministic:
"schnorr"(or absent):schnorr_verify(hash, sig, from)"ecdsa":ecdsa_verify(hash, sig, 0x02 || from)
Sequencer and Server Signatures
The alg field applies only to client signatures (sig). Sequencer signatures (seq_sig), STH signatures, and session token signatures always use Schnorr (BIP-340). Sequencers are server nodes without hardware signing constraints.
Security Considerations
Using the same secp256k1 private key for both Schnorr and ECDSA is safe under the following conditions, which this spec enforces:
- Deterministic nonce generation — BIP-340 and RFC 6979 derive nonces from different domain-separated functions. The same
(key, message)pair produces different nonces in each scheme, preventing nonce reuse across algorithms. - No cross-algorithm forgery — Schnorr and ECDSA have structurally different verification equations. A valid signature under one scheme cannot be reinterpreted as valid under the other.
algfield integrity — Ifalgis tampered in transit, signature verification fails. This is a denial-of-service (commit rejected), not a forgery.
Hash Function
- All
sha256()operations use SHA-256 as defined in FIPS 180-4.
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. The notation:
sha256([0x10, enclave, from, type, content_hash, exp, tags])it means:
sha256(cbor_encode([0x10, enclave, from, type, content_hash, exp, tags]))- Implementations MUST use deterministic CBOR encoding (RFC 8949 Section 4.2).
- Integer prefixes (
0x00,0x10, etc.) are encoded as CBOR unsigned integers. - Binary data (hashes, public keys) are encoded as CBOR byte strings.
- Strings (content, type) are encoded as CBOR text strings (UTF-8).
- Empty string
""is encoded as CBOR text string with length 0 (0x60). - Arrays are encoded as CBOR arrays with definite length.
- The
tagsfield is a CBOR array of tags, and each tag is itself a definite-length CBOR array of one or more CBOR text strings. A tag's element count is its actual arity — it is NOT fixed at 2. Tags are variable-length (Nostr-style): a tag MAY carry more than a[key, value]pair (e.g.[mention, pubkey, relay]), and every element participates in the hash pre-image. Encoders MUST NOT assume a fixed tag arity nor truncate elements past index 1; the per-tag array header MUST carry the tag's true length.
Hash Prefix Registry
To prevent hash collisions between different data types, all hash operations use a unique single-byte prefix:
| Prefix | Purpose |
|---|---|
0x00 | CT leaf (RFC 9162) |
0x01 | CT internal node (RFC 9162) |
0x10 | Commit hash |
0x11 | Event hash |
0x12 | Enclave ID |
0x20 | SMT leaf |
0x21 | SMT internal node |
Prefixes 0x02–0x0f, 0x13–0x1f, and 0x22–0x2f are reserved for future use.
Signature contexts use string prefixes (not byte prefixes) for domain separation:
| Context | Prefix String |
|---|---|
| STH signature | "enc:sth:" |
| Session token | "enc:session:" |
String prefixes are concatenated with data before hashing: sha256(prefix || data).
Identity Key
An Identity Key is a 256-bit public key (id_pub) used to represent an identity in the ENC protocol.
Delegated Sub-keys
An identity MAY delegate a scoped, revocable capability to a separate sub-key — for example, to authorize an autonomous agent or a per-device operating key without exposing the identity key. The sub-key holds its own keypair; the identity issues a grant binding it.
Cert (Cosigned Sub-key Authorization)
A sub-key authorization is a cosigned cert — both the parent identity and the sub-key sign, so a verifier knows the identity authorized the sub-key AND the sub-key holder accepted the binding. The cosignature shape is required so MetaMask-style ECDSA-only parent identities (which sign via EIP-191 and cannot perform BIP-340 Schnorr) can authorize Schnorr-native sub-keys without exposing the parent private key.
Wire shape:
{
"subkey": "<32-byte hex; sub-key's x-only public key>",
"parentId": "<32-byte hex; parent identity x-only public key>",
"expiry": 1706000000,
"ackSig": "<sub-key Schnorr signature; see below>",
"authSig": "<parent identity recoverable ECDSA signature in EIP-191; see below>"
}| Field | Description |
|---|---|
subkey | The sub-key's 32-byte x-only public key (the operating key the sub-key holder controls). |
parentId | The parent identity's id_pub (32-byte x-only). On verification this MUST equal commit.from — a cert binds the sub-key to one specific parent identity. |
expiry | Unix-seconds expiry. The cert is rejected if expiry < now. |
ackSig | Sub-key acknowledgment: schnorr_sign("ENC sub-key ack v1|<parentId hex>|<expiry>", subkey_priv). Proves the sub-key holder accepted the binding to this parent. Domain separator string: "enc:subkey-ack:v1|". |
authSig | Parent authorization: ECDSA-recoverable_sign( eip191_hash("enc:authorize-subkey:v1|<subkey hex>|<expiry>"), parent_priv ). 65 bytes (`r |
The cert as a whole is a one-shot artifact: the parent doesn't need to be online for the sub-key to operate; the sub-key submits the cert alongside the commit and the node verifies it offline.
Cert-Authorized Commits
When a commit is signed by a sub-key (not the parent identity directly), the commit MUST carry a cert field:
{
"hash": "<hex64>",
"enclave": "<hex64>",
"from": "<parentId hex>",
"type": "<string>",
"content": "<string>",
"content_hash": "<hex64>",
"exp": 1706000000000,
"tags": [...],
"cert": { subkey, parentId, expiry, ackSig, authSig },
"sig": "<Schnorr signature over commit.hash by the SUB-KEY>",
"alg": "schnorr"
}commit.fromis the parent identity (the cert'sparentId). This is what RBAC,reg_identitylookups, and CT membership are keyed to — the sub-key's existence is transparent to authorization at that layer.commit.sigis the SUB-KEY's Schnorr signature overcommit.hash(using the standard commit hash construction). The sub-key signs the same digest the parent identity would have, with the cert proving the parent delegated this capability.commit.algfor the sub-key signature is"schnorr"(sub-keys are always Schnorr — see Status note below).
Verification
A verifier accepts a cert-signed commit only if all hold:
commit.cert.parentIdequalscommit.from(case-insensitive hex).commit.cert.expiry > now()(current wall-clock seconds).commit.cert.ackSigis a valid Schnorr signature bycommit.cert.subkeyoversha256("enc:subkey-ack:v1|" || parentId_hex || "|" || expiry_decimal).commit.cert.authSigis a valid recoverable ECDSA signature overeip191_hash("enc:authorize-subkey:v1|" || subkey_hex || "|" || expiry_decimal), AND the recovered uncompressed public key's x-only encoding equalscommit.cert.parentId.commit.sigis a valid Schnorr signature bycommit.cert.subkeyovercommit.hash.
If any check fails, the commit is rejected. The alg field on the outer commit is "schnorr" (the sub-key's signature scheme); alg = "ecdsa" is NOT compatible with cert-authorized commits — direct-ECDSA commits use the parent ECDSA path (no cert), not cosign.
Rotation, Revocation
- Rotation: issue a new cert for a new sub-key; the parent identity (
id_pub) is unchanged so peers continue to recognise the same identity. Sub-keys rotate freely beneath the stable identity. - Revocation: a cert is revoked by
expiry, or explicitly via a future revocation event (post-v2 — see Status note below). A compromised sub-key thus has a bounded blast radius and never exposes the parent private key.
Note — derivation vs. authorization. A cert proves the identity authorized the sub-key, and (via
ackSig) that the sub-key is held. It does not prove the sub-key was derived from the identity key; an x-only-only authorization cannot enforce derivation. Verifier-enforced derivation requires a key-tweak relationship or a zero-knowledge proof and is out of scope here.
Wire Format
Transport Encoding:The normative wire format is JSON. CBOR is used only for hash computation, not transport.
JSON Encoding:When serializing for transport or storage as JSON:
| Type | Encoding | Example |
|---|---|---|
| Hash (32 bytes) | Hex string, no prefix | "a1b2c3..." (64 chars) |
| Public key (32 bytes) | Hex string, no prefix | "a1b2c3..." (64 chars) |
| Signature (64 bytes) | Hex string, no prefix | "a1b2c3..." (128 chars) |
| RBAC bitmask | Hex string with 0x prefix | "0x100000002" |
| Content | UTF-8 string | "hello world" |
| Binary in content | Base64 encoded | "SGVsbG8=" |
| Integers | JSON number | 1706000000 |
Consistency: All bitmask values (State + trait flags) in JSON (event content, proofs, API responses) MUST use the 0x hex prefix format.
When serializing for hashing or binary transport:
| Type | Encoding |
|---|---|
| Hash, public key, signature | CBOR byte string (major type 2) |
| Content, type | CBOR text string (major type 3) |
RBAC bitmask (SMT leaf, NOT CBOR): RBAC bitmasks (State + trait flags) are NOT encoded as CBOR; they are written into SMT leaves as 32-byte big-endian unsigned-integer byte strings and hashed via leaf_hash = H(0x20 || key || value) (raw concatenation, not CBOR-wrapped). See smt.md §Role-Based Access Control. Outside the SMT (event content, JSON proofs, API responses), bitmasks render as 0x-prefixed hex per the JSON table above.
Commit
A Commit is a client-generated, signed message proposing an event.
A commit:
- Is authored by a client
- Is cryptographically bound to its content and metadata
- Has not yet been ordered or finalized by a node
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>, ... ],
alg: <"schnorr" | "ecdsa">,
sig: <signature over hash>
}Where:
- hash: commitment hash of the commit fields
- enclave: destination enclave
- from: identity key of the author
- type: commit type, defined within the enclave scope
- content: payload (UTF-8 string; MAY be empty
""for events with no payload like Pause/Resume; binary data SHOULD be base64 encoded; large binaries SHOULD use external resource URLs). - exp: latest time at which the node may accept the commit (Unix epoch milliseconds; also acts as a nonce).
- tags: node-level metadata (see Tags)
- alg: OPTIONAL signature scheme —
"schnorr"(default if absent) or"ecdsa". See Signature Schemes. NOT bound inhash(see §"alg field integrity"); tampering causes signature verification to fail, not forgery. - sig: signature by
from, proving authorship
Commit Hash Construction
This formula applies to ALL event types (Manifest, Grant, 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.
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:
- Binary data:
0x48656c6c6f(5 bytes, "Hello") - Base64 encoded in content:
"SGVsbG8=" content_hash = sha256(utf8_bytes("SGVsbG8="))— hash of 8 UTF-8 bytes
This ensures the hash matches what is transmitted and stored. Implementations MUST NOT decode base64 before hashing.
This establishes:
- Integrity of content
- Binding between author and intent
Wire Format: The client transmits BOTH content AND content_hash in the commit (see §Commit Structure — content_hash is a required commit field, bound in the signature via the CBOR pre-image at byte position 5). On receipt the node MUST recompute expected = sha256(utf8_bytes(content)) and reject the commit with CONTENT_HASH_MISMATCH if expected ≠ content_hash. Without this server-side check, a misbehaving client could attach a signed content_hash to a different content payload and the receipt would still validate; the node's verification closes that gap.
Note on the finalized event: The event MUST carry content_hash as a clear-text field (it's inherited from the commit per §Event Structure). Recipients verify the commit signature against the same pre-image; they MAY also recompute sha256(utf8_bytes(content)) and confirm the match for defense-in-depth.
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.
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:- Content — opaque to the node; the node stores and serves it without interpretation
- Tags — understood by the node; predefined tags trigger specific node behaviors
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:
- Position 0: tag name (key)
- Position 1: primary value
- Position 2+: additional values (optional)
All tag values MUST be strings. Numeric or other typed values are encoded as their string representation.
[
["<name>", "<value>", "<additional>", ...],
...
]Hash Ordering: When computing the commit hash, tags are hashed as a CBOR array in their original order. The order of tags in the array is significant for hash computation.
Empty Tags: An empty tags array [] is valid.
Tags MUST be replicated unchanged. Nodes store the tags array byte-for-byte alongside the rest of the event and return it on every read path (HTTP GET, WebSocket Event frames, push delivery, backfill). Tags carry plugin-level routing data (["to", "<id>"] on dm:sent, ["enclave_id", "<id>"] on cross-enclave notice, etc.); plugin decryption fails if tags are dropped or mutated in the store. An implementation that backs events with a relational table MUST include a tags column (or equivalent encoding) so storage round-trip preserves them — the commit hash binds them into event identity, so any divergence corrupts every downstream proof.
[
["auto-delete", "1706000000000"],
["r", "abc123...", "reply"]
]| Tag | Description |
|---|---|
r | Reference — references another event. Format: ["r", "<event_id>", "<context>"]. See context values below. |
auto-delete | Auto-delete timestamp — node SHOULD delete the event content after this Unix timestamp (milliseconds, same as exp and timestamp) |
r Tag Context Values:
| Context | Meaning |
|---|---|
| (omitted) | General reference (default) |
"target" | Target for Update/Delete operations |
"reply" | Reply to referenced event |
"quote" | Quote/repost of referenced event |
"thread" | Part of a thread starting at referenced event |
Custom context values are allowed for application-specific semantics. Nodes treat unrecognized contexts as general references.
Auto-delete vs Delete event:Note: The
expfield (commit acceptance window) andauto-deletetag (event retention) serve different purposes.expcontrols when a commit can be accepted;auto-deletecontrols when the finalized event should be removed from storage.
| Aspect | Auto-delete (tag) | Delete (event) |
|---|---|---|
| Mechanism | Node silently removes content after timestamp | Explicit Delete event updates SMT |
| Verifiable | No — trust-based | Yes — auditable proof |
| SMT state | Unchanged (remains "active") | Updated to "deleted" |
| Audit trail | No | Yes (who, when, why) |
| Use case | Ephemeral content, disappearing messages | Compliance, moderation, user-initiated removal |
Relationship with exp: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.
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 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 while event metadata can remain. Auto-delete is trust-based, not verifiable via SMT proofs.
Future protocol or application schemas can define additional predefined tags.
Event Finalization
Upon receiving a valid commit, a node performs:
- Expiration check — reject if
exp< current time - Deduplication check — reject if commit
hashwas already processed (see Replay Protection) - RBAC authorization check — verify sender has C permission for this event type
- 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.
- Node maintains a set of accepted commit hashes per enclave
- Before accepting a commit, check if
hashexists in the set - If duplicate, reject the commit
- Only add hash to set AFTER successful acceptance
- Hashes MAY be garbage collected after
exp + 60000ms (60 seconds buffer for clock skew).
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 State or trait).
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 → rejectThe 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.
The protocol tolerates bounded clock skew (±60 seconds) between client and sequencer:
| Component | Tolerance | Behavior |
|---|---|---|
| Commit expiration | ±60 seconds | Accept commits up to 60s "early" (client ahead) |
| Hash deduplication | +60 seconds | GC buffer after exp + 60000 ms |
| Bundle timeout | event timestamps | Uses event.timestamp, not wall clock |
| Session expiry | ±60 seconds | Node checks token expiry with tolerance |
Implementations SHOULD sync clocks via NTP and log warnings if skew exceeds 60 seconds.
Event
An Event is the fundamental, immutable record within an enclave.
Conceptually, an event represents a user-authorized action that has been:
- Authored and signed by a client, and
- 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>,
alg: <"schnorr" | "ecdsa">,
sig: <signature over hash>,
seq_sig: <signature over hash>,
}Where:
- timestamp: time at which the event was finalized (Unix epoch milliseconds; MUST be ≥ previous event's timestamp; equal timestamps allowed, ordering determined by
seq). - sequencer: identity key of the sequencing node
- seq: monotonically increasing sequence number within the enclave (starts at 0; Manifest is seq=0).
- seq_sig: signature by the sequencer over the finalized event
- id: canonical identifier of the event
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).
The following fields are copied directly from the original Commit:
hash— the commit hash (Event.hash == Commit.hash).enclave,from,type,content,exp,tags,sig.
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:
- Commits to both client intent and node ordering
- Serves as the leaf identifier for Merkle trees and proofs
- Is immutable and globally referencable
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:
- sig: the client's signature from the original commit (proves client intent)
- seq_sig: the sequencer's signature over the finalized event (proves node acceptance)
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
enclavefield. The client already knows which enclave it submitted to, and omitting it provides privacy when receipts are broadcast or shared.
alg on receipts: The Receipt also intentionally omits alg. The receipt's sig IS the commit's sig (proving client intent) and is verified by the same alg the client used; the client receiving the receipt already knows that value. A third-party verifier obtains alg from the finalized event (which DOES inherit alg from the commit per §Event Structure), then verifies sig against the event's hash per §Signature Schemes. The receipt's seq_sig is always Schnorr (sequencer signature — see §Sequencer and Server Signatures) and needs no alg discriminator.
P (Push) and N (Notify)
P (Push) and N (Notify) enable proactive delivery from nodes to external services or clients.
P (Push):- Node delivers the full event to identities with P permission for that event type.
- Use case: DataView services that index, aggregate, or transform enclave data
- DataView does not need R permission — it receives data via push, not query
- Node delivers a lightweight notification (metadata only, not content).
- Use case: Clients that want to know when to refresh, or services tracking enclave activity
- Identities receiving P or N MUST be granted a trait with P/N ops in the manifest (e.g., admin grants a dataview trait with P permission).
- The delivery endpoint is carried in the
Grantevent itself (endpointfield on the Grant content); the node registers it as operational state at Grant time. The Registry enclave is NOT consulted for P/N destinations.
::: extension-point id=push-notify-transport class=deployment_choice reason: P/N delivery uses operator-deployment transport (HTTPS webhook, WebSocket, queue); the wire shape of the delivered event is unchanged across transports
The transport mechanism for Push (P) / Notify (N) delivery is a deployment choice. Nodes SHOULD support HTTPS POST (webhook) delivery; nodes MAY additionally support WebSocket for real-time streaming. :::
Delivery Guarantees:| Type | Guarantee | Behavior |
|---|---|---|
| P (Push) | At-least-once | Node retries with exponential backoff on failure. Recipient MUST handle duplicates (dedupe by event id). |
| N (Notify) | Best-effort | Fire and forget. N is a hint; recipients can poll for missed events. |
- Initial retry: 1 second after failure
- Exponential backoff: 2x multiplier per retry
- Maximum interval: 5 minutes
- Maximum retries: 10 attempts
- After max retries: drop event, log failure, SHOULD alert enclave owner.
Implementations MAY use different parameters but MUST implement retry with backoff. Nodes SHOULD NOT retry indefinitely to avoid resource exhaustion.
Push Endpoint Failure:When P delivery to a registered endpoint fails persistently (max retries exhausted):
- The endpoint registration is kept (not removed).
- The node SHOULD alert the enclave owner.
- The owner can revoke the trait if the endpoint is permanently invalid
- The node retries with a fresh retry cycle on subsequent events (stateless — no failure tracking between events)
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.
{
"enclave": "<enclave_id>",
"event_id": "<event_id>",
"type": "<event_type>",
"seq": 123,
"type_seq": 45,
"timestamp": 1706000000000
}Where:
- enclave — the enclave identifier
- event_id — the canonical event identifier
- type — the event type
- seq — the global sequence number of the event within the enclave
- type_seq — the sequence number of this event within its type (1-indexed, continuous per type)
- timestamp — the event timestamp (milliseconds)
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.
- Starts at 1 for the first event of each type (not 0).
- Increments by 1 for each subsequent event of that type.
- Never resets — even after migration, counters continue from their last value.
- Each event type has an independent counter.
When a recipient detects a type_seq gap (e.g., received 5, then 8):
- If R permission available: Use Query (
/query) to fetch missed events by type and sequence range. This is the preferred recovery method. - If N permission only: Gaps cannot be recovered through the protocol. N-only recipients SHOULD design their applications to tolerate gaps (e.g., treat notifications as hints rather than authoritative state). Consider upgrading to R permission if complete event history is required.
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:- The event log remains append-only; original events are never mutated.
- A Delete event marks a target event as logically deleted
- An Update event marks a target event as superseded and provides new content
- An event MAY be updated multiple times, forming an update chain.
- Only identities with U or D permission (per schema) for that event type can issue these operations.
The current status of each event (active, updated, deleted) is tracked in the Enclave State SMT alongside RBAC state (see smt.md §Enclave State SMT).
When a U or D event is finalized:
- The node updates the SMT entry for the target event.
- The SMT root reflects the new state
- Clients can verify event status via SMT proof
| SMT Proof Result | Interpretation |
|---|---|
| Non-membership (null) | Event is Active OR never existed |
0x00 (1 byte) | Event was Deleted |
<32-byte id> | Event was Updated to this event ID |
- Call
POST /statewithnamespace: "event_status"andkey: <event_id>→ get SMT proof - If proof value =
0x00→ event is Deleted (conclusive) - If proof value = 32-byte ID → event is Updated to that ID (conclusive)
- If proof value = null (non-membership):
- Call
POST /bundlewithevent_id→ get bundle membership proof - If proof succeeds → event is Active
- If proof fails (
EVENT_NOT_FOUND) → event never existed
- Call
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:
- The node SHOULD delete the original content from storage.
- However, there is no guarantee of content deletion — the node operates on a best-effort basis.
- Clients MUST NOT assume original content is irrecoverable.
- Enclave queries: Return event + status + SMT proof (verifiable)
- DataView queries: Return event + status (trusted, no proof)
Event Categories
| Category | Description | Can be U/D |
|---|---|---|
| AC Events | Modify RBAC state (Grant, Revoke, etc.) | No |
| Content Events | Application data with no state effect | Yes |
| Update / Delete | Modify event status of Content Events | No |
- Only Content Events can be Updated or Deleted.
- AC Events are irreversible — they represent state transitions.
- Update/Delete events cannot themselves be Updated or Deleted.
The node determines the category by event type — all AC event types and Update/Delete are predefined by the protocol.
Predefined Event Type Registry
| Type | Category | Modifies SMT | Description |
|---|---|---|---|
Manifest | Lifecycle | Yes (RBAC) | Initialize enclave |
Move | AC | Yes (RBAC) | Change identity's State |
Grant | AC | Yes (RBAC) | Add trait to identity |
Revoke | AC | Yes (RBAC) | Remove trait from identity |
Transfer | AC | Yes (RBAC) | Atomically move trait between identities |
Gate | AC | Yes (KV) | Toggle pre-RBAC event gate |
AC_Bundle | AC | Yes (RBAC) | Atomic batch of AC ops |
Shared | KV | Yes (KV) | Enclave-wide singleton key-value |
Own | KV | Yes (KV) | Per-identity key-value slot |
Update | Event Mgmt | Yes (Status) | Supersede content event |
Delete | Event Mgmt | Yes (Status) | Delete content event |
Pause | Lifecycle | Yes (KV) | Pause finalization |
Resume | Lifecycle | Yes (KV) | Resume finalization |
Terminate | Lifecycle | Yes (KV) | Close enclave |
Migrate | Lifecycle | Yes (KV) | Transfer to new sequencer |
Content Events: Any type NOT in this registry is a Content Event (e.g., message, reaction). Content Events do not modify SMT state.
For full AC event processing rules, see rbac/events.md.
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. For full processing rules, see rbac/events.md.
AC Event Summary
| Type | Effect |
|---|---|
| Move | Change identity's State (8-bit enum). Clears traits unless preserve: true. |
| Grant | Add trait flag to identity's bitmask. Scoped by target State. |
| Revoke | Remove trait flag. Self allowed as operator (voluntary step-down). |
| Transfer | Atomically move a trait from operator to target. |
| Gate | Toggle pre-RBAC event gate (open/closed). |
| AC_Bundle | Atomic batch of AC operations (all-or-nothing). |
Lifecycle Events
| Type | Effect |
|---|---|
| Pause | Transition to Paused state |
| Resume | Transition to Active state |
| Terminate | Transition to Terminated state |
| Migrate | Transfer to new sequencer |
Lifecycle state is stored in KV Shared (Shared("lifecycle")) in SMT namespace 0x02.
KV Events
| Type | Scope | Effect |
|---|---|---|
| Shared | Enclave-wide singleton per key | Last-write-wins mutable state |
| Own | Per-identity slot per key | Each identity writes to own slot |
KV events maintain current state in SMT namespace 0x02. History preserved in CT.
Content Events
Content Events are application-defined event types that carry data without affecting RBAC state.
- Defined in the manifest
customssection (not predefined by the protocol) - Do not modify RBAC state or enclave lifecycle
- Can be Updated or Deleted (if schema permits)
An event is a Content Event if its type is NOT in the Predefined Event Type Registry above. Determined by string comparison — no schema lookup needed.
Event Type Registry (Appendix)
Machine-readable registry of predefined event types. Content Events are any type NOT in this list.
{
"version": 2,
"predefined_types": {
"ac_events": [
"Manifest",
"Move",
"Grant",
"Revoke",
"Transfer",
"Gate",
"AC_Bundle"
],
"kv_events": [
"Shared",
"Own"
],
"lifecycle_events": [
"Pause",
"Resume",
"Terminate",
"Migrate"
],
"mutation_events": [
"Update",
"Delete"
],
"registry_events": [
"reg_node",
"reg_enclave",
"reg_identity"
]
}
}To determine if an event is a Content Event:
is_content_event = type NOT IN any predefined_types categoryNote:
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 for documentation purposes.
For event processing rules, see ../rbac.md Section 8.
Enclave Lifecycle
An enclave exists in one of four states, derived from the event log:
| State | Condition | Accepted Commits | Reads |
|---|---|---|---|
| Active | No Terminate/Migrate; last lifecycle event is not Pause | All (per RBAC) | Yes |
| Paused | No Terminate/Migrate; last lifecycle event is Pause | Resume, Terminate, Migrate only | Yes |
| Terminated | Terminate event exists | None | Until deleted* |
| Migrated | Migrate event exists | None | No obligation** |
*After Terminate, the node SHOULD delete enclave data. Reads MAY be allowed until deletion completes.
**After Migrate, the old node is not obligated to serve reads. Clients SHOULD query the new node.
State Derivation:migrated = exists(Migrate)
terminated = !migrated && exists(Terminate)
paused = !migrated && !terminated && last_of(Pause, Resume).type == "Pause"
active = !migrated && !terminated && !pausedMutual Exclusion: Terminate and Migrate are mutually exclusive terminal states. The node MUST reject:
- Migrate commit if Terminate already exists → error
ENCLAVE_TERMINATED - Terminate commit if Migrate already exists → error
ENCLAVE_MIGRATED
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.
Lifecycle state is stored in KV Shared (Shared("lifecycle")) in SMT namespace 0x02. The lifecycle events themselves are also recorded in the append-only log, providing an audit trail.
| Event | Transition | Reversible |
|---|---|---|
| Pause | Active → Paused | Yes |
| Resume | Paused → Active | Yes |
| Terminate | Active/Paused → Terminated | No |
| Migrate | Active/Paused → Migrated | No |
- Node MUST reject all commits except Resume, Terminate, and Migrate.
- In-flight commits at the moment of Pause are rejected
- Read queries continue to work normally
- In-flight P (Push) and N (Notify) deliveries SHOULD complete (events already finalized).
Events finalized before Pause can already have in-flight P/N deliveries; the paused-state rule above preserves completion for those deliveries. 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.
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
The Manifest RBAC section uses the v2 manifest format (see rbac/manifest.md). Simplified example:
{
"enc_v": 2,
"states": ["MEMBER"],
"traits": ["owner(0)", "admin(1)"],
"readers": [{ "type": "MEMBER", "reads": "*" }],
"moves": [],
"grants": [
{ "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
{ "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] }
],
"transfers": [],
"slots": [],
"lifecycle": [
{ "event": "Terminate", "operator": "owner", "ops": ["C"] }
],
"customs": [
{ "event": "message", "operator": "MEMBER", "ops": ["C"] },
{ "event": "message", "operator": "Self", "ops": ["U", "D"] }
],
"init": [
{ "identity": "<owner_pub>", "state": "MEMBER", "traits": ["owner", "admin"] }
],
"meta": { "description": "a simple group" },
"bundle": { "size": 256, "timeout": 5000 }
}For production manifest examples, see the app specs: Personal, DM, Group Chat.
Manifest Content Canonicalization:Unlike Content Events, the node parses Manifest content as JSON for validation. For commit hash verification:
- Client sends
contentas UTF-8 JSON string - Node hashes
contentbyte-for-byte (does not re-serialize). - Node parses JSON only for validation, not for hashing
Clients SHOULD use deterministic JSON serialization (sorted keys, no extra whitespace) for reproducibility, but this is not enforced by the protocol.
Fields
- enc_v — Version of the ENC protocol.
- states — Declared States (UPPER_CASE). See
rbac/manifest.mdfor the full manifest format. - traits — Declared traits with rank:
name(N). - readers — Which columns grant read authority on which events.
- init — Initial identities bootstrapped at enclave creation.
- moves, grants, transfers, slots, lifecycle, customs — Authorization entries for each event category.
- meta — Optional application-defined metadata (object). The serialized
metafield (as JSON) MUST NOT exceed 4 KB (4096 bytes). Nodes MUST reject Manifests with largermetafields. - bundle — Optional bundle configuration (size, timeout). If omitted, defaults apply.
The RBAC schema is immutable after the Manifest is finalized. To change the schema, a new enclave MUST be created.
This ensures:
- Role bit positions remain stable for the enclave's lifetime
- RBAC proofs remain valid across the entire event history
- No ambiguity about which schema version applies to which events
The schema has no explicit version field. The enclave ID is derived from Manifest content (which includes the schema), so any schema change produces a different enclave ID; changing the schema therefore requires creating a new enclave.
Bundle Configuration
The bundle field controls how events are grouped for CT and SMT efficiency.
| Field | Type | Default | Description |
|---|---|---|---|
size | number | 256 | Max events per bundle |
timeout | number | 5000 | Max milliseconds before closing bundle |
- Close when
sizeevents accumulated, OR - Close when
timeoutms passed since bundle opened - Whichever comes first
Timeout is measured using event timestamps, not wall clock. The bundle opens when the first event's timestamp is recorded. The bundle closes when a new event arrives with timestamp >= first_event.timestamp + timeout.
Determinism: Bundle boundaries depend only on the finalized event sequence (seq order) and timestamps, which are immutable once sequenced. Out-of-order network delivery does NOT affect bundle boundaries — replaying the same log always produces identical bundles.
Idle Bundles: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).
If an enclave receives no new events, the current bundle remains open (no timeout trigger); 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:- All events in a bundle share the same
state_hash(SMT root after last event in bundle). - CT leaf is created per bundle, not per event.
- Set
size: 1for per-event state verification (no bundling).
The state_hash is computed when the bundle closes:
- Process all events in bundle sequentially by
seqorder - Apply each state-changing event to SMT (RBAC updates, Event Status updates)
- After last event is applied, capture current SMT root
- This root becomes the bundle's
state_hash
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:
- Compute
enclaveusing the formula above - Set the
enclavefield in the commit to this value - Compute the commit
hash(which includesenclave)
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 = sign(commit_hash, id_priv, alg)
where sign() dispatches per `alg`:
- "schnorr" (or absent) : schnorr_sign(commit_hash, id_priv)
- "ecdsa" : ecdsa_sign(commit_hash, id_priv) (recoverable; r || s || v)This two-step derivation ensures the enclave ID is self-referential and deterministic.
Enclave Identity and Collision:Note: Two Manifests with identical
from,content, andtagsproduce the same enclave ID — identical inputs represent the same enclave intent. To create distinct enclaves with similar configurations, include a unique value inmeta(e.g., a UUID or timestamp).
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.
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. Discovery mechanisms (Registry, DNS, well-known URLs, bootstrap config) decide which the client reaches; canonicalization lives outside the core protocol. The Registry enclave's specific rules for handling such collisions are in enclaves/registry.md.
The exp field in a Manifest commit follows the generic commit rule in §Commit Object: it defines the latest time at which the node is allowed to accept the commit. After enclave creation, the Manifest's exp has no ongoing effect.
Manifest Validation
Validation Order:- Verify commit structure (fields present, types correct)
- Verify commit hash and signature
- Verify enclave ID derivation matches
- Parse and validate content JSON
- Apply content-specific rules below
The node MUST reject a Manifest commit if:
enc_vis not a supported protocol versionstatesis not a non-empty array of UPPER_CASE stringstraitsis not an array ofname(rank)strings with valid non-negative integer ranksinitis empty or contains invalid entries (each MUST haveidentity,state,traits[])- Any identity in
initis not a valid 32-byte public key - Any State in
initis not declared instates - Any trait in
initis not declared intraits - Manifest fails any validation rule defined in
rbac/manifest.md§Validation Rules
Initialization Semantics
If a node accepts the Manifest commit and returns a valid receipt, the client can conclude that:
- The enclave has been successfully initialized
- The RBAC schema and initial state are finalized
- The enclave identifier is canonical and immutable
Update
An Update event replaces the content of a previously finalized event.
Type: Update
Structure
| Field | Value |
|---|---|
| type | Update |
| content | The replacement content |
| tags | MUST include ["r", "<target_event_id>"] |
Semantics
- The target event MUST be a Content Event (AC events cannot be updated).
- The target event (referenced by
rtag) is marked as updated in the Enclave State SMT. - The original content SHOULD be deleted by the node (best-effort, no guarantee).
- Only identities with U permission for the target event's type can issue an Update.
- An event can be updated multiple times; each Update MUST reference the original event (not the previous Update).
- The SMT leaf for the original event points to the latest Update.
When an Update event is finalized:
- Node looks up the target event ID from the
rtag - Node writes
SMT[target_event_id] = update_event_id - 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.
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 Sender the U permission for message, only the original author can update their own messages.
Update Target Validation
The node MUST reject Update if:
- Target event does not exist
- Target event is a Delete or Update event (U/D events cannot be U/D'd)
- Target event is an AC Event
- Target event is already deleted (has Delete status in SMT)
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
| Field | Value |
|---|---|
| type | Delete |
| content | JSON object (see below) |
| tags | MUST include ["r", "<target_event_id>"] |
Content fields:Note: The
contentfield 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\":\"...\"}"
| Field | Required | Description |
|---|---|---|
| reason | Yes | "author" (self-deletion) or "moderator" (admin/role deletion) |
| note | No | Optional explanation (e.g., "policy violation") |
Semantics
- The target event MUST be a Content Event (AC events cannot be deleted).
- The target event (referenced by
rtag) is marked as deleted in the Enclave State SMT. - The original content SHOULD be deleted by the node (best-effort, no guarantee).
- Only identities with D permission for the target event's type can issue a Delete.
- Delete events provide a verifiable audit trail of who deleted content and why.
Authorization
The node checks D permission against the target event's type, not Delete. For example, if the schema grants Sender the D permission for message, only the original author can delete their own messages.
If the manifest customs section defines:
{ "event": "message", "operator": "Sender", "ops": ["D"] }Alice (id_alice) can delete a message only if target_event.from == id_alice. The Sender context matches the target event's author.
If the manifest also grants admin the D permission:
{ "event": "message", "operator": "admin", "ops": ["D"] }An admin can delete any message regardless of authorship. The Sender entry only applies when the actor's identity matches the target event's from.
Delete Target Validation
The node MUST reject Delete if:
- Target event does not exist
- Target event is a Delete or Update event (U/D events cannot be U/D'd)
- Target event is an AC Event
- Target event is already deleted
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.
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.
| Template | Description |
|---|---|
none | No template; use explicit schema |
In ENC v2, 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 enclaves.md.
Migration
Enclave migration (the Migrate event, eager / lazy / fork modes, checkpoint verification, split-brain prevention, backup pattern) is specified in migration.md.