# ENC Protocol
> A formally verified, append-only protocol for sovereign, independently-verifiable apps.
## ENC — Litepaper
### Abstract
The apps you use were built for a world where **humans wrote the code, humans reviewed it, humans ran the servers, and humans clicked the buttons.** That world is ending. Software is increasingly **written by AI** and **operated by autonomous agents** acting at machine speed — and every assumption the old app stack rested on (*trust the operator, someone reviewed the code, your data lives in their platform*) breaks at once.
**ENC** — *encode, encrypt, enclave* — is the substrate for what comes next: a **trust-minimized, self-sovereign** protocol where code is **verified, not trusted**, data **carries its own proof**, and apps are **generated** to be owned by their users and usable by agents.
The verification doesn't stop at the protocol. The **infrastructure and the apps are formally verified too**, because they're all *generated* from the same proven mathematical core — their guarantees hold by *mathematics*, not by trusting whoever (or *whatever*) wrote the code. So one specification, reviewed once, expands — **instantly, across \~10 platforms** — into a running app whose security is carried by proof, not re-audited by hand.
And it's not one app. The same machine that generates a verified messenger generates a verified marketplace, wallet, social feed, or registry — **AppGen redefines existing apps as agent-native, trust-minimized, sovereign ones.** A sovereign messenger is simply the **first instance** — not the product.
In one line: **you can forge a screenshot; you can't forge verified data** — and ENC turns every app's money, messages, agreements, and agent actions into verified data.
***
### I. The paradigm is breaking
Every app today runs on infrastructure you have to **trust**. The operator can silently rewrite your data, censor you, lock you out, or vanish — and you can't check. Even "audited" or "decentralized" systems still ask you to trust that the **code actually running matches what was reviewed**, and that the operator behaves.
That bargain held while four things were true: code was **human-written**, it was **human-reviewed**, apps were **operated by accountable people**, and they were **used by humans** who would notice when something looked wrong. All four are now false:
* **Code is AI-written** — "vibe-coded" faster than anyone can read, let alone audit. No reviewer — not the best engineer, not the best mathematician — keeps pace with what machines now emit; human review, the old basis of trust, simply doesn't scale to machine output. You can't validate what no one fully read; "we reviewed it" becomes a statement about a fraction of the code, at a point in time.
* **Apps are agent-operated** — autonomous agents act on your behalf and transact with *other* agents, at machine speed, across parties that have never met. There is no human in the loop to apply judgment.
* **Trust doesn't compose to machines** — reputation, terms of service, and a careful read are human-scale instruments. They don't survive millions of unattended interactions. And the human method for *building* trustworthy systems — theorize a clean spec, then hand-write the code and *hope* it matches — can't keep pace with machine-scale output either; it's the wrong instrument for the job.
* **Claims have to leave the room** — a screenshot proves nothing to anyone who wasn't there. The instant your data must be trusted by *someone else's* system, "trust me" fails.
When neither the operator nor the code can be vouched for by a human — because no human can keep up — the only trust that scales is **mathematical proof.** Trustworthy infrastructure *for* agents can therefore only be built *by* agents whose output is checked not by a reviewer but by **math.** That is the forcing function behind everything in this paper.
***
### II. The new paradigm: trust-minimized, self-sovereign, verifiable
ENC replaces the old bargain with three properties that stack:
* **Trust-minimized** *(the system property)* — you have to trust as little as possible. Not "trustless" (there's always *some* trusted base) — **minimized** to a small, explicit, *measurable* surface, with everything above it backed by proof.
* **Self-sovereign** *(the outcome you feel)* — your identity, data, and money are **yours**: encrypted, portable, no phone number, no central account, no operator that can censor or revoke you.
* **Verifiable** *(the mechanism)* — every piece of data and every line of running code can be **checked**, not believed: authentic, ordered, unaltered, authorized — by you, a counterparty, another app, or an agent, *without trusting the source.*
One sentence: **ENC is a *trust-minimized* protocol that delivers *self-sovereignty* through *verifiability and formal proof.*** *Own it — and prove it.*
Each primitive is the **forced replacement** for an assumption the agent era broke — skip any one and the stack falls back to the failure it was built to avoid:
| The agent era broke… | ENC forces… | Where |
| ------------------------------------- | ------------------------------------------------------- | ----- |
| reviewed, human-written code | a spec that **generates** its verified implementation | §VI |
| accountable human operators | a **self-sovereign mesh** — host your own node | §III |
| trust that composes (reputation, ToS) | **verifiable, composable** data — trust travels with it | §VIII |
| a human in the loop | **bounded authority** — permissions are theorems | §IX |
| apps hand-built one by one | apps **generated** from one verified spec | §VII |
***
### III. The protocol
At its core ENC is small and sharp — an **event-sourcing store you own**: signed writes (events), ordered into append-only logs, committed to structures anyone can verify, governed by explicit permissions.
#### Declarative, not computational
An ENC node runs *no app code.* State is **non-computing** — key→value leaves in a **Sparse Merkle Tree** — and a single **manifest** *declares* everything an app is: its states, traits, operations, readers, and lifecycle. The node doesn't *execute* an app; it **enforces a declaration** — verify the signature, check the manifest, append, update the tree. That is exactly what keeps the whole system *verifiable* (there's no arbitrary computation to reason about) and *fast* (the work per event is small and bounded).
#### Virtual enclaves
The unit of an ENC app is an **enclave** — a **self-sovereign realm** for your data. The name is an analogy to a hardware **TEE** (a protected enclave like SGX), but with the trust inverted: an ENC enclave is protected by **cryptography and math, not hardware.** Concretely it's a cryptographically-defined, append-only log that is *yours*: writes are authorized by your keys, and the data is portable. Reference templates express every app shape: **Personal** (profile / identity anchor), **DM** (a private mailbox), **Group** (roles, moderation, group encryption), **Registry** (discovery). An enclave is **platform-blind** (the host only sees ciphertext), **portable** (it *is* its keys plus signed history), **composable** (enclaves reference each other by key and coordinate via signed commitments any third party can verify), and **agent-compatible** (an agent's enclave is indistinguishable from a person's). **Discoverability is opt-in:** list an enclave in a **Registry** to be found, or keep it entirely private — the choice is yours, not the platform's.
#### Identity & lifecycle
An identity is a secp256k1 key with **Schnorr (BIP-340)** signatures, held by you — passkey, extension, or hardware, **no phone number** — never the server. The node is a **single sequencer** that only orders writes.
Because the node only orders writes — it can't author them or sign for you — its power is sharply bounded.
#### Authorization (RBAC)
Who may do what in an enclave is a small, *decidable* permission model baked into the verified kernel — **three columns and six verbs**, packed into a single **bitmask** so every check is one cheap, *provably-correct* computation, never a tangle of `if` statements.
*Three columns — who you are:*
* **State** *(where you are)* — UPPER-CASE, mutually exclusive: `PENDING`, `MEMBER`, `BLOCKED`.
* **trait** *(what modifies you)* — lower-case, stackable flags, each carrying a **rank**: `owner`, `admin`, `muted`.
* **Context** *(who you are to this object)* — `Self`, `Sender`, or `Public`, evaluated at check time.
*Six operations — what you may do:* **Create, Read, Update, Delete, Push, Notify** — permitted or refused as a function of the columns, with deny marked by a leading `_`, and **deny overriding allow**.
**Authority changes** are themselves signed events — `Move` (state, e.g. `PENDING → MEMBER`), `Grant` / `Revoke` / `Transfer` (traits), `Gate` (open or close a capability), `Shared` / `Own` (key-value state) — each requiring explicit permission, with a **proven rank rule** that stops anyone acting on someone ranked above them (no quietly promoting yourself to `owner`).
Concretely: an `admin` can admit a `PENDING` member or mute a `MEMBER`, but **cannot** grant `owner` or act on anyone ranked at or above itself — the kernel simply refuses, because the rank rule is a *theorem*, not a hopeful runtime check. So a moderated group's whole policy — who can post, admit, ban, or mute — is a tiny declared table the compiled code is *structurally incapable* of violating. **Permissions aren't config; they're theorems** — **impeccable math for authorization**, where escalation isn't merely *forbidden*, it's *unrepresentable*. Each enclave becomes a **sovereign jurisdiction**: its own small constitution, governing humans and their agents under one law the code *cannot* break — exactly what makes bounded delegation to an agent (§IX) safe.
#### Verifiable state & history
Current state commits to a **Sparse Merkle Tree** (one root); full history to an append-only **Certificate-Transparency-style log**; the two are bound and signed as a **Signed Tree Head**. Anyone verifies inclusion, append-only consistency, and any state fact **offline, without trusting the node** — history is **tamper-evident.** Optional **validity proofs (zkEnc)** prove state was reached *only by authorized transitions*, so a node can't write `Mallory = admin` and have a membership proof still pass. And privacy costs nothing here: an enclave's contents can stay **encrypted** while its *state* remains **zk-provable** — you can prove what's true about it without revealing what's in it.
#### Event sourcing, and dataviews
Underneath, ENC is an **event-sourcing store.** Every change is an immutable, signed **event** appended to the log; current state (the SMT) is just a *fold* over those events. Nothing is overwritten — the history *is* the database.
That log is the foundation all app-side logic builds on, and there are two ways to read it. A **private** app — a messenger, a DM, encrypted email — needs nothing else: the client holds the keys and **queries the node directly** for its own enclave's events, no index in between. Only when you need **public, indexable** data — a public timeline, a search index, an aggregate feed — does an **(optional) dataview** earn its place: a service that **projects** events into a queryable read-model and keeps it live as new events arrive. Delivery is part of the permission model: **Push (`P`)** streams full events to a dataview or service endpoint; **Notify (`N`)** sends a lightweight ping to a human client. A **Registry**, for instance, is just an enclave whose dataview indexes listings and serves them as a discovery **API**.
So it is command-and-query, signed end to end: write a signed event, then read it back — by **querying the node directly** for private data, or **through a dataview** for public, indexable data. On that one foundation you can build **anything** — a private messenger, a public timeline, productivity apps, an end-to-end-encrypted email system, a headless agent API, or a category no one has named yet.
#### The mesh
ENC is **not a central server and not a blockchain** — a **federated mesh** of nodes. A node's role is deliberately narrow: it **hosts** the enclaves assigned to it, **orders** their writes, and **delivers** them — pushing new events and **notifications** (the `Push` / `Notify` operations) to members and their devices, and holding data for offline sync and multi-device catch-up. It never authors writes or signs for you. Each enclave is ordered by one such node, but that sequencer is **bounded** — it can't forge, reorder, or fake state — so you get edge-latency speed and one ordered source of truth *without* a central operator or a global-consensus tax.
Because that power is bounded, the network is **ultra-flexible**: a node can run anywhere — the edge, a server, your laptop, even your **phone** — and you can **be your own node**, so no third party sees even your metadata. No one can lock you in, there's no choke point to censor, and your enclave **survives any node dying** — it just moves. An always-on node still gives reliable offline delivery and multi-device sync, so you keep sovereignty *without* the peer-to-peer reliability tax.
And because an enclave *is* a repository — keys plus signed history — the tooling treats it like **git for your data**: you `clone`, `push`, `pull`, `snapshot`, and `migrate` it. Moving your data to another host — or off one entirely — is a single command, and a snapshot round-trips byte-identically on any host. Portability stops being a promise and becomes `git clone`.
***
### IV. Privacy — transparent by default, private and pluggable
ENC is **transparent by default** — but transparent about *the rules*, not your *content.* State and every transition are verifiable and the log is tamper-evident, so anyone you allow can confirm that something happened and followed the rules; **confidentiality then layers on top**, so the operator can *store* your data but never *read* it. The primitives are standard and conservative — secp256k1 with **Schnorr (BIP-340)** signatures, **ECDH** key agreement (Curve25519 / secp256k1), **XChaCha20-Poly1305** authenticated encryption, and **HKDF-SHA256** derivation. What's unusual is that *which* scheme protects a given piece of data is a **swappable plugin**, chosen by the shape of the conversation:
* **One party** — your own private data — `identity-aead`: authenticated encryption under a key only you hold.
* **Two parties** — a direct message — `dm-ratchet`: a **forward-secret double ratchet**, so a compromised key doesn't expose past messages.
* **A group** — `group-mls-lazy`: a **lazy** variant of **MLS** (the IETF group-messaging standard) that scales it further — rekeying the whole group in *O(log N)* on a membership change while deferring the work *lazily* onto the enclave's real, verifiable history, so large groups stay cheap.
#### Crypto as swappable plugins
Each is a real package filling a typed **slot** in the model — `identity-aead`, `dm-ratchet`, `ecdh-envelope`, `group-mls-lazy`, and more — so you can **swap or add** schemes (post-quantum, a different group ratchet, a sealed-sender mode) *without touching the verified core.* And because every plugin carries its **own machine-checked security claim**, swapping never means "trust whoever wrote it": the guarantee travels with the plugin, and the set you've chosen *is* your explicit privacy surface. Encrypted reads require an ECDH session, so confidentiality is built in, not bolted on. *Privacy isn't a feature you're handed — it's a choice you own, and can upgrade.*
#### The subkey system — keys for agents and devices
You never hand an agent — or a new device — your root key. ENC mints **subkeys**: child keypairs derived from your identity with **HKDF** under a domain separator, so a subkey is provably *yours* yet leaks nothing about the root — and the root private key never leaves your control. Each subkey is issued with a signed **capability grant** that is *scoped, expiring, and revocable* — it names exactly what the holder may do and for how long. A co-signed certificate binds parent → subkey, and a *tweaked* variant (`S = P + t·G`) is even publicly verifiable without one. Revoke a subkey and it's dead — so a compromised agent or a lost device never compromises *you*. That is how a **remote** agent in the cloud or a **local** agent on your device can act *as you* within a strict, revocable scope (§IX) — a key that is **provably yours and provably bounded**, enforced by math, not a dashboard toggle.
***
### V. One instance: a messenger
ENC is the substrate; **a messenger is just one app on it** — taken here as an example because messaging is the hardest, most-contested consumer surface. Even there, the new paradigm shows: an ENC messenger owns your identity (no phone), your data (verifiable, portable), and your money — on a mesh no one controls — and every message can be a *typed, verifiable object* that other apps and agents compose.
How to read it: the table scores **default, user-visible** guarantees — optional modes, whether a system *retains or discards* its logs, and network reach are separate dimensions, called out where they differ.
The honest read: **encryption is table stakes** (ENC, Signal, WhatsApp, Keet all have it), and ENC does **not** claim to beat Signal on metadata — Signal's privacy comes from *storing nothing*, the opposite bet from a *verifiable, retained* log. The rows where **only the ENC app is green** are the point: **self-sovereignty** and **verifiability** (formally verified — alone, even against Keet), plus **reliable delivery** where pure-P2P Keet isn't. The one row reversed against everyone is **network** — so the battle is *distribution, not capability*. And this is only **instance #1**: the same AppGen line produces the next thousand apps.
***
### VI. Formal verification — trust, replaced by proof
#### The formalization gap
Every other "formally verified" protocol shares one unsolved problem: the proof covers a **model**, but the code that actually ships is **hand-written** — or now **vibe-coded by an AI** — and the compiler and runtime beneath it are trusted, not proven. A *specification* (a document) sits on one side and an *implementation* on the other; they drift the moment coding starts, and that gap is where bugs and backdoors live. It is the signature of the **top-down** method — *theorize a clean spec, then hand-write code and hope it matches* — which neither closes the gap nor keeps pace with the code machines now emit. An audit only ever *samples* it — and no one can audit what no one fully read. **This is the gap ENC closes.**
#### Closing the gap by construction
ENC closes it by construction — and inverts the method. It formalizes the protocol *from the behavior of real systems* and drives that formalization as far up the proof as the math allows — **bottom-up**, not top-down, an asymptote it approaches rather than a perfection it claims. The implementation is *generated from the proven spec* by a verified **CodeGen**: from one Lean 4 artifact — a small, closed, total calculus, **EncDSL** — four generators emit **Lean**, **JavaScript** (the SDKs and the Cloudflare host), **Rust**, and a **Rust→WebAssembly** kernel. No human hand-writes the protocol code and no AI vibe-codes it — *the code is the spec, compiled.* Because the generators are **deterministic**, a **reproducibility gate** regenerates every artifact and requires the bytes to match **hash-for-hash** (sha-256) or the build fails; key **refinement properties** are proven at the host boundary. So the deployed bytes provably *are* the generated ones — there is no second codebase to drift. The same gate lets the protocol *evolve* safely: every new version is re-proven before it ships, never merely re-trusted.
#### The spec runs — its own reference
Here is the part **no other "verified" project has**: the Lean spec isn't only a model on paper — *it runs.* Everyone else keeps a paper spec **and** a separate reference implementation, two artifacts that drift — the original formalization gap. ENC's spec **compiles to an executable reference** (the Lean `enc-oracle`), which decides every commit with the *exact* state machine the theorems are proved against. So *validating the spec is validating the reference;* there is **no gap between them to close.** Anyone can run that same oracle as an independent **referee** to settle whether an event was valid, or who deviated in a dispute.
#### The proofs are the audit
About **2,851 machine-checked theorems, zero `sorry`** — a security review performed by *mathematics*, exhaustive over every input, that never goes stale because the code is generated from the very thing it proves. No human vouches for these bytes; the proof does, mechanically and in full.
The remaining links are closed concretely. The **reproducibility gate** regenerates every artifact and fails the build unless the bytes match hash-for-hash. **Generated tests (TestGen)** replay the *same* workflow corpus across **every platform** through pluggable adapters, and a *matrix theorem* makes passing the two axes imply the whole grid. And **witnesses** run those tests against the *exact published bytes* and record their hashes. So spec, proof, code, tests, and running bytes become one continuous, checkable chain — not a document and a hope.
#### SpecGen — created and audited
ENC isn't hand-built and hoped-correct; it's produced and graded by **SpecGen**, a protocol-agnostic framework that drives every claim along one pipeline: human-readable **prose** → a reviewed **formal meaning** → a **Lean theorem** → **generated code** → a **witness** that ran the real bytes → a single **signed report**. ENC is just *one instance* of the **SpecGen / CodeGen / AppGen / TestGen** meta-protocols (which carry no protocol of their own) — generators that turn any domain into a fully-verified system. Aim them widely and you get a **formally-verified, autonomously-evolving universe** of software; ENC is simply its first citizen. So the "audit" isn't a PDF from a firm that sampled the code once — it's a machine-checked report that re-derives the entire chain and grades each claim.
And trust-minimization here is **measured**, not asserted: that report places every claim on a public **trust lattice**, from **T0** ("trust the operator — nothing checked") up to **T6** (a ratified, version-pinned release) — with **T5, *proved & conformant***, the tier where the generated code provably matches the proof and the witnesses agree. Almost no system can put a *number* on how much trust it removes; ENC can.
#### Verification all the way up
And none of this is confined to the protocol kernel. Because the **infrastructure and the apps are generated from the same proven core**, the verification flows all the way up: you review a small declarative spec *once*, and the math guarantees the expansion into a running, multi-platform app. That is the elegance — a tiny calculus that does the expanding for you — and it's what makes "formally verified" affordable for *ordinary apps*, not just a protocol.
It reaches the **infrastructure and its scaling**, too — which is why ENC apps reach **Telegram scale** on a kernel small enough to *verify.* The proven kernel ships as a compact **WebAssembly** module on **Cloudflare**'s edge: one Durable Object sequences one enclave, globally distributed and auto-scaling, and because the protocol is **declarative and non-computing**, the work per event is tiny and bounded — verify, check the manifest, append, update the tree, nothing arbitrary to run. The node, the policy layer, and the host contract are all specified in Lean — even the server-side **hook system** is proven **orthogonal** (`replay_invariant_under_hook_swap`: swapping policy hooks can't change replayed state). And **how it scales is *proven*, not just benchmarked**: **sharding** provably preserves the full history (the shards' union *is* the log), rebalancing loses nothing, shard assignment is bounded, and a connection-**aggregating hub** collapses socket blow-up from *O(clients × enclaves)* to *O(clients) + O(enclaves)* — relaying frames **verbatim** as a metadata-only router that never sees content or becomes an auth point. A *Telegram-scale* workload is mechanically modelled to pass; raw throughput is *measured*, but the **correctness of scaling** is *proved* — so the system grows without any guarantee quietly breaking.
**What you still trust** is small and named: the platforms ENC runs on (the WebAssembly runtime, the browser, the Cloudflare edge), a few standard cryptographic primitives taken as axioms (Schnorr, SHA-256), and the integrity of code distribution. Everything above that line is backed by proofs you can check — §X measures exactly how much.
***
### VII. AppGen — redefining every app for agents
A verified core isn't safe on its own. The **app** is where your **identity** lives and the surface your **agent** acts through — so an unverified app is a trusted black box sitting between you and the proof, free to misuse the very keys and permissions the protocol so carefully bounds. Verifying only the node leaves the most dangerous layer unchecked: the app's behavior has to be verified *too*. That's why, on ENC, **apps are generated** — emitted from the same proven core, so the parts that can hurt you (how it wields your identity, keys, and authorization) are verified *by construction*, not hand-written and hoped-safe.
#### Apps, generated not hand-built
An app is mostly declarative JSON — data types, mapping, actions, a UI tree — compiled by the **Flow** engine. Flow even **resolves the infrastructure for you**: it matches each piece of state to an enclave that can host it by its *access model* (who must read it back, how many parties, public or private), so you never hand-wire where data lives.
The **interface isn't exempt from the math** either. The **ui-kit** builds views from a formally-specified **atom algebra** — a closed set of UI atoms with composition laws — and its rendering implementation is proven to **refine** that algebra.
From that one definition ENC generates the **typed SDK**, the **tests**, native clients for **\~10 platforms** (built instantly by platform adaptors, not rewritten), and **agent skills** — so an agent drives the app through the same verified surface a human's SDK uses.
#### From a sentence of intent
**AppGen** goes the last mile: it turns an **intent in natural language** into a validated specification and then a generated, verified app — correctness proved once and reused across many. The generated apps are **formally verified too** — not because each is hand-proved, but because the pipeline is proven and the app is generated from a tiny reviewed spec: you review the *sentence* — a human stays on the intent, which is tiny and human-scale — and the math verifies the *implementation*, which no longer is; cross-platform consistency is a **theorem**, so the same app behaves identically on all \~10 targets. **Instant, ten platforms, verified — because the math is small and elegant enough to do the expanding for you.**
#### Redefining the app landscape
This is the paradigm's reach: the same machine that produces a verified, sovereign, agent-native **messenger** produces a **marketplace, a wallet, a social feed, a registry, a payments app, an identity app** — each trust-minimized, owned by its users, and usable by agents, *by construction.* The ambition is not "a few apps on a new protocol." It is to **redefine the existing app landscape for the agent era** — every category re-expressed as a verified, composable, sovereign ENC app, generated rather than re-audited one by one. And it's already underway, not hypothetical: **\~1,500 existing apps** — from health systems to social feeds and payments — have already been **redefined as formally-specified ENC apps** — a corpus **empirically built by agents** that AppGen draws on to turn a **sentence of intent** into the next app **instantly, with formally-verified security, across all \~10 platforms**.
#### Plugins & custom apps
Plugins make this extensible without bloating the proven core: the kernel encodes only what *all* apps share — the state machine and RBAC, generated and type-checked — and **everything outside it is a composable plugin** filling a **typed slot**. Read-side projections (ranker, indexer, aggregator, filter), write-side gates (spam, moderation, fraud, validation), and the crypto suite (sign, verify, key-exchange, ratchet, group) are all slots; today **\~41 plugin packages** fill the kernel-defined ones. Each slot's *type* is its contract and its assumptions join the verification surface — so you build **custom apps** the same generated way — spin up new enclaves and infra, compose existing enclaves, or extend the verified SDKs, plugins, and app UIs — and they *inherit* the proofs rather than re-earning trust. ENC provides the **whole toolchain** for exactly this.
***
### VIII. Verifiable data → composability
Verifiability matters far beyond "was this message tampered with." A piece of **non-verifiable** data is *trapped in the conversation*: a message, a screenshot, a P2P chat record only means something to the people in the room, in the moment. Hand it to a third party, another app, or an agent and it's a forgeable claim. **Verifiable data is portable trust** — it can *leave* the room and be checked by anything, *without re-trusting the source.* It stops being a dead end and becomes a **building block.**
That is the unlock: **verifiability is the precondition for composability.** An ENC object is a **typed, verifiable thing** — a **payment receipt**, an **API call and result**, a **vote**, a signed **agreement**, an **attestation** — that can be handed to an escrow, court, or tax tool and verified; consumed by another app as a *trusted input*; or **chained**, each step verifying the last *without trusting it.*
A clean test for where verifiability earns its place: **anything the data must be trusted by someone *not in the room*** — money, agreements, cross-app inputs, an agent, or future-you after a reinstall. (Two people just chatting don't need it.) One honest boundary: verifiability kills **forgery, tampering, reordering, and deniability** — it does *not* make content *true.* A verified object proves *"X really committed to this, unaltered, in order, and was allowed to"* — which, for money and agents, is exactly what you needed: *can I hold them to it, and can anyone check?*
#### Verifiable, not exposed
And verifiable does **not** mean *exposed.* With **zero-knowledge proofs**, data stays **private** while remaining verifiable — you can prove a balance is sufficient, a member is authorized, or a rule was followed *without revealing the content itself.* Privacy and verifiability aren't a trade-off; ZK gives you both.
So ENC isn't a verifiable messenger — it's a **composable, verifiable data fabric.**
***
### IX. Built for agents
An agent has no human judgment to fall back on — so the trust a human used to supply (reputation, a gut-check, support) must become something the machine can **compute.** The only computable trust is **verification.** Walk one transaction — *your agent buys a service from a stranger's agent and pays* — and every ENC primitive is load-bearing:
* **Authenticity** → the quote is signed; your agent can't be fed a forged or injected one.
* **Bounded authority** → your agent acts under a **derived sub-key** carrying a *scoped, revocable* grant — "up to $50, this category" — and the kernel structurally prevents more, *even if your agent is buggy or hijacked.* Compromising the agent never exposes your root identity. That's what makes delegating to an AI you don't fully trust survivable — the leash is math, not hope.
* **Verifiable receipts** → the payment emits a proof the seller's agent *checks* before releasing. Agent-to-agent commerce is impossible without it.
* **Ordered tamper-evidence** → "deposit *before* release" is reliable.
* **Mechanical disputes** → a third party verifies the signed, ordered log and sees who deviated.
* **Composability** → the receipt feeds an accounting agent; the next agent verifies the last one's output without trusting it.
At every step, **trust travels with the data**, because two agents that share no central authority can't trust each other any other way. That's the gap between agents calling APIs *inside one vendor's walls* (siloed) and an **open agent economy where agents from different parties transact** — and that wave is cresting now (agent-payment rails are hitting exactly this wall and faking it with cards and API keys inside silos).
#### Agent apps belong on ENC
So the framing flips: it isn't "agent apps *need* verifiability" — it's that **agent apps should be *built on* ENC.** On ENC an agent gets, *for free*, the five things an autonomous actor actually needs:
* **Identity** — a sovereign, portable agent identity (a derived sub-key), with no central account to revoke or impersonate.
* **Memory** — durable, portable, *verifiable* memory: its enclave is an append-only log it owns and can carry anywhere, not state trapped in one vendor's database.
* **Privacy** — pluggable, formally-specified encryption over everything it holds.
* **Composability** — typed, verifiable data that other agents and apps consume as trusted input, so work chains across parties.
* **Verifiability** — every action is checkable, so trust travels *with the data*, not with a relationship.
Add RBAC-**bounded** delegation and a **wallet** on top, and **if an agent can take any responsibility, ENC is the substrate it should stand on.** "Agents are just regular users" is only *safe* because it's verifiable and bounded.
***
### X. What you have to trust — minimized, and measured
The aim isn't zero trust — it's a trusted surface that is **small, explicit, and measured**: the soundness of the host platforms ENC runs on (the WebAssembly runtime, the browser, the edge), a few well-studied cryptographic primitives, and the integrity of code distribution. Everything above that line — that the running code matches the proven spec, that history is intact, that state is valid, that your agent stayed in bounds — is backed by **proofs you, or your agent, can check.** The trust lattice puts a number on it.
The bet is simple, and it's a paradigm bet: software you depend on shouldn't require you to trust the people who run it — or the AI that wrote it. And it cuts deeper than the operator: the same force that broke human review (§I) forces the real paradox of the agent era — *you can't trust an app an agent hand-built either*, vibe-coded past anything a human could vouch for. ENC resolves it **one level up**. Agents don't hand-build the apps you depend on; agents build the verified **system that *generates* them** — **SpecGen → CodeGen → AppGen**, a toolchain architected on these principles so that every output carries the math stamped into it. Trust never rests on the builder, human or machine; it rests on the proof the builder was forced to satisfy. **Don't trust the builder — trust the proof it had to satisfy.**
And that proof is **empirical, not theorized** — formalized upward from how real systems actually behave (§VI, §VII), not dreamed top-down and hoped into code. Replacing human audit with machine-checked proof doesn't just make iteration *safe* — it makes it *fast*: a proof checks in an instant where a human review would stall, so each new version is regenerated and **re-proven** *before it ships*, reproducibility-gated, advancing toward that ceiling without anyone re-vouching by hand — safe *because* verified, not because someone signed off. The human still owns the one thing that stays human-scale: the **intent** — you review the *sentence*, and the math owns the implementation, the audit, the code that ships. This is the whole toolchain, not just the protocol: **built by agents, for agents.** Make the spec, the proofs, the code, and the running bytes one continuous, checkable chain; let your data leave the room carrying its own proof; generate every app verified and sovereign; and **prove, not promise**, that the rules were followed. The old paradigm trusted. The new one verifies. **Own it — and prove it.**
## Tutorials
Hands-on, build-something walkthroughs. Where the [Developer Guide](/guide) covers each tool
and the [SDK Reference](/sdk) documents every function, a tutorial takes you **end to end** —
from an empty project to a working ENC app you can run.
### Build a custom Personal app
The first tutorial builds a small **custom Personal app** on the
[`personal` App SDK](/sdk/apps/personal) (`@enc-protocol/personal-cli` → `PersonalSdk`). You'll:
* create an identity and mint your own **Personal enclave** from a manifest;
* write **public posts** and **owner-only encrypted private notes** (via the
[identity-aead](/sdk/plugins/identity-aead) plugin the SDK applies for you);
* read your feed, your private notes, and cross-enclave **profiles**;
* subscribe to live updates and wire it into a minimal client.
By the end you'll have a personal micro-app — your own public posts and a private vault —
backed by a verifiable enclave, with the SDK doing the signing, RBAC, and encryption.
**Prerequisites**
* Node 18+ and the [SDK installed](/guide/sdk).
* A backend to run against: either a local [node](/guide/node), or the zero-setup in-process
[`memory`](/sdk/memory) adapter (great for following along without deploying anything).
It builds directly on the foundations from the Developer Guide — if you haven't yet, skim
[Building with the SDK](/guide/sdk) first for the identity → enclave → submit/query → verify
loop this tutorial puts to work.
### The build, step by step
1. [Run a local node](/tutorials/personal/node)
2. [Run a dataview server](/tutorials/personal/dataview)
3. [Deploy the Personal enclave](/tutorials/personal/enclave)
4. [Write the frontend app](/tutorials/personal/frontend)
5. [Write a test](/tutorials/personal/test)
6. [Deploy to production](/tutorials/personal/deploy)
[Start → Run a local node](/tutorials/personal/node)
## 2. Run a dataview server
The Personal app has a **cross-enclave read** — `profiles` aggregates the latest profile across
every user's enclave. That's served by a **dataview**: a small Worker that subscribes to the
node, projects matching events into a queryable read-model, and answers `query`s. (Your own
`public` / `private` reads come straight from the node and need no dataview.)
The Personal app ships its dataview. Run it locally, pointed at the node from step 1:
```bash
cd impl-cli/apps/personal/dataview
# a throwaway signing key for local dev (file is gitignored)
echo "DATAVIEW_PRIVATE_KEY=$(python3 -c "print('11'*32)")" > .dev.vars
npx wrangler dev --port 8788 # → http://127.0.0.1:8788
```
Its `NODE_URL` defaults to `http://localhost:8787`, so it follows your local node. Keep it
running alongside the node.
> A dataview is **optional** — a purely private app (a notes vault, a DM) skips it and reads the
> node directly. The Personal app uses one only for its public `profiles` feed.
**Next:** [Deploy the Personal enclave →](/tutorials/personal/enclave)
## 6. Deploy to production
Local works — now ship the node and the dataview, and point the app at them. All three are
Cloudflare Workers.
**Node.** Deploy the production node (see [Run a Node → Deploy](/guide/node#deploy)):
```bash
cd impl-node/cf-worker
yarn wrangler deploy # → https://.workers.dev
```
**Dataview.** Set its signing secret and the production node URL, then deploy:
```bash
cd impl-cli/apps/personal/dataview
wrangler secret put DATAVIEW_PRIVATE_KEY # paste a real 32-byte hex key
./deploy.sh --var NODE_URL:https://.workers.dev
```
**App.** Point the helper at the deployed node — the same code you ran locally:
```js
import { createPersonalSdk } from './enc-personal.mjs'
const sdk = await createPersonalSdk({ nodeUrl: 'https://your-node.example.com' })
```
Then ship the frontend to any static host. Per-enclave isolation, geo-routing, and scaling are
handled by the node's Durable Object model; the dataview keeps the public `profiles` feed live.
**Verify** the deploy end-to-end with the node's test harness (see
[Run a Node → Verify](/guide/node#verify-a-deploy)).
🎉 You've built and shipped a custom Personal app on ENC — local node, dataview, enclave,
frontend, tests, and production, all on one verifiable protocol.
## 3. Deploy the Personal enclave
An enclave is minted by submitting a **signed manifest** to the node — its RBAC schema,
states, and traits. The Personal app's manifest is bundled in the SDK, so you never write it by
hand.
In this app you don't mint it as a separate step: the `createPersonalSdk({ mode: 'cf' })`
helper you'll write [next](/tutorials/personal/frontend) mints the owner's Personal (and Group)
enclave on first run — it calls `NetworkAdapter.createEnclave(...)` with the bundled manifest
and gets back the derived **enclave id** (deterministic from the manifest plus your key).
Minting is idempotent per owner key, so running the app again reuses the same enclave.
:::tip
Prefer the terminal? The `enc` CLI can mint one ahead of time against your local node:
```bash
npm install -g @enc-protocol/cli --registry https://npm-registry.ocrybit.workers.dev/
enc keygen # identity at ~/.enc/key.json
NODE_URL=http://localhost:8787 enc personal create # mint the Personal enclave
```
:::
Either way the result is the same: a Personal enclave on your node, owned by your key, where
your posts and private notes live — one Personal enclave per owner.
**Next:** [Write the frontend app →](/tutorials/personal/frontend)
## 4. Write the frontend app
Now the app, using the [`personal` SDK](/sdk/apps/personal). `PersonalSdk` is platform-agnostic
— it takes one **adapter** per enclave it declares (Personal + Group). We use the formalized
**`NetworkAdapter`** from `@enc-protocol/client` (codegen'd from the Lean spec): it does
plaintext signed writes and the **ECDH session-authenticated reads** the node requires.
```bash
npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/
npm install @enc-protocol/personal-cli @enc-protocol/client @enc-protocol/protocol-runtime
```
### `enc-personal.mjs` — wire the SDK to a node
```js
// enc-personal.mjs
import { PersonalSdk } from '@enc-protocol/personal-cli'
import { flattenEnclaveManifest } from '@enc-protocol/protocol-runtime'
import { createIdentity } from '@enc-protocol/client'
import { NetworkAdapter } from '@enc-protocol/client/network-adapter.js'
export { createIdentity }
/** Build a PersonalSdk wired to a real node (mints the owner's enclaves). */
export async function createPersonalSdk({ nodeUrl, identity = createIdentity() }) {
const M = PersonalSdk.MANIFESTS
const adapters = {}
for (const name of M.app.enclaves) { // ['Personal', 'Group']
// the bundled enclave manifest → the wire RBAC manifest for this owner
const wire = flattenEnclaveManifest(M.enclaves[name]).enclaveManifest(identity.publicKeyHex)
const adapter = new NetworkAdapter(nodeUrl, '', identity)
await adapter.createEnclave(wire) // mint + wire the ECDH reader
adapters[name] = adapter
}
const sdk = new PersonalSdk({ adapters, identity: { pubHex: identity.publicKeyHex } })
await sdk.init()
return sdk
}
```
### `app.mjs` — the app
```js
// app.mjs — NODE_URL=http://localhost:8787 node app.mjs
import { createPersonalSdk } from './enc-personal.mjs'
const sdk = await createPersonalSdk({ nodeUrl: process.env.NODE_URL || 'http://localhost:8787' })
await sdk.submitPublic({ draft: 'hello from my Personal app' })
await sdk.submitPrivate({ draft: 'a note only I can read' })
const posts = await sdk.queryPublic()
const notes = await sdk.queryPrivate()
console.log('public feed:')
for (const p of posts) console.log(' •', JSON.parse(p.content).draft)
console.log('private notes:')
for (const n of notes) console.log(' •', JSON.parse(n.content).draft)
```
Run it (with the [node from step 1](/tutorials/personal/node) running):
```bash
$ NODE_URL=http://localhost:8787 node app.mjs
public feed:
• hello from my Personal app
private notes:
• a note only I can read
```
The `NetworkAdapter` mints the enclave, signs writes, and runs the **ECDH session-authenticated
reads** the node enforces — all from the formalized client SDK. Each query returns event
objects whose `content` is the JSON you wrote, so `JSON.parse(p.content).draft` reads the field
back.
**Next:** [Write a test →](/tutorials/personal/test)
## 1. Run a local node
The app talks to an ENC node. For development, run one locally on port `8787`.
```bash
git clone https://github.com/enc-protocol/impl-node
cd impl-node
yarn install
yarn wrangler dev --config test/wrangler.toml --local # → http://127.0.0.1:8787
```
This is the **spec-shape node** the public SDK speaks to — it mints an enclave from a signed
manifest commit over `POST /` and serves ECDH-authenticated reads (the same node the
[test](/tutorials/personal/test) boots). Leave it running in its own terminal.
Quick check it's up:
```bash
curl -s http://localhost:8787/ | head -c 60 # the protocol banner
```
:::tip
The WASM `cf-worker` node bootstraps differently (`init-with-priv`), so for this SDK-driven
tutorial use the spec-shape Worker above.
:::
**Next:** [Run a dataview server →](/tutorials/personal/dataview)
## 5. Write a test
Test the app the way it runs — against a **real node**, with [vitest](https://vitest.dev). A
global setup boots a node for the run; the tests mint an enclave, write, and read back over HTTP.
```bash
npm install -D vitest
```
`vitest.config.mjs`:
```js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { globalSetup: './test/global-node.mjs', hookTimeout: 120_000, testTimeout: 60_000 },
})
```
`test/global-node.mjs` — boots the node (and reuses one if already running):
```js
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const NODE_DIR = process.env.NODE_DIR || resolve(__dirname, '../../impl-node')
const PORT = Number(process.env.NODE_PORT || 8787)
const BASE = `http://localhost:${PORT}/`
export default async function () {
try { if ((await fetch(BASE)).ok) return () => {} } catch {} // reuse a running node
const node = spawn(
'npx', ['wrangler', 'dev', '--config', 'test/wrangler.toml', '--local', '--port', String(PORT)],
{ cwd: NODE_DIR, stdio: 'ignore', detached: true },
)
for (let i = 0; i < 90; i++) {
await new Promise((r) => setTimeout(r, 1000))
try { if ((await fetch(BASE)).ok) return async () => { try { process.kill(-node.pid) } catch {} } } catch {}
}
throw new Error(`ENC node did not start at ${BASE}`)
}
```
`personal.test.mjs`:
```js
import { test, expect } from 'vitest'
import { createPersonalSdk } from './enc-personal.mjs'
const nodeUrl = 'http://localhost:8787'
test('public post round-trips on a real node', async () => {
const sdk = await createPersonalSdk({ nodeUrl })
await sdk.submitPublic({ draft: 'gm everyone' })
const posts = await sdk.queryPublic()
expect(posts.map((p) => JSON.parse(p.content).draft)).toContain('gm everyone')
})
test('private notes round-trip for the owner', async () => {
const sdk = await createPersonalSdk({ nodeUrl })
await sdk.submitPrivate({ draft: 'a private note' })
const notes = await sdk.queryPrivate()
expect(notes.map((n) => JSON.parse(n.content).draft)).toContain('a private note')
})
```
Run them:
```bash
$ npx vitest run
Test Files 1 passed (1)
Tests 2 passed (2)
```
The tests sign real commits and read them back through the node's ECDH-authenticated query path
— the same code your app runs, exercised against the real protocol, not a mock.
**Next:** [Deploy to production →](/tutorials/personal/deploy)
## ENC Protocol — Spec
The normative protocol spec, divided by dependency layer. The **kernel** holds the
protocol math and canonical semantics; the **node** realizes it at runtime; and the
**app** layer — enclave profiles and confidentiality plugins — builds on top.
### Kernel
Protocol math and canonical semantics.
* [Protocol summary](/spec/kernel/spec) — trust model, core abstractions, cryptography, wire protocol, event semantics, lifecycle
* [RBAC](/spec/kernel/rbac) — RBAC v2, contexts, manifests, authorization, per-event processing
* [Sparse Merkle Tree](/spec/kernel/smt) — state and proof semantics
* [Certificate Transparency](/spec/kernel/ct) — log and proof bundles
### Node
Node, server, and runtime behavior that realizes the kernel.
* [Node API](/spec/node/node-api) — REST / WS / session / errors, hooks, DataView surfaces
* [Migration](/spec/node/migration) — migration modes, checkpoints, backup / restore
* [ZK validity proofs](/spec/node/zk) — zero-knowledge folded validity proofs
### Enclaves
The enclave profile catalog and per-profile semantics.
* [Catalog](/spec/app/enclaves)
* [Personal](/spec/app/enclaves/personal)
* [DM](/spec/app/enclaves/dm)
* [Group](/spec/app/enclaves/group)
* [Registry](/spec/app/enclaves/registry)
### Plugins
Confidentiality plugins and the cryptographic suite registry.
* [Catalog](/spec/app/plugins)
* [Cryptographic suites](/spec/app/suites)
* [ratchet-pair](/spec/app/plugins/ratchet-pair)
* [mls-lazy](/spec/app/plugins/mls-lazy)
* [ecdh-envelope](/spec/app/plugins/ecdh-envelope)
* [identity-aead](/spec/app/plugins/identity-aead)
## Enclave Migration
This document specifies enclave **migration** — the controlled transfer of an enclave from one node to another while preserving its full event history and identity. The `Migrate` event, the three migration modes (eager / lazy / fork), checkpoint verification, split-brain prevention, and the backup pattern that re-binds the old enclave's SMT root are all defined here.
***
### Table of Contents
* [Migrate Event](#migrate-event)
* [Migration Modes](#migration-modes)
* [Checkpoint Verification](#checkpoint-verification)
* [Split-Brain Prevention](#split-brain-prevention)
* [Backup Pattern](#backup-pattern)
* [Enclave Snapshot Format](#enclave-snapshot-format)
* [Snapshot Endpoints](#snapshot-endpoints)
***
### Migrate Event
**Type:** `Migrate`
**Content Structure:**
```json
{
"new_sequencer": "",
"prev_seq": 1234,
"ct_root": ""
}
```
**Fields:**
| Field | Required | Description |
| -------------- | -------- | -------------------------------------------------------------------------------------------------- |
| new\_sequencer | Yes | Public key of the new sequencer node |
| prev\_seq | Yes | Sequence number of the last event BEFORE the Migrate event (Migrate will have seq = prev\_seq + 1) |
| ct\_root | Yes | CT root at prev\_seq (proves log and state before Migrate) |
**Authorization:**
* Only Owner can issue Migrate.
* The commit MUST be signed by Owner.
**Migration Barrier:**
Once a node accepts a Migrate commit:
* The node MUST reject all other pending commits.
* The Migrate event MUST be the final event from this sequencer.
* No concurrent commits are allowed during migration.
* The node MUST immediately close the current bundle after finalizing Migrate.
**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`:
* Events seq=100-105 are in an open bundle
* Migrate commit arrives, finalized as seq=106
* Bundle closes immediately with events seq=100-106
* No events seq=107+ are possible from this sequencer
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 any discovery mechanism in use (e.g., the [Registry enclave](/spec/app/enclaves/registry) via `reg_enclave`) — outside the core migration protocol.
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 trait 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.
Alternatively, a folded validity proof over `(r_0 … state_hash)` attests that every transition was valid without replaying the log — see [zk.md](/spec/node/zk) → Folding. (Replay remains the baseline; the validity proof is an O(1) optimization, not a replacement for data availability.)
***
### Checkpoint Verification
The `ct_root` field serves as a checkpoint:
* CT root commits to both the event log AND state (via `state_hash` in leaves)
* New node recomputes CT root from received log
* If mismatch, migration is rejected
***
### Split-Brain Prevention
After migration:
* Old node's sequencer key is no longer valid.
* Any events finalized by old node after Migrate are invalid.
* Sequencer discovery is out of scope for core; clients periodically re-check whatever discovery mechanism they use (e.g., the [Registry enclave](/spec/app/enclaves/registry)).
**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 its discovery mechanism (e.g., a Registry lookup) or via `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 its discovery mechanism.
***
### Backup Pattern
To enable forced takeover, ensure someone has the full event log:
**Option 1: Owner maintains backup**
* Owner's client stores all events locally
**Option 2: Dedicated Backup role**
* Define a custom role with P (Push) permission for all event types
* Assign to a backup service
Schema example — define a `backup` trait with P ops on all events:
```json
{ "event": "*", "operator": "backup", "ops": ["P"] }
```
The wildcard `*` means the backup trait 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:
>
> * Forced takeover is blocked (intentional security — only Owner can authorize sequencer changes)
> * Mitigation: ensure Owner's client/key is highly available, or use Transfer(owner) before sequencer goes offline
***
### Enclave Snapshot Format
When the old node transfers full state to the new node (Peaceful Handoff step 3) or a Backup supplies the log to a new sequencer (Forced Takeover step 3), the bytes on the wire need a stable interchange format. The protocol defines `.enc` as the canonical format.
A `.enc` file is the **complete enclave state**: all finalized events, the SMT, the CT log, and the per-event metadata needed to rebuild the enclave on any compliant node. The format is hash-pinned (sha256 footer) and version-tagged so the receiving node can refuse incompatible kernels rather than silently corrupt state.
#### File Layout
```
┌──────────────────────────────────────────────────────────┐
│ Header (32 bytes, fixed) │
│ │
│ offset size field │
│ ───── ──── ────────────────────────────────────── │
│ 0 4 magic b"ENC\x01" │
│ 4 4 layout_ver u32 LE │
│ 8 4 kernel_ver u32 LE (semver-packed) │
│ 12 4 flags u32 LE (see Flags) │
│ 16 8 payload_size u64 LE (bytes that follow)│
│ 24 8 reserved 0u64 │
├──────────────────────────────────────────────────────────┤
│ Payload (variable, payload_size bytes) │
│ │
│ The complete enclave state in the producing kernel's │
│ natural layout (see Payload Encoding below). │
├──────────────────────────────────────────────────────────┤
│ Footer (32 bytes, fixed) │
│ │
│ sha256(header || payload) │
└──────────────────────────────────────────────────────────┘
Total: 32 + payload_size + 32 bytes
```
#### Magic
`b"ENC\x01"` — four bytes, fixed. Lets restorers reject non-snapshot inputs immediately.
#### layout\_ver (u32 LE)
Version of THIS file format. v1 = the format above. A future v2 MAY move fields or change framing; restorers MUST refuse on unknown `layout_ver`.
#### kernel\_ver (u32 LE)
Semver of the kernel that produced the snapshot, packed:
* bits 0-15: patch
* bits 16-23: minor
* bits 24-31: major
**Compatibility rules:**
| Producer / restorer relationship | Restorer action |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Same `kernel_ver` byte-for-byte | Accept. |
| Differs in patch only, ≥ 1.0 | Accept; MAY warn. |
| Differs in minor only, ≥ 1.0 | Accept; SHOULD warn. |
| Differs in major | REFUSE. Requires explicit out-of-band migration. |
| Any difference at all, pre-1.0 kernels (0.x.y) | REFUSE. Pre-1.0 layouts can shift between any release; defer loosening until a kernel reaches 1.0 with a documented backwards-compat contract. |
On refusal, the restorer MUST surface `KERNEL_VERSION_MISMATCH` (the named error code in [`node-api.md` §Snapshot Endpoints](/spec/node/node-api#snapshot-endpoints)) along with both versions.
#### Flags (u32 LE)
```
bit 0: COMPRESSED — payload is brotli-compressed
bit 1: SELF_CONTAINED — payload prepends a copy of the kernel binary for offline verification
bit 2: ENCRYPTED — payload encrypted; key derivation in implementation-defined metadata
bit 3-31: reserved — MUST be 0
```
v1 producers MAY set all flags to 0 (no compression, no embedded kernel, no encryption). Restorers MUST refuse on unknown flag bits.
#### Footer
`sha256(header || payload)`, 32 bytes. Restorers MUST recompute and refuse on mismatch — this is what makes the snapshot integrity-checked even when transferred over an untrusted channel (e.g., R2 / S3 / IPFS / hand-off via USB drive).
#### Payload Encoding
Two payload encodings are defined; restorers detect by sniffing the first four bytes of the payload:
* **Compact (`payload[0..4] == "ENC\x01"`)** — just the populated storage buffer. Typical 200 KB – few MB for a single-bundle enclave; 10-30× smaller than the raw form. RECOMMENDED for migration over the network.
* **Raw memory dump (anything else, typically zero bytes from a WASM data segment)** — the entire kernel address space as a single byte blob. Typical 65 MB even for small enclaves (the kernel reserves a full bump-allocator arena). Useful for forensic snapshots that capture allocator state exactly.
Both encodings carry the same logical enclave state — every event, every SMT entry, every CT leaf. The choice is bytes-on-the-wire vs. memory-layout fidelity. A future `layout_ver = 2` can assign a flag bit to make the distinction explicit instead of sniffed.
#### Snapshot Procedure
```
1. Kernel writes its full state to a contiguous byte buffer `payload`.
2. Compute header bytes: magic || layout_ver || kernel_ver || flags || payload_size || reserved
3. Compute footer = sha256(header || payload)
4. Emit: header || payload || footer
```
#### Restore Procedure
```
1. Read first 32 bytes → header.
Verify magic == "ENC\x01". Reject otherwise.
Verify layout_ver is known. Reject otherwise.
2. Read next payload_size bytes → payload.
3. Read next 32 bytes → expected_footer.
4. Compute actual_footer = sha256(header || payload).
Reject on mismatch.
5. Apply kernel_ver compatibility rules. Reject on major bump.
6. Sniff payload[0..4] to pick the encoding; install into a fresh kernel instance.
7. Run the kernel's self-test entry point. Reject on non-zero result.
8. The enclave is live; sequencing can resume.
```
Steps 1-5 are MUST. Step 6 is implementation-dependent on the encoding. Step 7 is OPTIONAL but RECOMMENDED — a self-test catches structural corruption the sha256 footer alone cannot (e.g., bit-flips in the page table that happen to balance).
***
### Snapshot Endpoints
The `.enc` byte format is wire-format only; the protocol surface that moves snapshots between nodes is two HTTP endpoints. They are specified in [`node-api.md` §Snapshot Endpoints](/spec/node/node-api#snapshot-endpoints):
| Method | Path | Body | Purpose |
| ------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------ |
| `GET` | `/enclaves/:id/snapshot` | — | Download the complete enclave state as `application/octet-stream` `.enc` |
| `POST` | `/enclaves/:id/restore` | `application/octet-stream` `.enc` | Bootstrap a fresh enclave on the receiving node from a `.enc` payload |
\::: extension-point id=migration-snapshot-restore-authz class=local\_policy
reason: authorization is operator-deployment policy; the wire format and verification rules are the same regardless of who's authorized to call
Authorization for the `/enclaves/:id/snapshot` and `/enclaves/:id/restore` endpoints is set by the operator (typically Owner only for `restore`, public-with-RBAC-projection for `snapshot`). The wire format and verification rules are the same regardless of who's authorized to call them.
\:::
***
## Node API
This document specifies the **ENC Node API** — the HTTP, WebSocket, and webhook surface every conforming node exposes. It covers the high-level surface and Session tokens, the REST endpoints and Filter query DSL, the WebSocket streaming subscription protocol, the proof retrieval endpoints, the push / notify and webhook delivery semantics, the wire-level handling of encrypted payloads, and the full error catalog.
***
### Table of Contents
* [Overview](#overview)
* [Session](#session)
* [Enclave API](#enclave-api)
* [Filter](#filter)
* [WebSocket API](#websocket-api)
* [Proof Retrieval API](#proof-retrieval-api)
* [Webhook Delivery](#webhook-delivery)
* [Registry DataView API](#registry-dataview-api)
* [Push/Notify](#push-notify)
* [Encryption](#encryption)
* [Error Codes](#error-codes)
* [Error Response Format](#error-response-format)
***
### Overview
#### Base URL
```
https:///
```
#### Content Type
All requests and responses use `application/json`.
#### Authentication
| Operation | Method |
| ---------------- | --------------------------------------------------------------------------------------------------- |
| Commit | Schnorr or ECDSA signature over commit hash (per `alg`; see [Signature Schemes](/spec/kernel/spec)) |
| Query | Session token (see [Session](#session)) |
| Pull | Session token |
| WebSocket Query | Session token |
| WebSocket Commit | Schnorr or ECDSA signature (per `alg`) |
#### Endpoints Summary
**Enclave API (all enclaves):**
| Method | Path | Description |
| ------ | ------------------------ | ---------------------------------------------------------------------------------------- |
| POST | `/` | Submit commit, query, or pull request |
| WS | `/` | Real-time subscriptions |
| GET | `/enclaves/:id/snapshot` | Download complete enclave state (`.enc`) — see [Snapshot Endpoints](#snapshot-endpoints) |
| POST | `/enclaves/:id/restore` | Bootstrap enclave from a `.enc` payload — see [Snapshot Endpoints](#snapshot-endpoints) |
**Node Bootstrap (legacy):**
| Method | Path | Description |
| ------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST | `/create-enclave` | Legacy self-serve enclave bootstrap endpoint; see [Legacy `/create-enclave` reference](#legacy-create-enclave-reference-kept-for-backward-compat). |
Deployments can omit `/create-enclave` when bootstrap is gated externally (e.g., a hosted node service like `impl-cloud` that pre-provisions enclaves before the node accepts traffic). The detailed legacy section specifies the absence response.
**Request types:** Commit, Query, Pull
**Proof Retrieval API:**
| Method | Path | Access | Description |
| ------ | ----------------------- | ------ | ------------------------ |
| GET | `/:enclave/sth` | Public | Current signed tree head |
| GET | `/:enclave/consistency` | Public | CT consistency proof |
| POST | `/inclusion` | R | CT inclusion proof |
| POST | `/bundle` | R | Bundle membership proof |
| POST | `/state` | R | SMT state proof |
| POST | `/state-batch` | R | Batched SMT state proofs |
**Registry DataView API (Registry enclave only):**
| Method | Path | Description |
| ------ | ----------------------- | ----------------------------------------------- |
| GET | `/nodes/:seq_pub` | Resolve node by public key |
| GET | `/enclaves/:enclave_id` | Resolve enclave → enclave record + hosting node |
| GET | `/identity/:id_pub` | Resolve identity by public key |
***
***
### Session
Session tokens provide stateless authentication for queries. An identity authorizes a session key in one of two ways: the **Schnorr-algebraic** token below (default), or a **delegated** token authorized by a recoverable ECDSA signature (for identities that sign with ECDSA; see [Delegated Session](#delegated-session-ecdsa-authorized)).
#### Token Format
136 hex characters = 68 bytes
```
Bytes 0-31: r (Schnorr signature R value)
Bytes 32-63: session_pub (x-only public key)
Bytes 64-67: expires (big-endian uint32, Unix seconds)
```
#### Client Derivation
```
1. expires = now + duration (max 7200 seconds)
2. message = "enc:session:" || be32(expires)
3. sig = schnorr_sign(sha256(message), id_priv)
4. r = sig[0:32]
5. s = sig[32:64]
6. session_priv = s
7. session_pub = point(s)
8. session = hex(r || session_pub || be32(expires))
```
#### Node Verification
O(1) EC math — no signature verification.
```
1. Parse: r, session_pub, expires from token
2. Check: expires > now - 60 (allow 60s clock skew)
3. Check: expires ≤ now + 7200 + 60 (allow 60s clock skew)
4. message = "enc:session:" || be32(expires)
5. expected = r + sha256(r || from || message) * from
6. Verify: session_pub == expected
```
Clock skew tolerance (±60 seconds) allows clients with slightly-off clocks to connect.
**Curve Parameters:**
All EC arithmetic uses secp256k1. The curve order is:
```
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
```
All scalar operations (addition, multiplication) are performed modulo `n`.
#### Delegated Session (ECDSA-authorized)
Identities that sign with ECDSA rather than Schnorr (see [Signature Schemes](/spec/kernel/spec)) cannot produce the Schnorr-algebraic token above, because `session_pub = r + e·from` requires a Schnorr signature by the identity key. Such an identity MAY instead authorize a fresh **ephemeral session key** with a recoverable ECDSA signature.
**Client Derivation:**
```
1. expires = now + duration (max 7200 seconds)
2. (session_priv, session_pub) = fresh ephemeral secp256k1 keypair
3. auth_msg = sha256("enc:session:delegate:" || session_pub || be32(expires))
4. session_auth = ecdsa_sign_recoverable(auth_msg, id_priv) # { sig: r||s compact, recovery }
5. session = hex(0x00*32 || session_pub || be32(expires)) # the r slot is unused (32 zero bytes)
```
`session_auth` is carried **alongside** the token in the request payload (it does not fit the 68-byte token): the Query/Pull encrypted content gains an optional `session_auth` field.
**Node Verification:**
```
1. Parse: session_pub, expires from token; read session_auth from the request payload
2. Check expiry (same ±60s skew bounds as the Schnorr session)
3. auth_msg = sha256("enc:session:delegate:" || session_pub || be32(expires))
4. recovered = ecdsa_recover(auth_msg, session_auth.sig, session_auth.recovery)
5. Verify: x_only(recovered) == from
```
The ephemeral `session_pub` feeds Signer Derivation and ECDH exactly as a Schnorr session does — only the authorization differs. The identity key signs once to authorize the session key; the long-term key is not used again for that connection.
| | Schnorr session | Delegated session |
| ----------------------------------- | ---------------------------------------------------- | -------------------------------------------- |
| Identity binding | intrinsic (token *is* a Schnorr signature by `from`) | explicit (`ecrecover(session_auth) == from`) |
| Recoverable from token alone | no | yes (via `ecrecover`) |
| Self-contained in the 68-byte token | yes | no (auth travels with the request) |
| Requires Schnorr signing | yes | no (ECDSA only) |
#### Signer Derivation
Per-node signer for ECDH.
```
t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
signer_pub = session_pub + t * G
```
**Design Rationale:**
The `enclave` ID is included in the signer derivation so that the same `session_pub` produces different signer keys for different enclaves. This provides per-enclave key isolation:
* Session token is reusable across enclaves (same `session_pub`)
* Derived encryption key (via `signer_pub`) is enclave-specific
* A compromised signer key in Enclave A cannot decrypt messages for Enclave B
The isolation provides defense-in-depth.
**Security Properties:**
| Property | Protected? | Reason |
| ---------------------- | ----------- | ---------------------------------------------------------- |
| Cross-session reuse | ✓ | Different `session_pub` → different `t` → different signer |
| Cross-enclave reuse | ✓ | Different `enclave` → different `t` → different signer |
| Cross-node reuse | ✓ | Different `seq_pub` → different `t` → different signer |
| Same session + enclave | Same signer | Intentional — enables session continuity |
No additional replay protection is needed; the derivation inputs guarantee uniqueness.
#### Session Properties
| Property | Value |
| --------------- | ----------------------------------------------------------------- |
| Max expiry | 7200 seconds (2 hours) |
| Timestamp unit | Seconds (for uint32 compactness; API timestamps use milliseconds) |
| Reusable | Yes, until expiry |
| Per-node signer | Yes (different ECDH per node) |
| Multi-key | One connection, multiple sessions |
***
***
### Hook System (server-side policy layer)
Nodes admit commits to the protocol layer via a configurable **hook chain**.
A hook is a policy gate that examines an incoming commit and either lets
it through (`pass`) or refuses admission (`reject`). Hooks **MUST NOT**
transform commits — they only side-effect (audit, telemetry, rate-counter
increment) or reject. The protocol layer's state machine sees only the
admitted commits.
```
Policy layer (hooks) ─── side effects + reject decisions
│
▼ (admitted commits, unchanged)
Protocol layer (kernel) ── deterministic state machine, CT log, SMT roots
```
The **hook orthogonality theorem** (`Enc.Core.Hooks.Determinism.replay_invariant_under_hook_swap`)
establishes the release rule: two deployments with different hook configurations but identical admitted-commit sets MUST produce IDENTICAL protocol state. The kernel's
correctness theorems (sharding, CT log, composition, rebalance, jump-hash bound,
Telegram-scale runnability) all apply for ANY hook configuration. Hooks are
deployment policy; the protocol layer is policy-agnostic.
#### Hook chain semantics
* Hooks compose in deployer-specified order.
* Any `reject` short-circuits the chain. The commit is refused.
* All `pass` → commit is admitted to the protocol layer.
* Empty chain → admit everything (no policy; not safe for production).
#### Standard starter hooks
| Hook | Trigger | Rejects when |
| ----------------- | ------------------------------------------------ | ------------------------------------------------- |
| Bearer | `Authorization: Bearer ` header | header missing or token != configured secret |
| ProvisionerSig | `X-Provisioner-Sig` + `X-Provisioner-Ts` headers | sig invalid, ts skewed, or signer not allowlisted |
| ParseCommitSigner | (no headers — reads `commit.from_pub`) | from\_pub not in allowlist |
| RateLimitByPub | per-pubkey counter | rate exceeded |
Implementations can ship additional hooks (CF Access JWT, API key allowlist,
IP allowlist, etc.) as opt-in plugins. The intended promotion path for each
hook plugin is a formal Lean specification in `Enc.Core.Hooks.` and a
claim sidecar in `claims/prose/node/node-api.claims.yaml` (or per-plugin file).
#### Verify-once invariant
For Commit-shaped requests, the hook layer avoids verifying the commit's
Schnorr signature; the kernel verifies it as part of normal commit
processing. A `ParseCommitSigner` hook that does an allowlist check on
`commit.from_pub` (without re-verifying the sig) keeps the verification
cost at ONE signature operation per request, not two.
For non-commit requests (admin endpoints with no downstream kernel
verification), full cryptographic auth via Bearer / ProvisionerSig / JWT
is appropriate.
#### Deprecated: `/create-enclave`
The `/create-enclave` endpoint is **DEPRECATED** as of this revision.
Hosted node services and self-serve deploys use either of these bootstrap
paths:
1. **Hosted gating** — the hosting plane (e.g., `impl-cloud`) accepts
user bootstrap requests, signs the manifest commit itself, and POSTs
the resulting commit to `POST /` (the standard multiplexed endpoint).
The node treats this as any other commit; a Bearer hook authorizes
the hosting plane's API access.
2. **Self-signed bootstrap** — the operator signs a manifest commit
offline and POSTs it to `POST /` directly.
Backward-compatible implementations can retain a `/create-enclave` endpoint,
but new deployments use `POST /` plus a hook chain for bootstrap admission.
The hook chain at `POST /` covers all admission policies that
`/create-enclave` previously gated.
#### Legacy `/create-enclave` reference (kept for backward compat)
Some deployments expose a self-serve endpoint that creates a fresh
enclave by signing a manifest commit. The endpoint is **OPTIONAL**:
implementations choose whether to include it based on their
deployment model.
**When to include `/create-enclave`:**
* Self-serve nodes where any caller may instantiate a new enclave.
* Reference implementations used for protocol conformance testing.
* Local-dev / open-test infrastructure.
**When to OMIT `/create-enclave`:**
* Hosted node services (e.g., `impl-cloud`) that gate enclave creation
externally — bootstrap is performed via the hosting plane's admin
API, not the per-node HTTP surface.
* Single-tenant deployments where the enclave set is fixed at deploy
time.
* Compliance regimes that require pre-provisioning + per-enclave KYC.
Deployments that omit the endpoint MUST return `404 Not Found` so
callers can detect the absence with a single round-trip.
#### POST `/create-enclave` (when supported)
Create a fresh enclave by submitting a manifest commit. The node
generates the enclave\_id deterministically from the manifest content
and signs the bootstrap event with its node key
(`NODE_PRIVATE_KEY`).
**Request:**
```json
{
"manifest": {
"RBAC": { "use_temp": "none", "schema": [ ... ] },
"": ...
}
}
```
**Response (success):**
```json
{
"ok": true,
"enclave_id": "<64 hex chars>",
"head": { "": ... }
}
```
**Response (omitted endpoint):** `404 Not Found`
**Response (errors):** Standard error envelope (see [Error Codes](#error-codes)).
**Semantics:**
* The node MUST verify the manifest is well-formed before signing.
* The node MUST sign the bootstrap commit with the configured
`NODE_PRIVATE_KEY`.
* The resulting `enclave_id` MUST be deterministic from the manifest
content (so the same manifest from two callers produces the same
enclave\_id and idempotent collisions are detectable).
* Idempotent reposts of the same manifest MUST return the existing
enclave's `head`, not create a duplicate.
### Enclave API
#### POST / (Commit)
Submit a commit to the enclave.
**Detection:** Request contains `exp` field.
**Request:**
```json
{
"hash": "",
"enclave": "",
"from": "",
"type": "",
"content": "",
"content_hash": "",
"exp": 1706000000000,
"tags": [["key", "value"]],
"sig": ""
}
```
| Field | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| hash | hex64 | Yes | CBOR hash of commit (see spec.md) |
| enclave | hex64 | Yes | Target enclave ID |
| from | hex64 | Yes | Sender's identity public key |
| type | string | Yes | Event type |
| content | string | Yes | Event content as a UTF-8 string. Binary MUST be base64-encoded (see [`spec.md` §Content](/spec/kernel/spec)). The node MUST verify `sha256(utf8_bytes(content)) == content_hash` (next field) and reject with `CONTENT_HASH_MISMATCH` if not. Empty payloads use `""`. |
| content\_hash | hex64 | Yes | `sha256(utf8_bytes(content))`. Bound in the commit signature via the CBOR pre-image; the node enforces the match against `content`. |
| exp | uint | Yes | Expiration timestamp (Unix milliseconds) |
| tags | array | No | Array of \[key, value] pairs |
| alg | string | No | Signature scheme: `"schnorr"` (default if absent) or `"ecdsa"` (see [Signature Schemes](/spec/kernel/spec)) |
| sig | hex128 | Yes | Signature over hash, per `alg` |
**`content_hash`:** The client MUST send `content_hash` alongside `content` (it's a commit field — see [`spec.md` §Commit Structure](/spec/kernel/spec)).The node MUST recompute `expected = sha256(utf8_bytes(content))` and reject the commit with `CONTENT_HASH_MISMATCH` if `expected ≠ content_hash`.See [`spec.md` §Commit Hash Construction](/spec/kernel/spec) for the rationale and implementation status table.
**Response (200 OK):** Receipt
```json
{
"type": "Receipt",
"id": "",
"hash": "",
"timestamp": 1706000000000,
"sequencer": "",
"seq": 42,
"sig": "",
"seq_sig": ""
}
```
| Field | Type | Description |
| --------- | ------ | ----------------------------------------------------------------------------------------------------------------- |
| type | string | Always `"Receipt"` |
| id | hex64 | Event ID |
| hash | hex64 | Original commit hash |
| timestamp | uint | Sequencer timestamp (Unix milliseconds) — recorded when sequencer finalizes the event, not client submission time |
| sequencer | hex64 | Sequencer public key |
| seq | uint | Sequence number |
| sig | hex128 | Client's signature (from commit) |
| seq\_sig | hex128 | Sequencer's signature over event |
**Note:** Receipt omits `enclave` for privacy — client already knows which enclave it submitted to.
**Errors:**
| Code | HTTP | Description |
| -------------------- | ---- | -------------------------------- |
| `INVALID_COMMIT` | 400 | Malformed commit structure |
| `INVALID_HASH` | 400 | Hash doesn't match CBOR encoding |
| `INVALID_SIGNATURE` | 400 | Signature verification failed |
| `EXPIRED` | 400 | `exp` \< current time |
| `DUPLICATE` | 409 | Commit hash already processed |
| `UNAUTHORIZED` | 403 | Insufficient RBAC permissions |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
| `ENCLAVE_PAUSED` | 403 | Enclave is paused |
| `ENCLAVE_TERMINATED` | 410 | Enclave is terminated |
| `RATE_LIMITED` | 429 | Too many requests |
#### POST / (Query)
Query events from the enclave.
**Detection:** Request contains `type: "Query"` field.
**Request:**
```json
{
"type": "Query",
"enclave": "",
"from": "",
"content": ""
}
```
| Field | Type | Required | Description |
| ------- | ------ | -------- | ------------------------------------------------- |
| type | string | Yes | Must be `"Query"` |
| enclave | hex64 | Yes | Target enclave ID (plaintext for routing) |
| from | hex64 | Yes | Requester's identity public key |
| content | string | Yes | Encrypted payload (see [Encryption](#encryption)) |
**Content (plaintext):**
```json
{
"session": "",
"filter": { ... }
}
```
| Field | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| session | hex136 | Yes | Session token (see [Session](#session)) |
| session\_auth | object | No | ECDSA authorization `{ sig, recovery }` for a delegated session (see [Delegated Session](#delegated-session-ecdsa-authorized)) |
| filter | object | Yes | Query filter (see [Filter](#filter)) |
**Note:** `enclave` is plaintext for routing — node needs it before decryption.
**Response (200 OK):**
```json
{
"type": "Response",
"content": ""
}
```
**Response Content (plaintext):**
```json
{
"events": [
{ "event": Event, "status": "active" },
{ "event": Event, "status": "updated", "updated_by": "" },
...
]
}
```
| Field | Description |
| ----------- | --------------------------------------------------------------------------- |
| event | The event object |
| status | `"active"` — event is current; `"updated"` — superseded by Update event |
| updated\_by | (Present when `status: "updated"`) Event ID of the superseding Update event |
**Note:** Deleted events are NOT returned. To query deleted event IDs, use the SMT Event Status proof.
**Errors:**
| Code | HTTP | Description |
| ------------------- | ---- | --------------------------------- |
| `INVALID_QUERY` | 400 | Malformed query structure |
| `INVALID_SESSION` | 400 | Session token verification failed |
| `SESSION_EXPIRED` | 401 | Session token expired |
| `DECRYPT_FAILED` | 400 | Cannot decrypt content |
| `INVALID_FILTER` | 400 | Malformed filter |
| `UNAUTHORIZED` | 403 | No read permission |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
| `RATE_LIMITED` | 429 | Too many requests |
***
***
### Filter
Query filter for event retrieval.
#### Structure
```json
{
"id": " | [, ...]",
"seq": " | [, ...] | Range",
"type": " | [, ...]",
"from": " | [, ...]",
"tags": { "": " | [, ...] | true" },
"timestamp": "Range (Unix ms)",
"limit": 100,
"reverse": false
}
```
All fields are optional. Omitted field = no filter (match all).
**Tags Filter:**
The `tags` field filters events by tag presence or value:
* `{ "r": "abc123." }` — events with `r` tag matching value
* `{ "r": ["abc123.", "def456."] }` — events with `r` tag matching any value
* `{ "auto-delete": true }` — events with `auto-delete` tag (any value)
**Default Sort Order:**
Events are sorted by sequence number ascending unless `reverse: true`. This is the canonical enclave order.
#### Range
```jsonc
{
"start_at": 100, // >= 100
"start_after": 100, // > 100
"end_at": 200, // <= 200
"end_before": 200 // < 200
}
```
#### Semantics
| Pattern | Meaning |
| ---------------- | --------- |
| Top-level fields | AND |
| Array values | OR |
| Omitted field | Match all |
#### Limits
| Field | Max |
| --------------------- | ---- |
| `id[]` | 100 |
| `seq[]` | 100 |
| `type[]` | 20 |
| `from[]` | 100 |
| `tags` keys | 10 |
| `tags` values per key | 20 |
| `limit` | 1000 |
#### Examples
**By type:**
```json
{ "type": "message" }
```
**By authors:**
```json
{ "type": "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": "message", "reverse": true, "limit": 20 }
```
***
***
### WebSocket API
Real-time pub/sub for event subscriptions.
**Endpoint:** `wss:///`
**Subscription is automatic:** First valid Query on a connection creates a subscription. The node assigns a `sub_id` — or, if the Query carries one, adopts the caller-supplied value (any UTF-8 string) — and begins streaming events.
#### Connection Model
| Direction | Type | Description |
| --------- | --------- | ----------------------------------------------------------------------------------- |
| C → N | `Query` | First valid Query subscribes; node adopts a caller-supplied `sub_id` or assigns one |
| C ← N | `Event` | Stored events (encrypted) |
| C ← N | `EOSE` | End of stored events |
| C ← N | `Event` | Live updates (encrypted) |
| C → N | `Commit` | Write event |
| C ← N | `Receipt` | Write success |
| C ← N | `Error` | Write error |
| C → N | `Close` | Unsubscribe from subscription |
| C ← N | `Closed` | Subscription terminated |
| C ← N | `Notice` | Informational message |
#### Client → Node
| Message | Format | Description |
| ------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| Query | Same as POST / (Query), optional `sub_id` | First valid Query subscribes; node adopts a caller-supplied `sub_id` or assigns one |
| Commit | Same as POST / (Commit) | Write event, returns Receipt |
| Close | `{ "type": "Close", "sub_id": "" }` | Unsubscribe from subscription |
**Note:** `Close` unsubscribes from a single subscription. To close the entire WebSocket connection, close the WebSocket transport directly. Closing the transport terminates all active subscriptions.
#### Subscription Identifiers (`sub_id`)
Every subscription on a connection is keyed by a `sub_id`, which the node echoes on every frame it sends for that subscription (`Event`, `EOSE`, `Closed`). A `sub_id` is set one of two ways:
* **Node-assigned (default):** if a `Query` carries no `sub_id`, the node generates a unique one and tags the subscription's frames with it.
* **Caller-supplied:** if a `Query` includes a `sub_id` (a non-empty string), the node adopts it verbatim as the subscription key. The caller is responsible for keeping its supplied ids unique within the connection.
Caller-supplied ids let a client carry an arbitrary number of independent subscriptions over a single connection and demultiplex incoming frames by `sub_id` without tracking a node-assigned mapping. They are also the wire-level foundation of the **aggregator pattern** described below.
#### Subscribe Replay Semantics (no silent truncation)
`Query`-as-subscribe replays stored events to the client before transitioning to live. The replay window is fully determined by the caller's filter cursor, NOT by a node-side default page size:
* **With `seq.start_after = c`**: the node MUST replay every stored event with `seq > c` contiguously, then transition to live. The transition is signalled by `EOSE` and the live stream resumes from the first event committed after the last replayed `seq`. There MUST NOT be a gap.
* **Without a `seq` cursor**: the subscription is **live-only**. The node MUST NOT replay an oldest-N window of stored events. Catch-up history is obtained via a separate `POST / (Query)` request with explicit pagination, then the client opens a subscribe with `seq.start_after = ` to attach the live stream contiguously.
Equivalently: `limit` is a **Pull/Query pagination control**, not a subscribe-replay cap. A node MUST NOT apply a default `limit` to subscribe-replay that would produce a non-contiguous "oldest-N then live" stream.
The rule exists because silent truncation is the worst failure mode: a `{}` subscribe that returns the oldest 100 stored events and then transitions to live drops the entire middle range and is impossible for the client to detect from frame contents alone. A node that needs to bound subscribe cost for DoS reasons MUST do so by:
1. Rejecting the subscribe with an explicit `Closed` reason (e.g., `subscribe_replay_too_large` with a hint to Pull history in chunks); OR
2. Streaming events contiguously and asynchronously without buffering them all in memory; OR
3. Treating the absent-cursor case as live-only per the rule above.
A node MUST NOT silently keep the oldest-N events and discard everything since.
**Client cache reconciliation.** A client that caches events locally MUST NOT assume subscribe-replay re-delivers state-mutating events such as `Delete` or `Update`. On reconnect / rejoin, the client MUST reconcile its cached events against the authoritative `event_status` SMT entries (the `0x01` namespace, per smt.md §Event Status), and treat any cached event whose authoritative status is `deleted` or `updated` as no longer active — regardless of whether the corresponding `Delete` / `Update` event was observed in the subscribe stream. Caching clients on busy enclaves WILL, by the no-truncation rule above, sometimes miss the explicit `Delete` event on the live stream; reconciliation against `event_status` is what closes the resurrection hole.
#### Aggregator Pattern (Connection Collapse)
**The problem.** A client watching its DM enclave plus N group enclaves needs `N+1` WebSocket connections to the node — one per enclave (because each enclave is its own subscription target, and platforms like Cloudflare bind an accepted WS to one Durable Object instance per enclave). A 100-member group with every member online means every other member opens a socket into that enclave's DO; connection count scales as `O(clients × enclaves_per_client)`.
**The aggregator.** A WebSocket aggregator (a "hub") sits between clients and the node and collapses that fan-out:
```
Without aggregator With aggregator
────────────────── ───────────────
client A ─┬─► node:enclave_1 client A ─┐
├─► node:enclave_2 │
└─► node:enclave_3 ├─► hub ─┬─► node:enclave_1
│ ├─► node:enclave_2
client B ─┬─► node:enclave_1 client B ─┤ ├─► node:enclave_3
├─► node:enclave_2 │ └─► node:enclave_4
└─► node:enclave_4 │
client C ─┘
(6 client→node sockets)
(3 client→hub sockets
+ 4 hub→node upstream sockets)
```
* **Each client opens ONE WebSocket** to the hub, regardless of how many enclaves it watches. All of that client's subscriptions are multiplexed on it via caller-supplied `sub_id`s.
* **The hub opens ONE upstream WebSocket per enclave** (refcounted across all its clients of that enclave). When the last client of an enclave unsubscribes, the upstream is closed; when the next client subscribes, it reopens.
* Total connections drop from `O(clients × enclaves_per_client)` (without) to `O(clients) + O(distinct live enclaves)` (with).
**Why this is wire-compatible.** The hub is a pure `sub_id` router:
* It forwards `Query` frames **verbatim** — `{session, filter}` stays opaque, the node verifies each client's session and enforces per-client RBAC. The hub is **never** an authorization point; it cannot read encrypted event payloads and does not need to.
* It rewrites the `sub_id` on the way in (to a hub-global id unique on the upstream socket) and back to the client's id on the way out — so two clients can both use `sub_id = "s1"` without colliding.
* It honors `Close { sub_id }` per subscription, decrementing the upstream refcount and closing the upstream when refcount = 0.
The node sees only a normal multiplexed-`sub_id` client and applies its normal authorization rules per `Query`. No new opcodes; no auth model changes.
**Wire-level requirements the aggregator pattern imposes on the node.** These are normative — an aggregator built against a node missing any of them silently misroutes subscriptions:
1. **`Query` accepts a caller-supplied `sub_id`** (used verbatim) and echoes it on `Event` / `EOSE` / `Closed` / `Error` frames. (Defined above under *Caller-supplied*.)
2. **`Close { sub_id }`** removes only the named subscription, leaving other subscriptions on the same connection alive.
3. **Hibernation preserves ALL subscriptions per connection.** When a node hibernates an idle WebSocket (e.g., Cloudflare Durable Object `acceptWebSocket()`) and restores it on the next inbound message, ALL of the connection's active subscriptions MUST be restored — not just the last-touched one. Restoring only the last drops every other client routed through the aggregator; this is a wire-incompatible regression. Implementations using attachment-style persistence (e.g., `ws.serializeAttachment()`) MUST write the FULL per-connection subscription set, not per-subscription.
4. **`tags` round-trip losslessly through storage.** Plugins (`dm:sent` with `["to", ...]`, cross-enclave `notice` with `["enclave_id", ...]`) depend on tags surviving backfill from the event store, not just live broadcast. (Normative requirement is in [`spec.md` §Tags](/spec/kernel/spec#tags).)
Items 1, 2, and 4 are also true for direct client→node use; item 3 only matters under aggregation (a single-subscription connection trivially preserves its only sub). All four are checked by `tools/conformance/run-wire.mjs` against the Lean reference; sibling impls get equivalent CI coverage.
#### WebSocket Heartbeats
The wire protocol defines a minimal ping / pong heartbeat to keep connections alive across proxy / edge idle timeouts (notably Cloudflare's \~100s outbound WS idle close, which does not always surface as a clean close event):
| Direction | Frame | Body |
| --------- | ---------- | ------ |
| C → N | text frame | `ping` |
| N → C | text frame | `pong` |
| N → C | text frame | `ping` |
| C → N | text frame | `pong` |
**Wire format:** plain-text `ping` / `pong`. **Not** JSON. **Not** a typed message envelope. The 4-byte (`ping`) / 4-byte (`pong`) payloads are kept short so they remain distinguishable from any JSON message (every JSON message begins with `{`).
**Cadence:** SHOULD send a `ping` every 25 seconds of idle, with a 10-second `pong` deadline. On missed pong, the sender SHOULD close the connection and reconnect.
**Symmetry:** both directions independently initiate pings. A client pings the node to detect silent edge timeouts; the node (or an intermediary aggregator forwarding to an upstream node) pings its peer for the same reason. The response handler is the same on both sides: reply `pong` to any inbound `ping`.
**Aggregator forwarding:** an intermediary multiplexer forwards a client's `ping` to its upstream and the upstream's `pong` back, so a single missed pong on EITHER side surfaces to the originator and triggers reconnect.
#### Node → Client
**Event (stored or live):**
```json
{
"type": "Event",
"sub_id": "",
"event": ""
}
```
**End of Stored Events:**
```json
{
"type": "EOSE",
"sub_id": ""
}
```
**Write Success:** Receipt (same as HTTP)
```json
{
"type": "Receipt",
"id": "",
"hash": "",
"timestamp": 1706000000000,
"sequencer": "",
"seq": 42,
"sig": "",
"seq_sig": ""
}
```
**Write Error:**
```json
{
"type": "Error",
"code": "",
"message": ""
}
```
**Subscription Closed:**
```json
{
"type": "Closed",
"sub_id": "",
"reason": ""
}
```
**Closed Reasons:**
| Reason | Description | Client Action |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `access_revoked` | Live tail rejected at session open: requester has neither a `retention: "current"` column in their current bitmask nor any open-ended `retention: "snapshot"` interval. | Permanent; re-subscribe on rejoin or permission restoration. |
| `no_access` | Query accepted, but its seq / filter range does not overlap any reader interval the requester is allowed to read in this enclave. | Permanent for this query; a different seq range may succeed. |
| `live_access_ended` | Subscription served historical events successfully and was streaming live, but an RBAC change closed the requester's open-ended interval mid-stream (Move(MEMBER → OUTSIDER), Revoke(admin), …). | Permanent; re-subscribe on rejoin to resume live tail. |
| `session_expired` | Session token expired | Generate new session, re-subscribe |
| `enclave_terminated` | Enclave was terminated | Permanent; no recovery |
| `enclave_paused` | Enclave was paused | Wait for Resume event, then re-subscribe |
| `enclave_migrated` | Enclave migrated to new node | Query Registry for new node, re-subscribe there |
| `upstream_closed` | Emitted by a WebSocket aggregator (see [Aggregator Pattern](#aggregator-pattern-connection-collapse)) when its shared upstream socket to the enclave node died. Per-subscription; other subs on the same client connection remain alive. | Re-subscribe (the aggregator opens a fresh upstream + backfill catches up). |
`no_access` vs `live_access_ended` vs `access_revoked` are normatively distinct outcomes — see [Read Authorization](#read-authorization) for when each fires. Implementations targeting the snapshot read-authorization path SHOULD emit the most specific reason that applies.
**Notice:**
```json
{
"type": "Notice",
"message": ""
}
```
#### Node Processing
**On Query:**
1. Verify session (same as HTTP)
2. Authorize the query per [Read Authorization](#read-authorization) — compute the requester's served seq set; reject with `access_revoked` (no readable intervals at all) or `no_access` (intervals exist but don't overlap the query) as defined there
3. Adopt the Query's `sub_id` if present (non-empty string), else generate one; store subscription
4. Send matching events as `Event` messages (encrypted), restricted to the served seq set
5. Send `EOSE` message
6. On new events matching filter AND inside the requester's open-ended interval → push `Event` to client; on RBAC change that closes the open-ended interval, emit `Closed { reason: "live_access_ended" }`
**On Commit:**
Same as HTTP, returns Receipt.
**On Close:**
Remove subscription. Terminate connection if none remain.
#### Read Authorization
The node's authorization decision per `Query` (HTTP or WebSocket) is normative; deviation produces wire-incompatible behavior — a client sees inconsistent close reasons or, worse, served events the requester was not entitled to.
The authorization rule has three concerns:
1. Compute the requester's **per-reader-column access intervals** in the target enclave (a list of disjoint seq ranges).
2. Intersect with the query's `seq` / filter range to obtain the **served seq set** and reject if empty.
3. For subscriptions, split into a historical phase and a live tail; the live tail terminates on RBAC events that close the requester's open-ended interval.
##### Access Intervals
For a `(requester, enclave, reader)` triple, where `reader` is a `readers` entry from the enclave's manifest (rbac/manifest.md §Section Details / Reader Retention), the **access interval list** `I(requester, reader)` is a sorted list of disjoint half-open seq intervals `[[s0, e0), [s1, e1), …]`:
| `reader.retention` | `reader.type` | `I(requester, reader)` |
| --------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `"current"` (default) | State / trait | `[[0, ∞)]` if requester's CURRENT bitmask contains `reader.type`; else `[]`. |
| `"snapshot"` | State / trait | Replayed over CT as defined in **Computing Snapshot Intervals** below. |
| n/a | `Self` / `Sender` / `Public` | `[[0, ∞)]` — intervals span all seqs; the per-event Context predicate is checked at serve time, not in the interval set. |
The **served seq set** for the requester is then:
```
served_seq_set(requester) =
⋃ I(requester, r) for each r ∈ manifest.readers
```
with the per-event constraint that an event `e` is served only if `e.type ∈ r.reads` for at least one reader `r` whose interval contains `e.seq` AND, for Context readers, whose per-event predicate (`Sender`: `e.from == requester`; `Self`: `e.from == requester` for the event-as-actor case; `Public`: always true) holds.
The query's served events are then `{ e ∈ enclave.events : e.seq ∈ served_seq_set(requester) AND e matches query.filter AND the per-event Context constraint holds }`.
##### Computing Snapshot Intervals
For `retention: "snapshot"` with column `c` (a State or trait), the node replays the requester's RBAC history in the enclave's CT:
```
1. Initialize bitmask = init_bitmask(requester) // per manifest.init, or 0 if absent
open = (c ∈ bitmask)
if open: emit interval-start at seq = 0
2. For each event e in CT, in seq order, where e affects requester:
(i.e. Move with target == requester,
Grant / Revoke with target == requester,
Transfer with operator == requester OR target == requester)
apply e per rbac/events.md → new_bitmask
new_open = (c ∈ new_bitmask)
if new_open AND NOT open:
emit interval-start at seq = (e.seq + 1)
if open AND NOT new_open:
emit interval-end at seq = (e.seq + 1)
bitmask, open = new_bitmask, new_open
3. If open at end-of-CT: the last interval is open-ended ([…, ∞)).
```
Resulting intervals are half-open `[start, end)`, sorted, disjoint, with the last entry's end either a concrete seq (column was lost) or `∞` (column is currently held). For Sub-keyed Transfer (rbac-v2 §8.4) where the same event mutates both operator and target, the same seq advance applies to both the operator's and target's interval lists.
The State enum value vs trait flag distinction does NOT matter for interval computation: both are bits in the same 256-bit RBAC bitmask (rbac-v2 §1.1); the interval ends the moment the bit is no longer set.
**Caching.** Implementations SHOULD cache `I(requester, reader)` per `(enclave_id, requester_id_pub, reader_index)`.The cache MUST be invalidated when any of the following events finalize: Move with `target == requester`, Grant / Revoke with `target == requester`, Transfer with `operator == requester` OR `target == requester`.The SMT update path already detects these (the bitmask actually changed); the cache invalidation hook can ride on the same trigger.
##### Query Phases
A query has two distinct phases, evaluated separately:
| Phase | Seq range | Authorized iff |
| -------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Historical** | `[query_start, current_seq)` at query open | `query_seq_range ∩ served_seq_set(requester) ≠ ∅` |
| **Live tail** | `[current_seq, ∞)` (only if the query has no `seq.end_before` cap or it exceeds `current_seq`) | At least one reader `r` exists where `I(requester, r)` contains an open-ended `[…, ∞)` entry |
The two phases produce distinct close-reason outcomes:
* Historical phase empty → `Closed { reason: "no_access" }`. Permanent for this query.
* Historical phase non-empty but live phase rejected → serve the historical events, then emit `Closed { reason: "live_access_ended" }` immediately after the `EOSE`.
* Both phases empty (rare: every reader is `current` and requester has nothing) → `Closed { reason: "access_revoked" }` at query open, before any `EOSE`.
* Live phase was streaming and an RBAC change closes the requester's last open-ended interval → emit `Closed { reason: "live_access_ended" }` (the same code, but mid-stream rather than at open).
`access_revoked` is the "the requester has no read access in this enclave at all" case; `no_access` is "the requester has some access but not in the slice asked for"; `live_access_ended` is "the requester had access and lost it (at open or mid-stream)." Implementations MUST emit the most specific reason.
##### Worked Examples
Assume the group manifest:
```json
"readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ]
```
and requester Alice's RBAC history in the enclave:
* seq=10: `Move(OUTSIDER → MEMBER, target=Alice)` by admin
* seq=400: `Move(MEMBER → OUTSIDER, target=Alice)` by admin (kick)
* seq=520: `Move(OUTSIDER → MEMBER, target=Alice)` by admin (re-invite)
Then `I(Alice, MEMBER) = [[11, 401), [521, ∞)]`. Cases:
* Alice opens a fresh subscription with `seq.start_after: 0`: historical `[1, current_seq)` intersects both intervals; serve all events in `[11, 401) ∪ [521, current_seq)`. Live tail authorized (open-ended interval exists). Subscription stays open.
* Alice (currently MEMBER) is kicked at seq=900 while subscribed: the second interval closes at `[521, 901)`. The node emits `Closed { reason: "live_access_ended" }`. She keeps the historical events delivered before.
* Alice (kicked, currently OUTSIDER) re-subscribes asking for `seq.start_after: 500, seq.end_before: 600`: range is `[501, 600)`. Intersected with `I = [[11, 401), [521, …)]` gives `[521, 600)`. Historical serves; the seq cap means there is no live phase, no `live_access_ended` ever fires.
* Alice (kicked) asks for `seq.start_after: 600, seq.end_before: 800`: range is `[601, 800)`. Empty intersection with `I` (her second interval starts at 521 and ended at 401 — wait no, scratch — assume here she's mid-gap with `I = [[11, 401)]` because she has not yet been re-invited). Empty intersection → `Closed { reason: "no_access" }`.
* Alice (was never in this group, current bitmask 0) opens any subscription: `I(Alice, MEMBER) = []` under snapshot, and no other reader applies. Both phases empty → `Closed { reason: "access_revoked" }`.
For `retention: "current"`, the same Alice mid-kick gets `I = []` immediately (current bitmask lacks MEMBER) — `access_revoked` on open, even for queries against seqs she could read under snapshot semantics. This is the design point of the field.
##### Performance Bound (Non-Normative)
Per-query cost is `O(R + I + Q)`:
* `R` = manifest readers (typically \< 10)
* `I` = events targeting `requester` in the CT — Move / Grant / Revoke / Transfer (typically \< 10 per identity per enclave)
* `Q` = events served (bounded by `query.limit`)
The cached interval list per `(requester, enclave, reader)` is on the order of tens of bytes; cache hits are O(R + Q). The "snapshot" path is strictly no more expensive than a per-event SMT lookup in the legacy `current`-only path — both already require checking authorization against state, and snapshot collapses that to one interval test per query instead of per event.
##### Self-Authored Content (Sender Context)
A `readers` entry with `type: "Sender"` is satisfied per-event by `event.from == requester`. The node serves these regardless of the requester's State / trait history. A kicked former MEMBER who authored messages during their membership window MUST be able to query and recover those messages via the `Sender` reader, even when the corresponding MEMBER reader is `retention: "current"` and currently denies.This preserves the requester's ability to export their own content irrespective of RBAC outcome.
A manifest that wants to disable this property MUST omit the `Sender` reader entry — there is no separate switch.
#### Connection Lifecycle
**Termination conditions:**
* All subscriptions closed by client
* All sessions expired
* All access revoked
* Client disconnects
**Multi-key support:**
* One connection, multiple identities (`from`)
* Each identity has own session
* Session expiry only affects that identity's subscriptions
#### HTTP vs WebSocket
| Aspect | HTTP | WebSocket |
| ------- | ----------------- | --------------------------------- |
| Query | One-time response | Subscribe + live updates |
| sub\_id | N/A | Node-assigned, or caller-supplied |
| Commit | Receipt | Receipt |
| Session | Per-request | Cached per connection |
| State | Stateless | Subscriptions |
***
***
### Proof Retrieval API
Endpoints for retrieving cryptographic proofs. Used by clients to verify events and state.
**Access Control:**
| Endpoint | Access |
| ----------- | --------------------- |
| STH | Public |
| Consistency | Public |
| Inclusion | Requires R permission |
| State | Requires R permission |
#### STH (Signed Tree Head)
**GET** `/:enclave/sth`
Returns the current signed tree head. Public endpoint for auditing.
**Response (200 OK):**
```json
{
"t": 1706000000000,
"ts": 1000,
"r": "",
"sig": ""
}
```
See [proof.md](/spec/kernel/ct) for STH structure and verification.
#### Consistency Proof
**GET** `/:enclave/consistency?from=&to=`
Returns consistency proof between two tree sizes. Public endpoint for auditing.
| Parameter | Type | Description |
| --------- | ---- | ---------------------------------- |
| from | uint | Earlier tree size |
| to | uint | Later tree size (omit for current) |
**Response (200 OK):**
```json
{
"ts1": 500,
"ts2": 1000,
"p": ["", ...]
}
```
See [proof.md](/spec/kernel/ct) for verification algorithm.
**Errors:**
| Code | HTTP | Description |
| ------------------- | ---- | --------------------------- |
| `INVALID_RANGE` | 400 | from > to or invalid values |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
#### Inclusion Proof
**POST** `/inclusion`
Returns inclusion proof for a bundle. Requires R permission.
**Request:**
```json
{
"type": "Inclusion_Proof",
"enclave": "",
"from": "",
"content": ""
}
```
**Content (plaintext):**
```json
{
"session": "",
"leaf_index": 42
}
```
**Response (200 OK):**
```json
{
"type": "Response",
"content": ""
}
```
**Response Content (plaintext):**
```json
{
"ts": 1000,
"li": 42,
"p": ["", ...],
"events_root": "",
"state_hash": ""
}
```
| Field | Description |
| ------------ | ---------------------------------- |
| ts | Tree size when proof was generated |
| li | Leaf index |
| p | Inclusion proof path |
| events\_root | Merkle root of event IDs in bundle |
| state\_hash | SMT root after bundle |
See [proof.md](/spec/kernel/ct) for verification algorithm.
**Errors:**
| Code | HTTP | Description |
| ------------------- | ---- | --------------------------------- |
| `INVALID_SESSION` | 400 | Session token verification failed |
| `SESSION_EXPIRED` | 401 | Session token expired |
| `UNAUTHORIZED` | 403 | No read permission |
| `LEAF_NOT_FOUND` | 404 | Leaf index out of range |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
#### Bundle Membership Proof
**POST** `/bundle`
Returns bundle membership proof for an event. Requires R permission.
**Request:**
```json
{
"type": "Bundle_Proof",
"enclave": "",
"from": "",
"content": ""
}
```
**Content (plaintext):**
```json
{
"session": "",
"event_id": ""
}
```
**Response Content (plaintext):**
```json
{
"leaf_index": 42,
"ei": 2,
"s": ["", ...],
"events_root": ""
}
```
| Field | Description |
| ------------ | ------------------------------------ |
| leaf\_index | Bundle's position in CT tree |
| ei | Event index within bundle |
| s | Siblings for bundle membership proof |
| events\_root | Merkle root of event IDs in bundle |
See [proof.md](/spec/kernel/ct) for verification algorithm.
**Errors:**
| Code | HTTP | Description |
| ------------------- | ---- | --------------------------------- |
| `INVALID_SESSION` | 400 | Session token verification failed |
| `SESSION_EXPIRED` | 401 | Session token expired |
| `UNAUTHORIZED` | 403 | No read permission |
| `EVENT_NOT_FOUND` | 404 | Event doesn't exist |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
#### State Proof
**POST** `/state`
Returns SMT proof for a key. Requires R permission.
**Request:**
```json
{
"type": "State_Proof",
"enclave": "",
"from": "",
"content": ""
}
```
**Content (plaintext):**
```json
{
"session": "",
"namespace": "rbac" | "event_status",
"key": "",
"tree_size": 1000
}
```
| Field | Required | Description |
| ---------- | -------- | ------------------------------------------------------ |
| session | Yes | Session token |
| namespace | Yes | `"rbac"` or `"event_status"` |
| key | Yes | Identity public key (rbac) or event ID (event\_status) |
| tree\_size | No | Bundle index for historical state (omit for current) |
**Response Content (plaintext):**
```json
{
"k": "",
"v": "",
"b": "",
"s": ["", ...],
"state_hash": "",
"leaf_index": 999
}
```
| Field | Description |
| ----------- | -------------------------------------------- |
| k, v, b, s | SMT proof fields (see proof.md) |
| state\_hash | SMT root hash for verification |
| leaf\_index | Bundle index (0-based) containing this state |
**Verification Flow:**
To fully verify a state proof is authentic and from the requested tree position:
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](/spec/kernel/ct) for detailed algorithms.
**Errors:**
| Code | HTTP | Description |
| --------------------- | ---- | --------------------------------- |
| `INVALID_SESSION` | 400 | Session token verification failed |
| `SESSION_EXPIRED` | 401 | Session token expired |
| `INVALID_NAMESPACE` | 400 | Unknown namespace |
| `UNAUTHORIZED` | 403 | No read permission |
| `TREE_SIZE_NOT_FOUND` | 404 | Historical state not available |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
#### Batched State Proof
**POST** `/state-batch`
Returns SMT proofs for many keys against a single `state_hash`, in one round trip. Required by the cache-reconciliation flow (§Subscribe Replay Semantics): on rehydrate-from-archive a client checks `eventstatus:` for every cached message, which is O(N) leaves; the batch endpoint collapses those N lookups into one request bound to one signed root.
Requires R permission. Cache-reconciliation is the load-bearing caller; `rbac` keys can be batched too (e.g. computing a per-identity capability matrix on first connect).
**Request:**
```json
{
"type": "State_Proof_Batch",
"enclave": "",
"from": "",
"content": ""
}
```
**Content (plaintext):**
```json
{
"session": "",
"namespace": "rbac" | "event_status",
"keys": ["", "", ...],
"tree_size": 1000
}
```
| Field | Required | Description |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------- |
| session | Yes | Session token |
| namespace | Yes | All keys MUST share one namespace; cross-namespace batches MUST be rejected with `INVALID_NAMESPACE` |
| keys | Yes | Array of `` SMT keys to look up; max 1000 per request |
| tree\_size | No | Bundle index for historical state (omit for current) |
**Limits:**
| Field | Max |
| ------ | ---- |
| `keys` | 1000 |
A node MUST reject a batch larger than the limit with `BATCH_TOO_LARGE`. The 1000-key cap matches the `limit` cap on Query / Pull pagination (§Filter Limits) so a single batched-proof round trip covers a single page of cached events without further chunking.
**Response Content (plaintext):**
```json
{
"state_hash": "",
"leaf_index": 999,
"proofs": [
{ "k": "", "v": "", "b": "", "s": ["", ...] },
{ "k": "", "v": "", "b": "", "s": ["", ...] }
]
}
```
| Field | Description |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
| state\_hash | SMT root hash; ONE root for the whole batch, MUST be the same root every per-key proof verifies against |
| leaf\_index | Bundle index (0-based) containing this state |
| proofs | Array, same length and order as request `keys`; each entry has the same `k`/`v`/`b`/`s` shape as the single-key `/state` response |
The batch response MUST satisfy two invariants:
1. **Same root for every proof.** Every entry in `proofs` is an SMT proof against the SAME `state_hash`. The client verifies all entries against the single returned root.
2. **Order preservation.** The response's `proofs` array MUST be the same length as the request's `keys` array, and `proofs[i]` MUST be the proof for `keys[i]`. Clients MAY rely on positional alignment.
**Verification Flow:**
Same as `/state`, applied once to the shared `state_hash`:
1. For each `proofs[i]`, verify SMT proof against `state_hash` (see proof.md).
2. Request CT inclusion proof for `leaf_index` via `POST /inclusion` (ONCE; the same `leaf_index` covers every per-key proof in the batch).
3. Verify CT inclusion as in the single-key flow.
4. Verify STH signature.
The savings: one CT-inclusion + one STH-signature verification per batch instead of per key.
**Cache-reconciliation usage:**
```json
{
"type": "State_Proof_Batch",
"content": {
"session": "...",
"namespace": "event_status",
"keys": ["", "", ""]
}
}
```
For each `proofs[i]`, the client checks the returned `v`: `null` (absent) or `0x00…` (active) means the event is live; `0x01` (deleted) means the event is dropped from the cache. The membership proof is verified against `state_hash` once.
**Errors:**
| Code | HTTP | Description |
| --------------------- | ---- | ------------------------------------------------------- |
| `INVALID_SESSION` | 400 | Session token verification failed |
| `SESSION_EXPIRED` | 401 | Session token expired |
| `INVALID_NAMESPACE` | 400 | Unknown namespace, or batch carried multiple namespaces |
| `BATCH_TOO_LARGE` | 400 | `keys.length` exceeds the 1000 limit |
| `UNAUTHORIZED` | 403 | No read permission |
| `TREE_SIZE_NOT_FOUND` | 404 | Historical state not available |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist |
***
***
### Webhook Delivery
Node delivers Push messages via HTTPS POST to registered endpoints. See [Appendix: Push/Notify](#push-notify) for design rationale.
#### Grant with Push Endpoint
A Grant event with P (push) ops and an `endpoint` field registers a webhook endpoint. See spec.md for event structure and content fields.
Node maintains queue per `(identity, url)`:
* Aggregates events from all enclaves **on this node** where identity has P/N permission
* Tracks single `push_seq` per queue
* All enclaves in a Push delivery share the node's `seq_priv` for encryption
**Multiple Endpoints:**
An identity MAY register multiple webhook endpoints via separate Grant events with different `endpoint` values.Each `(identity, url)` pair has its own `push_seq` and event queue. Events are delivered independently to each endpoint. To replace an endpoint, submit a new Grant with the new URL; both endpoints remain active until explicitly revoked.
**Ordering Guarantee:**
Events within each enclave (`push.enclaves[N].events`) are ordered by sequence number ascending. Events from different enclaves have no guaranteed relative ordering. If global ordering is required, use per-enclave `seq` to reconstruct the timeline.
**Endpoint Transition Atomicity:**
When a Grant changes the webhook URL (new Grant with the same `trait`, different URL):
1. Old and new endpoints operate as separate queues (no events lost)
2. Old endpoint receives events finalized before the Grant
3. New endpoint receives events finalized after the Grant
4. To stop delivery to old endpoint, explicitly Revoke the trait
#### Delivery Flow
```
1. Enclave submits Grant event with P ops + endpoint (grants trait + registers url)
2. Node adds enclave to (identity, url) queue if not exists
3. On new event, node checks the trait's P/N permissions
4. Node aggregates events into (identity, url) queue
5. Node periodically POSTs Push to url
6. Node increments push_seq
```
#### Push
Webhook delivery containing full events (P permission) and/or event IDs (N permission).
**HTTP Request:**
```
POST
Content-Type: application/json
```
**Body:**
```json
{
"type": "Push",
"from": "",
"to": "",
"url": "",
"content": ""
}
```
| Field | Type | Description |
| ------- | ------ | -------------------- |
| type | string | Always `"Push"` |
| from | hex64 | Sequencer public key |
| to | hex64 | Recipient identity |
| url | string | Webhook URL |
| content | string | Encrypted payload |
**Content (plaintext):**
```json
{
"push_seq": 130,
"push": {
"enclaves": [
{ "enclave": "", "events": [Event, ...] }
]
},
"notify": {
"enclaves": [
{ "enclave": "", "seq": 150 }
]
}
}
```
| Field | Type | Description |
| -------------------------- | ------ | ------------------------------------------ |
| push\_seq | uint | Sequence number per (identity, url) |
| push | object | Full events for enclaves with P permission |
| push.enclaves\[].enclave | hex64 | Enclave ID |
| push.enclaves\[].events | array | Array of Event objects |
| notify | object | Latest seq for enclaves with N permission |
| notify.enclaves\[].enclave | hex64 | Enclave ID |
| notify.enclaves\[].seq | uint | Latest sequence number in this enclave |
Either `push` or `notify` can be omitted if empty.
**Encryption:** See [Encryption](#encryption).
**Delivery semantics:**
* At-least-once delivery
* Exponential backoff on failure
* Recipient MUST dedupe by `event.id` (for push) or track last synced seq (for notify)
**Expected response:** `200 OK`
#### Pull Fallback
If webhook delivery fails, recipient can pull missed batches.
**Request:**
```json
{
"type": "Pull",
"enclave": "",
"from": "",
"content": ""
}
```
**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": "",
"url": "",
"push_seq": { "start_after": 5, "end_at": 7 },
"enclave": ""
}
```
| Field | Type | Required | Description |
| --------- | ------------------------- | -------- | -------------------------------- |
| session | hex136 | Yes | Session token |
| url | string | Yes | Registered webhook endpoint |
| push\_seq | uint, \[uint,.], or Range | Yes | Batch sequence(s) to retrieve |
| enclave | hex64 | No | Filter results to single enclave |
**push\_seq formats:**
* Single: `6` — returns batch 6
* Array: `[6, 7, 8]` — returns batches 6, 7, 8
* Range: `{ "start_after": 5, "end_at": 7 }` — returns batches 6, 7
**Response:**
```json
{
"type": "Response",
"content": ""
}
```
**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:
* `start_after` / `end_at` are inclusive/exclusive as documented
* Results are ordered by `push_seq` ascending
* If `enclave` filter is provided, only events from that enclave are included in each batch
* If `enclave` is omitted, all enclaves in the original batch are included
**Retry Policy:**
Node retries webhook delivery with exponential backoff per spec.md. After max retries exhausted, batch is moved to dead-letter queue. Recipient can recover via Pull fallback.
**Encryption:** Same as Query. See [Encryption](#encryption).
***
***
### Registry DataView API
Registry-specific endpoints (`/nodes/:seq_pub`, `/enclaves/:enclave_id`, `/identity/:id_pub`) are specified in [`enclaves/registry.md` §DataView API](/spec/app/enclaves/registry#dataview-api). They are NOT part of the generic node API; only nodes hosting the Registry enclave expose them.
***
### Snapshot Endpoints
Two endpoints move complete enclave state between nodes. They are the wire protocol for the migration flow specified in [`migration.md`](/spec/node/migration), and they're equally useful for backup, archival, and audit handoff.
The byte format (`.enc`) is normative; see [`migration.md` §Enclave Snapshot Format](/spec/node/migration#enclave-snapshot-format) for the layout, kernel-version compatibility rules, and verification procedure. This section specifies the HTTP surface.
#### GET /enclaves/:id/snapshot
Download the complete enclave state.
**Path parameters:**
| Param | Type | Description |
| ----- | ----- | ----------- |
| `id` | hex64 | Enclave id |
**Request headers:**
| Header | Required | Description |
| --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `Authorization` | Implementation-defined | Most production deployments require an auth token; a public snapshot endpoint is the exception, not the default. |
**Response (200 OK):**
| Header | Value |
| --------------------- | -------------------------------------------------- |
| `Content-Type` | `application/octet-stream` |
| `Content-Length` | `32 + payload_size + 32` |
| `Content-Disposition` | OPTIONAL `attachment; filename=".enc"` |
Body: the raw `.enc` bytes (header || payload || footer; see migration spec).
**Errors:**
| Code | HTTP | Description |
| ---------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `ENCLAVE_NOT_FOUND` | 404 | This node does not host the requested enclave. |
| `ENCLAVE_PAUSED` | 409 | Enclave is paused; some implementations refuse snapshot of a paused enclave to avoid capturing transient state. Caller MAY retry after Resume. |
| `SNAPSHOT_UNSUPPORTED` | 501 | This node does not support snapshot export (e.g., proxy-only nodes). |
#### POST /enclaves/:id/restore
Bootstrap a fresh enclave on this node from a `.enc` payload. The receiving node MUST be able to host the enclave id and MUST NOT already have state for it.
\::: extension-point id=enclave-restore-overwrite-mechanism class=local\_policy
reason: an overwrite path for non-fresh restore is out of scope for the core protocol but operators can add one (with their own consent and authorization rules)
An explicit overwrite mechanism for restoring on top of existing enclave state is out of scope for the core protocol. Operators can provide one with deployment-specific authorization; the wire shape of the restore call is unchanged.
\:::
**Path parameters:**
| Param | Type | Description |
| ----- | ----- | ------------------------------------------- |
| `id` | hex64 | Enclave id the snapshot will be restored as |
**Request headers:**
| Header | Required | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------- |
| `Content-Type` | Yes | `application/octet-stream` |
| `Content-Length` | Yes | Total bytes; MUST equal `32 + payload_size + 32` |
| `Authorization` | Implementation-defined | Restore is a privileged operation; production deployments typically require Owner credentials. |
Body: the raw `.enc` bytes.
**Response (200 OK):** the enclave is live.
```json
{
"type": "Restored",
"id": "",
"kernel_ver": "",
"events": 1234,
"last_seq": 1233,
"ct_root": ""
}
```
**Errors:**
| Code | HTTP | Description |
| -------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BAD_SNAPSHOT_MAGIC` | 400 | Header magic ≠ `b"ENC\x01"`. |
| `UNKNOWN_LAYOUT_VERSION` | 400 | `layout_ver` is not 1. |
| `SNAPSHOT_FOOTER_MISMATCH` | 400 | Computed sha256 footer does not match stored. |
| `KERNEL_VERSION_MISMATCH` | 400 | `kernel_ver` falls outside the compatibility window (always: differing major; pre-1.0: any difference). The error body MUST include both producer and restorer versions. |
| `SNAPSHOT_TOO_LARGE` | 413 | `payload_size` exceeds the node's configured limit. |
| `SELF_TEST_FAILED` | 422 | Post-restore self-test rejected. |
| `ENCLAVE_ALREADY_EXISTS` | 409 | This node already hosts an enclave with this id; restoration would overwrite. |
| `RESTORE_UNSUPPORTED` | 501 | This node does not support restore. |
**`KERNEL_VERSION_MISMATCH` body shape:**
```json
{
"type": "Error",
"code": "KERNEL_VERSION_MISMATCH",
"producer": "0.12.3",
"restorer": "0.13.0",
"message": "snapshot was written by kernel 0.12.3; this node runs 0.13.0; pre-1.0 kernels do not accept any version drift"
}
```
#### Migration Flow Using These Endpoints
For Peaceful Handoff:
```
1. Owner submits Migrate commit to old node.
2. Old node finalizes Migrate; closes current bundle.
3. Old node responds to GET /enclaves//snapshot with .enc bytes.
4. New node receives bytes; calls POST /enclaves//restore on itself
(or the orchestrator POSTs the bytes to the new node directly).
5. New node verifies header + footer + kernel_ver + self-test, then
verifies that the Migrate event in the restored log matches the
commit it has (sequencer transition is real).
6. New node becomes the sequencer; sequencing resumes at prev_seq + 2.
```
For Forced Takeover, the Backup holds the `.enc` bytes (produced by an
earlier `GET /snapshot` while the old node was alive); the new node
verifies + restores + finalizes Migrate as the last event.
***
### Push/Notify
#### Problem
A DataView server can have P/N permissions across hundreds of enclaves on the same node. Naive approach — one HTTP request per event per enclave — creates massive overhead.
#### Solution
Node aggregates events into a single queue per `(identity, url)` pair.
```
Enclave A ──┐
Enclave B ──┼──► Queue (identity, url) ──► Single POST to url
Enclave C ──┘
```
#### Why This Is Efficient
| Aspect | Benefit |
| -------------------- | ----------------------------------------------------- |
| Batching | One POST delivers events from many enclaves |
| Single sequence | One `push_seq` for gap detection across all enclaves |
| Periodic aggregation | Node batches events instead of instant push per event |
| Unified message | Push and Notify combined in single delivery |
#### Pull Fallback Efficiency
When webhook delivery fails, recipient uses Pull to recover. The single sequence number makes this extremely efficient:
**Without unified sequence (naive):**
```
Recipient must track:
- Enclave A: last_seq = 42
- Enclave B: last_seq = 17
- Enclave C: last_seq = 103
...hundreds of enclaves...
Recovery requires:
- One Query per enclave
- Complex state management
- N round trips for N enclaves
```
**With unified push\_seq:**
```
Recipient tracks:
- push_seq = 130
Recovery requires:
- One Pull request with Range
- Returns all missed batches
- Single round trip
```
**Gap detection is trivial:**
* Received push\_seq 5, then 8 → the gap implies missed 6, 7
* Pull with `push_seq: { "start_after": 5, "end_at": 7 }` returns both batches in one request
This is why the queue is per `(identity, url)` not per enclave — it enables O(1) state tracking regardless of subscription count.
#### Push vs Notify (within same message)
A single Push delivery contains both:
* `push.enclaves[]` — full events for enclaves with P permission
* `notify.enclaves[]` — latest seq for enclaves with N permission
| | push | notify |
| -------- | -------------- | ------------------ |
| Content | Full events | Latest seq only |
| Use case | Real-time sync | Lightweight alerts |
#### Permissions
| Permission | What it grants |
| ---------- | ----------------------------------------------- |
| P (Push) | Receive full event content in `push.enclaves[]` |
| N (Notify) | Receive latest seq in `notify.enclaves[]` |
| R (Read) | Query full event content from enclave |
**Important:** P delivers full content directly. N only delivers the latest seq; fetching full events requires R permission on that enclave.
**Example:**
```
Identity has:
- Enclave A: P permission → full events in push.enclaves[]
- Enclave B: N permission → seq in notify.enclaves[]
- Enclave C: N + R permissions → seq in notify.enclaves[], can Query full events
```
If the requester only has N (no R), new events are observable but their content is not readable. This is useful for:
* Mobile push notifications (just alert user)
* Protocol-level sync signals (trigger sync via other means)
* Audit logging (record that activity occurred)
#### Typical Pattern
1. Receive Push with notify.enclaves\[].seq
2. Query events with `{ "seq": { "start_after": last_synced_seq } }` (requires R permission)
3. Full events arrive directly in push.enclaves\[] if the requester has P permission
#### Node Internal Table
```
Key: (identity, url)
Value: { push_seq, enclaves[] }
```
For each enclave, node tracks which events to include based on the identity's State + trait bitmask (and its P / N permissions on each event type).
***
***
### Encryption
Client-node communication is encrypted using ECDH + XChaCha20-Poly1305.
#### Overview
| Context | Client Key | Node Key | HKDF Label |
| --------------- | -------------- | --------- | ---------------- |
| Query request | signer\_priv | seq\_pub | `"enc:query"` |
| Query response | signer\_priv | seq\_pub | `"enc:response"` |
| Pull request | signer\_priv | seq\_pub | `"enc:query"` |
| Pull response | signer\_priv | seq\_pub | `"enc:response"` |
| WebSocket event | signer\_priv | seq\_pub | `"enc:response"` |
| Push delivery | to (recipient) | seq\_priv | `"enc:push"` |
#### Query Encryption (Client → Node)
Client encrypts query content:
```
1. Derive signer from session:
t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
2. Compute shared secret:
shared = ECDH(signer_priv, seq_pub)
3. Derive key and encrypt:
key = HKDF(shared, "enc:query")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)
```
Node decrypts:
```
1. Derive signer_pub from session_pub:
t = sha256(session_pub || seq_pub || enclave)
signer_pub = session_pub + t * G
2. Compute shared secret:
shared = ECDH(seq_priv, signer_pub)
3. Decrypt:
key = HKDF(shared, "enc:query")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)
```
#### Response Encryption (Node → Client)
Node encrypts response (HTTP and WebSocket):
```
shared = ECDH(seq_priv, signer_pub)
key = HKDF(shared, "enc:response")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)
```
Client decrypts:
```
shared = ECDH(signer_priv, seq_pub)
key = HKDF(shared, "enc:response")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)
```
#### Push Encryption (Node → Webhook)
Node encrypts webhook payload:
```
shared = ECDH(seq_priv, to)
key = HKDF(shared, "enc:push")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)
```
Recipient decrypts:
```
shared = ECDH(recipient_priv, seq_pub)
key = HKDF(shared, "enc:push")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)
```
#### Primitives
| Primitive | Specification |
| --------- | ------------------ |
| ECDH | secp256k1 |
| HKDF | HKDF-SHA-256 |
| AEAD | XChaCha20-Poly1305 |
**HKDF Parameters:**
```
IKM = ECDH shared secret (32 bytes)
salt = empty (no salt)
info = UTF-8 encoded label string, NO null terminator
e.g., "enc:query" = 9 bytes: 0x65 0x6E 0x63 0x3A 0x71 0x75 0x65 0x72 0x79
L = 32 bytes (256-bit key)
```
**XChaCha20-Poly1305 Nonce:**
```
nonce = random 24 bytes, prepended to ciphertext
ciphertext_wire = nonce || ciphertext || tag
```
Recipient extracts first 24 bytes as nonce before decryption.
**Minimum Length:** `ciphertext_wire` MUST be at least 40 bytes (24-byte nonce + 16-byte Poly1305 tag).Shorter values indicate malformed or truncated ciphertext — implementations MUST reject with `DECRYPT_FAILED`.
***
***
### Error Codes
#### HTTP Status Mapping
| HTTP | Category |
| ---- | -------------------------------- |
| 400 | Client error (malformed request) |
| 401 | Authentication error |
| 403 | Authorization error |
| 404 | Not found |
| 409 | Conflict |
| 410 | Gone |
| 429 | Rate limited |
| 500 | Internal server error |
| 502 | Upstream unreachable |
| 503 | Service temporarily unavailable |
#### Error Response Format
```json
{
"type": "Error",
"code": "",
"message": ""
}
```
#### Error Codes
| Code | HTTP | Description |
| -------------------- | ---- | ------------------------------------ |
| `INVALID_COMMIT` | 400 | Malformed commit structure |
| `INVALID_HASH` | 400 | Hash doesn't match CBOR encoding |
| `INVALID_SIGNATURE` | 400 | Signature verification failed |
| `INVALID_QUERY` | 400 | Malformed query structure |
| `INVALID_SESSION` | 400 | Session token verification failed |
| `INVALID_FILTER` | 400 | Malformed filter |
| `DECRYPT_FAILED` | 400 | Cannot decrypt content |
| `SESSION_EXPIRED` | 401 | Session token expired |
| `EXPIRED` | 400 | Commit `exp` \< current time |
| `UNAUTHORIZED` | 403 | Insufficient RBAC permissions |
| `ENCLAVE_PAUSED` | 403 | Enclave is paused |
| `DUPLICATE` | 409 | Commit hash already processed |
| `NODE_NOT_FOUND` | 404 | Node not registered |
| `ENCLAVE_NOT_FOUND` | 404 | Enclave not registered |
| `IDENTITY_NOT_FOUND` | 404 | Identity not registered |
| `ENCLAVE_TERMINATED` | 410 | Enclave is terminated |
| `ENCLAVE_MIGRATED` | 410 | Enclave has migrated to another node |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Internal server error |
**WebSocket Errors:**
WebSocket errors use the same JSON format as HTTP errors. HTTP status codes do not apply to WebSocket; use the application-level `code` field instead.
***
***
### Rejection Error Envelope
When a node rejects a commit or request, it MUST return an error response:
```json
{
"type": "Error",
"code": "",
"message": "",
...additional fields specific to the error...
}
```
| Field | Required | Description |
| ------------ | -------- | ------------------------------------------------ |
| type | Yes | Always `"Error"` |
| code | Yes | Machine-readable error code (`UPPER_SNAKE_CASE`) |
| message | Yes | Human-readable description |
| *additional* | No | Error-specific context (see below) |
The canonical code strings and HTTP statuses are defined in §Error Codes. The table below lists additional RBAC and lifecycle rejection codes whose context fields are not already described by the canonical registry.
**Additional Rejection Codes:**
| Code | Context Fields | Description |
| ---------------------------- | ------------------------ | --------------------------------------------------------- |
| `UNAUTHORIZED` | — | Sender lacks required permission |
| `STATE_MISMATCH` | `expected`, `actual` | Move target's current State does not match `from` |
| `RANK_INSUFFICIENT` | — | Operator's rank is not strictly less than target's rank |
| `INVALID_STATE_FOR_GRANT` | — | Target's State is not in Grant/Revoke scope |
| `INVALID_STATE_FOR_TRANSFER` | — | Target's State is not in Transfer scope |
| `INVALID_TRANSFER_TARGET` | — | Transfer target is the operator (self-transfer) |
| `TRAIT_ALREADY_HELD` | — | Transfer target already holds the trait |
| `INVALID_LIFECYCLE_STATE` | — | Lifecycle transition not valid from current state |
| `AC_BUNDLE_FAILED` | `failed_index`, `reason` | AC\_Bundle operation failed at index |
| `EVENT_DELETED` | — | Target event has been deleted (Self:U/D on deleted event) |
| `ENCLAVE_PAUSED` | — | Enclave is paused, only Resume/Terminate/Migrate accepted |
| `ENCLAVE_TERMINATED` | — | Enclave is terminated, no events accepted |
| `ENCLAVE_MIGRATED` | — | Enclave has migrated to another node |
| `ENCLAVE_NOT_FOUND` | — | Enclave ID not found on this node |
***
## Zero-Knowledge Validity Proofs
This document specifies the ZK validity-proof layer for the ENC protocol: a succinct proof that an
SMT state transition produced by the node is **valid** under the RBAC rules, verifiable by a client
in O(1) without replaying the event log or trusting the node.
It complements the Merkle proofs in [proof.md](/spec/kernel/ct) (which prove *inclusion* against a root the
client already trusts) by proving the *root transition itself* is well-formed.
***
### Table of Contents
1. [Motivation](#motivation)
2. [Proving System](#proving-system)
3. [SMT Hash: Poseidon2 over BN254](#smt-hash-poseidon2-over-bn254)
4. [The Transition Circuit](#the-transition-circuit)
5. [Multi-Leaf Transitions](#multi-leaf-transitions)
6. [Folding (Incremental Verifiable Computation)](#folding-incremental-verifiable-computation)
7. [Out of Circuit](#out-of-circuit)
8. [Verification Flow](#verification-flow)
9. [Status](#status)
***
### Motivation
In the base protocol the node is an **untrusted sequencer**: it orders events, applies them to the
Enclave State SMT (see [smt.md](/spec/kernel/smt)), and publishes roots committed via Certificate Transparency.
A client that wants to trust a new `state_hash` otherwise has to either re-execute every event or trust the node.
A ZK validity proof removes that choice. For each state-changing AC event the node (acting as
**prover**) emits a proof that
```
old_root --[valid RBAC transition for this event]--> new_root
```
The client (acting as **verifier**) checks the proof against the public roots and is convinced the
transition obeys every authorization rule — semiring evaluation, rank, state preconditions, leaf
deletion — without seeing the witness or recomputing the tree. This makes ENC a **validity rollup**
for RBAC state.
The same transition relation is proven in two complementary forms: a **bounded** Groth16 proof of one
transition (or a fixed-size batch), and a single **folded proof over the entire transition history**
(Nova IVC — see [Folding](#folding-incremental-verifiable-computation)). The folded form gives O(1)
verification of an *unbounded* chain, which the bounded form cannot.
| Role | Who | Sees |
| --------- | ---------------------------- | ------------------------------------------------ |
| Sequencer | Node | Orders + applies events (untrusted) |
| Prover | Node (or a delegated prover) | Full witness: pre-image leaves, siblings, schema |
| Verifier | Client | Public inputs + proof only; O(1) |
***
### Proving System
The validity relation `old_root → new_root` MUST be proven in two complementary modes, both over BN254; implementations MUST use the choices in the following table:
| | Bounded | Unbounded (folding) |
| ------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Proof system | **Groth16** over R1CS | **Nova IVC** (folding) + **Spartan** compression |
| Scope | one transition, or a fixed batch chained through intermediate roots | an **incremental, unbounded** chain — the enclave's whole transition history |
| Prover | one circuit, size O(N) in batch length | **O(1) work per step**, constant memory |
| Verifier | O(1) (≈3 pairings) | succinct: O(log n)-size compressed proof on the transparent (IPA) path, O(1) with the optional KZG decider |
| Curve(s) | BN254 (alt-bn128) | BN254 + **Grumpkin** (the second curve of the cycle) |
Both modes use the **BN254 scalar field** `Fr`
(`p = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001`) as the field over which the
SMT's Poseidon2 hash is defined, so SMT membership is proven natively in-circuit.
The bounded Groth16
verifier is pairing-based and on-chain-friendly (EIP-196/197); the unbounded path folds each transition
with Nova and compresses the final instance to one succinct proof.
Trusted setup: Groth16 (and the optional KZG-based folding decider) require a per-circuit / universal
setup; the **transparent** folding path (IPA/Pedersen commitments) requires none.
Verifying keys are
published and pinned by clients; a proof is valid only against the pinned key.
***
### SMT Hash: Poseidon2 over BN254
The Enclave State SMT leaf and node hashing use **Poseidon2 over BN254** (a SNARK-native hash), so the
prover can prove SMT membership inside the circuit cheaply.
The canonical instance is normative:
| Parameter | Value | | |
| ----------------------- | --------------------------------------------------------------------------------- | - | ----------- |
| State width | `t = 3` (rate 2, capacity 1) — clean 2-to-1 compression | | |
| S-box | `x^5` (`d = 5`) | | |
| Full rounds | `R_F = 8` (4 + 4 external) | | |
| Partial rounds | `R_P = 56` (internal) | | |
| External matrix `M_ext` | MDS circulant `[[2,1,1],[1,2,1],[1,1,2]]` | | |
| Internal matrix `M_int` | `[[2,1,1],[1,2,1],[1,1,3]]` (`1 + diag[1,1,2]` ones-form) | | |
| Round constants | NUMS: \`RC\_g = Fr::from\_be\_bytes\_mod\_order(sha256("ENCv1-Poseidon2-BN254-t3" | | LE64(g)))\` |
The round structure is the Poseidon2 form (distinct from Poseidon v1): an initial external linear
layer, `R_F/2` external rounds, `R_P` internal rounds, `R_F/2` external rounds.
`R_F`, `R_P`, `d`, and `M_int` match the official Poseidon2 BN256 reference (HorizenLabs/poseidon2).
The round constants are an **intentional NUMS deviation** from the reference Grain-LFSR — a
nothing-up-my-sleeve choice for trivial cross-implementation reproducibility; security rests on the
(matching) round structure and matrices, not on the specific constants.
The full instance, including
the materialized constants, is the cross-language contract and MUST be embedded, never re-derived per
implementation.
#### Hash modes
Implementations MUST compute each hash mode per the following equations:
```
compress(cap, l, r) = Poseidon2_permute([cap, l, r])[0]
leaf_hash(key, value) = compress(DOMAIN_LEAF, F(key), F(value))
node_hash(left, right) = compress(DOMAIN_NODE, F(left), F(right))
```
Where `F(b)` interprets a 32-byte big-endian string as an `Fr` (reduced mod `p`), and the capacity
element carries the domain constant.
This replaces the SHA-256 prefix-byte domain separation of
[smt.md](/spec/kernel/smt):
| Constant | Value | Replaces |
| ------------- | ---------- | ---------------------- |
| `DOMAIN_LEAF` | `Fr(0x20)` | SMT-leaf prefix `0x20` |
| `DOMAIN_NODE` | `Fr(0x21)` | SMT-node prefix `0x21` |
#### Unchanged from [smt.md](/spec/kernel/smt)
* **Tree depth** stays 168 bits, MSB-first traversal, namespaced fixed-depth sparse tree.
* **Key derivation** stays `namespace || sha256(raw)[0:160 bits]` — SHA-256, *not* Poseidon2.
The
in-circuit identity→key derivation (`derive_key(ns, id) = poseidon2(...)`) is deliberately **not**
adopted: the circuit takes the SMT key as a public input and the binding to identity is enforced
out-of-circuit (see [Identity Binding](#identity-binding)), which is sound and avoids a second SMT
migration plus \~30k constraints of in-circuit SHA-256.
* **Empty sentinel** stays `sha256("")` (reduced into `Fr` for hashing). Empty subtree hashes are
precomputed per level.
* **Leaf value** encoding: a 32-byte SMT leaf value is interpreted as a single `Fr` via `F(b) = Fr::from_be_bytes_mod_order(b)` (32-byte big-endian → Fr, reduced modulo `p`).
RBAC bitmasks (State bits 0-7, trait bits 8+) fit directly in `Fr` (`< 2^253`) and the reduction is a no-op for them.
Arbitrary 32-byte values (event-status ids, KV content hashes, SMT leaf hash outputs from a SHA-256 path) MAY exceed `p` by a small margin (`2^254 ≤ x < 2^256`); the modular reduction is sound by reduction to discrete-log hardness of the underlying 32-byte source — the collision probability between two SHA-256 outputs `a, b` with `a ≡ b (mod p)` is `~2^-254`, indistinguishable from collision in SHA-256 itself.
***
### The Transition Circuit
The circuit proves a single state-changing AC event drives `old_root → new_root` validly.
There is one
circuit family parameterized by event shape; the single-leaf circuit is the core, and multi-leaf
events chain it (see [Multi-Leaf Transitions](#multi-leaf-transitions)).
#### Public Inputs
A single-leaf transition exposes exactly these field elements as public inputs:
| Input | Meaning |
| ------------ | ------------------------------------------------------- |
| `old_root` | SMT root before the event |
| `new_root` | SMT root after the event |
| `author_key` | 21-byte SMT key of the event author (operator), as `Fr` |
| `target_key` | 21-byte SMT key of the target identity, as `Fr` |
Everything else — the author's and target's leaf values (bitmasks), the Merkle siblings and presence
bitmaps for both leaves, the action schema, the operator rank, the Move `from`-state, the Grant scope
— is a **private witness**. The verifier learns only that *some* valid witness exists.
#### Enforced Constraints
The circuit enforces the complete node authorization pipeline (see [`rbac/events.md`](/spec/kernel/rbac)):
1. **Membership binding (old).** `fold_membership(target_key, old_target_leaf, siblings, bitmap)`
recomputes to `old_root`. Same for the author leaf. This binds the witnessed pre-image leaves to the
public `old_root`.
2. **Authorization (semiring).** The RBAC semiring verdict (deny-override via `_op` rows, Public/Any/
wildcard/Self columns) over the operator's traits MUST be `allow` for this event type and operation.
3. **Rank.** The operator's best rank MUST be strictly less than the target's (skipped for the
no-traits and self cases per the spec rule).
4. **Event-specific preconditions.**
* **Move:** `current_state == from` (else the transition is invalid), set the State enum and clear
trait flags unless `preserve`.
* **Grant:** the trait being granted MUST be in-scope for the current State.
* **Revoke / Transfer:** clear/move the trait bit.
5. **Mutation + leaf deletion.** The new target leaf is the mutated bitmask, *except* a bitmask of `0`
removes the leaf (substitutes the empty sentinel) — an identity with no roles is not in the tree.
6. **Membership binding (new).** Re-folding the mutated target leaf through the *same* siblings MUST
recompute to `new_root`. (Single-leaf updates share siblings, so this is sound without a
divergence-detection multiproof.)
A proof exists iff all of the above hold. A forged `new_root` (or any rule violation) is
unsatisfiable.
> **Emptiness handling (bounded vs folding).** This bounded circuit takes the per-level
> sibling-presence bitmap as a private witness (pinned by the old-root membership binding) and covers
> **present-leaf update and deletion** (bitmask→0). **Insert** from a non-membership old state, and the
> empty-subtree short-circuit generally, are provided by the **folding step**
> (see [Folding](#folding-incremental-verifiable-computation)), which *derives* emptiness in-circuit
> from the sibling values rather than from a witnessed flag.
#### Identity Binding
`author_key` and `target_key` are public 21-byte SMT keys, not raw identities. The verifier closes the
identity binding **out-of-circuit** by checking
```
key == namespace || sha256(id_pub)[0:160 bits]
```
against the `id_pub` it already authenticated (e.g. from the Schnorr-signed commit). This is sound —
the key→identity map is a single trivial SHA-256 the verifier already computes — and keeps the
expensive SHA-256 out of R1CS. `is_self` is derived in-circuit from `author_key == target_key`, so the
Self authorization path cannot be forged.
***
### Multi-Leaf Transitions
Events that touch more than one leaf MUST be proven as **chains of single-leaf transitions through
intermediate roots**, rather than with a single multi-leaf multiproof, with each multi-leaf event constructed per the following table:
| Event | Construction |
| -------------- | ----------------------------------------------------------------------------------------------------------------- |
| **Transfer** | Two chained single-leaf updates (clear operator trait, set target trait) through one intermediate root `mid_root` |
| **AC\_Bundle** | N chained single-leaf updates through N−1 intermediate roots |
| **Manifest** | Genesis: chained inserts from `init` entries (insert support via the folding step) |
For a bundle, `old_root` and every intermediate root are public inputs; the final root is the
published `new_root`. Each link is the single-leaf circuit above, so atomicity is proven as "the whole
chain verifies or none does." This avoids an in-circuit divergence-detection gadget: because each link
is an independent single-leaf update with its own membership binding against the previous root, the
chain is sound.
***
### Folding (Incremental Verifiable Computation)
The bounded forms above prove a *fixed-size* batch: the circuit grows O(N) in the number of
transitions, so there is a ceiling. But the Enclave State SMT also holds arbitrary application state
(KV namespace `0x02`, see [smt.md](/spec/kernel/smt)), so the number of state transitions scales with app
activity and is **unbounded** over an enclave's lifetime — no fixed batch can cover the whole history.
**Folding** (Incrementally Verifiable Computation, via the Nova folding scheme) closes this: each step
folds one transition into a running instance, so the prover does **O(1) work per step** with constant
memory, and a final compression SNARK yields **one O(1)-verifiable proof of the entire chain**.
**Step relation.** The IVC state is the two field elements `z = [smt_root, schema_commitment]`. The
first moves each step (the SMT root); the second is **constant** across the chain — a commitment to the
enclave's manifest schema, carried unchanged.
Each fold step proves
the full per-transition authorization relation — membership of the old leaf in `z[0]`, the RBAC
semiring + rank rule on the author (with `is_self` derived in-circuit), the Move from-state
precondition and Grant state-scope (Enforced Constraints item 4), the `apply_op` mutation, and the new
root (including **insert** of a leaf into an empty slot and **delete** when a bitmask reaches 0) — and
outputs the next root.
**Universal step + witnessed op/schema.** One circuit proves **any** single-leaf transition (RBAC, KV,
or event-status), with `is_ac` (whether RBAC authorization applies) derived in-circuit from the target
key's namespace byte — so one IVC chain covers an enclave's whole state across all namespaces. To make
the constraint *matrices* identical for every transition (a precondition for a single shared
`PublicParams`), both the **event op** (Grant/Revoke/Move of any trait) and the **action schema**
(allow/deny flags + ranks, with trait positions fixed at bits `8+k`) are **witnessed circuit inputs**
rather than baked constants. So one `PublicParams` folds arbitrary ops under **any** enclave schema.
**Schema binding.** Because the schema is witnessed, the step also binds it: it hashes the witnessed
schema in-circuit to `schema_commitment = Poseidon2(0x53, flags_packed, ranks_packed)` and enforces it
equals `z[1]`. The verifier sets `z[1]` from the published manifest (the same hash), so a prover
**cannot fold under a forged, more-permissive schema** — the proof is valid only for the manifest's
schema. (This is stronger than the bounded circuit, where the schema is a trusted private witness.)
Folding `N` steps attests every transition in the chain from a trusted `r_0` to a final `r_n`,
with no replay; binding `r_n` to the node's published `state_hash` is the verifier's job (see
Verification below).
**Construction.** The folding construction MUST follow the choices in the table below:
| Aspect | Choice |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| Folding scheme | Nova (the canonical microsoft/Nova `nova-snark`), over a BN254/Grumpkin 2-curve cycle |
| IVC state | `z = [smt_root, schema_commitment]` — the root moves; the commitment is constant (carried) |
| Step hash | the **same** Poseidon2-over-BN254 instance as the node and the bounded circuit (byte-identical) |
| Op + schema | **witnessed** circuit inputs (not baked) → one `PublicParams` folds arbitrary ops under any schema |
| Schema binding | witnessed schema hashed in-circuit to `Poseidon2(0x53, flags, ranks)` and enforced `== z[1]` |
| Commitments | **IPA/Pedersen** — transparent, **no trusted setup** (default); KZG optional for constant-size proofs |
| Final proof | a **Spartan** SNARK compressing the folded instance to one succinct proof |
The empty-subtree short-circuit in the folding step is **derived in-circuit** from the sibling values
(a parent of two empty children stays the sentinel) rather than taking a witnessed "sibling present"
flag, so the emptiness check is bound to the actual sibling value (no free witness) — the basis for
non-membership, insert, and delete, pending the external review noted under Status.
Because the step hash is the identical Poseidon2 instance, a folded root commits to state exactly as
the node's root does, and the folding step enforces the same authorization relation as the bounded
circuit. The one remaining difference is **identity binding**: the bounded circuit exposes the
author/target keys as public inputs (so the verifier can bind them to signed events out-of-circuit),
whereas the folding step witnesses the keys and derives `is_self` — the IVC state is `[smt_root,
schema_commitment]`, no per-step keys. So a folded proof attests the same per-transition validity,
without exposing the per-step identities; the anchoring binds **roots** to the signed log (which is
exactly what the folding path certifies: every transition between two log-anchored roots was valid).
**Verification.** Recompute `schema_commitment` from the published manifest. Obtain `r_0` from a trusted
genesis/checkpoint (or anchor it via CT + STH as in the bounded flow), then verify the compressed
folding proof against `z = [r_0, schema_commitment]`, checking the attested final state is `[r_n,
schema_commitment]`. Success means every one of the (unboundedly many) intervening transitions was
valid **and** folded under the manifest's schema — in O(1), with no replay. A reference implementation
of this anchored verification (STH + CT inclusion + schema recompute + proof) lives in the impl-zk
`verifier/` crate.
***
### Out of Circuit
The following checks MUST be performed out-of-circuit by the client with ordinary
cryptography, and linked to the proof only by equality of the public field elements (the roots and
keys). Putting SHA-256/CBOR/secp256k1 in-circuit would cost millions of constraints per event for no
soundness gain.
| Check | Where | How it links |
| ------------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Commit signature (Schnorr / ECDSA / cosign cert) | Out-of-circuit | Authenticates `commit.from` (the parent `id_pub`) → `author_key`. Sub-key-authored commits validate the cosign cert per [`spec.md` §Delegated Sub-keys](/spec/kernel/spec); the SMT key still derives from the parent `id_pub`, never the sub-key. |
| CBOR event/commit hash recompute | Out-of-circuit | Standard event verification |
| CT inclusion / consistency | Out-of-circuit ([proof.md](/spec/kernel/ct)) | Binds `state_hash` to the log |
| STH signature | Out-of-circuit ([proof.md](/spec/kernel/ct)) | Anchors the published root |
| Identity → SMT key | Out-of-circuit (verifier SHA-256) | Binds public `*_key` to identity |
***
### Verification Flow
To accept a node-claimed state transition for an AC event:
1. Authenticate the event out-of-circuit: verify the commit signature per `commit.alg`
([`spec.md` §Signature Schemes](/spec/kernel/spec) — Schnorr for the default path; ECDSA
for the `alg: "ecdsa"` path; cosign cert validation for sub-key-authored commits per
[`spec.md` §Delegated Sub-keys](/spec/kernel/spec)). Recompute the CBOR event hash. In all
cases, `id_pub_author = commit.from` and `id_pub_target = content.target` (when present); RBAC
and the SMT key derivation are keyed to the **parent identity**, not the sub-key, so a
cert-authorized commit produces the same `author_key` as a direct-signed commit by the same
parent.
2. Derive the expected SMT keys `namespace || sha256(id_pub)[0:160 bits]` and check they equal the
proof's public `author_key` / `target_key`. The hash input is the 32-byte x-only `id_pub`
(the field-name used in [`spec.md` §reg\_identity](/spec/app/enclaves/registry#reg_identity) — not `id_pub_full` or
`sub_pub`). Sub-keys are not in the SMT and are never used as SMT-key material.
3. Establish that `old_root` is trusted — typically via a prior accepted transition, or via a CT
inclusion proof + STH anchoring `old_root` to the signed log ([proof.md](/spec/kernel/ct)).
4. Verify the proof against the pinned verifying key. **Bounded mode:** a Groth16 proof over public
inputs `(old_root, new_root, author_key, target_key)` (plus any intermediate roots for multi-leaf
events). **Unbounded mode:** the compressed folding (Spartan) proof over `z = [r_0,
schema_commitment]` → `[r_n, schema_commitment]`, where `schema_commitment` is recomputed from the
published manifest — see [Folding](#folding-incremental-verifiable-computation).
5. On success, `new_root` (bounded) / `r_n` (folded) is accepted as a valid successor reached by valid
transition(s) — without replaying the SMT.
The proof certifies *validity of the transition*; the CT/STH layer certifies *which transition the log
commits to*. Together a client trusts a fresh `state_hash` end-to-end with O(1) work.
***
### Production Readiness
This layer is **implemented and reference-tested** but not yet production-locked:
* **Bounded (Groth16).** The validity proof verifies over a live node's real Poseidon2 SMT roots (real
signed RBAC transition → Groth16 proof → client verify; a forged root is rejected). The single-leaf
circuit is \~127k constraints; Transfer/AC\_Bundle chain it; the Move precondition, Grant state-scope,
rank rule, and leaf deletion are enforced.
* **Unbounded (folding).** The Nova IVC layer is implemented on the canonical Nova implementation:
single-leaf membership-and-update, the **full in-circuit RBAC authorization relation** (semiring +
rank + `apply_op` + Move from-state precondition + Grant state-scope), and structure-changing
insert/delete are each folded and recursively verified. The **universal step** proves any single-leaf
transition (RBAC/KV/event-status), and the **op and schema are witnessed inputs** — so one shared
`PublicParams` folds arbitrary ops under any enclave schema, with the witnessed schema **bound** to a
manifest commitment in `z[1]` (a prover cannot fold under a forged schema). A **Spartan** compression
to one succinct proof is demonstrated end-to-end on the Poseidon2 fold chain. The folding step's
Poseidon2 is gated **byte-identical** to the node (shared golden vectors). The full **out-of-circuit
verifier** (impl-zk `verifier/` crate) is implemented and run **end-to-end against a live `wrangler
dev` node**: a real signed Grant is folded through the prover-service, and the client verifies the
node's real **STH (Schnorr)** + **CT inclusion** proofs anchoring both `r_0` and `r_n` to the signed
log, then the compressed folding proof against `[r_0, schema_commitment]` — accepting `r_n`, and
rejecting any tampering. It runs **ceremony-free** (transparent IPA commitments). The bounded circuit
additionally exposes the keys as public inputs for identity binding; the folding step witnesses them
(a by-design IVC difference). Each fold step is sizable (\~1.5·10⁵ constraints); "O(1) per step" is
asymptotic in chain length, not small.
* **Poseidon2** is byte-identical across the JS node, the Rust node logic, the bounded circuit, and the
folding step; the instance is **validated** against the official BN256 reference for `R_F`/`R_P`/`d`/
`M_int` (round constants an intentional NUMS deviation, above).
* **Trusted setup.** Groth16 (and the optional KZG folding decider) need a real setup ceremony before
production; the transparent IPA folding path does not. Verifying keys MUST be published and pinned.
The reference implementation lives in the impl-zk circuit crate (bounded) and its `folding/` crate (IVC).
## Certificate Transparency Proofs
This document specifies the **Certificate Transparency** proof formats clients use to verify the ENC enclave event log without trusting the node: CT inclusion and consistency proofs for the log itself, bundle membership proofs that tie individual events to a CT leaf, and the Signed Tree Head (STH) the sequencer publishes as the trust root. SMT membership / non-membership proofs (for current enclave state) are specified separately in [`smt.md`](/spec/kernel/smt).
***
### Table of Contents
1. [Overview](#overview)
2. [CT Proofs](#ct-proofs)
3. [Wire Format (JSON)](#wire-format-json)
4. [Bundle Construction (RFC 9162)](#bundle-construction-rfc-9162)
***
### Overview
The ENC protocol uses two types of Merkle proofs:
| Proof Type | Purpose | Tree |
| ------------------ | ------------------------------------------ | ------------------------ |
| **CT Inclusion** | Prove event exists at position N in log | Certificate Transparency |
| **CT Consistency** | Prove earlier log is prefix of current log | Certificate Transparency |
These Merkle proofs prove inclusion against a root the client *already trusts*. To instead prove that a root *transition* `old_root → new_root` is itself **valid** under the RBAC rules — letting a client trust a fresh `state_hash` in O(1) without replaying the log — see the zero-knowledge validity-proof layer in [`zk.md`](/spec/node/zk). For proofs of current enclave state against a known `state_hash`, see [`smt.md`](/spec/kernel/smt).
***
### CT Proofs
#### Bundle Membership Proof
Proves that an event is part of a specific bundle.
**Structure:**
```
{
event_id: <32 bytes>,
bundle_index: ,
bundle_size: , // total event count in the bundle; needed for odd-size promotion
siblings: [, ...]
}
```
Where:
* **event\_id** — the event being proven
* **bundle\_index** — position of event within the bundle (0-indexed)
* **bundle\_size** — total number of events in the bundle (`>= 1`); used by the verifier to detect odd-leaf promotion at each layer
* **siblings** — Merkle proof siblings from event\_id to events\_root, in **leaf-to-root** order (`siblings[0]` is the deepest sibling). Carried (odd-promoted) leaves contribute NO sibling at the layer where they were carried up.
**Verification:**
```
hash = event_id
index = bundle_index
n = bundle_size
sib_i = 0
while n > 1:
if index == n - 1 and (n % 2 == 1):
# Odd promotion — last leaf at an odd-sized layer is carried up
# unchanged. No sibling consumed; no hash step at this layer.
pass
else:
s = siblings[sib_i]; sib_i += 1
if index % 2 == 0:
hash = H(0x01, hash, s) # current is left child
else:
hash = H(0x01, s, hash) # current is right child
index = index >> 1
n = (n + 1) >> 1 # ceil(n / 2) — size of next layer
require sib_i == siblings.length # no excess siblings
require hash == events_root
```
**Algorithm Notes:**
* `bundle_index` is the event's 0-indexed position within the bundle.
* The LSB of `index` determines left (0) or right (1) at each level.
* After each layer, shift `index` right and recompute `n = ceil(n/2)` for the next layer's size.
* The odd-promotion check (`index == n - 1 and n % 2 == 1`) reflects the RFC 9162-style root construction: when a layer has an odd number of nodes, the rightmost node is carried up to the next layer **unchanged** (no `node_hash` applied).Reference: `computeEventsRoot` and the parity-matching Rust at.
* Siblings are ordered leaf-to-root; the verifier consumes `siblings[0]` at the deepest non-promoted layer.
**Example (even bundle):**
Bundle with 4 events, verifying event at `bundle_index = 2`, `bundle_size = 4`:
```
events_root
/ \
h01 h23
/ \ / \
e0 e1 e2 e3 ← bundle_index: 0, 1, 2, 3
```
* Start: `hash = e2`, `index = 2`, `n = 4`, `sib_i = 0`
* Layer 0 (`n=4`): `index=2, n-1=3`, not last. LSB(2)=0 → `hash = H(0x01, hash, siblings[0])` where `siblings[0] = e3`. `sib_i=1, index=1, n=2`.
* Layer 1 (`n=2`): `index=1, n-1=1`, IS last but `n=2` is even — not odd promotion. LSB(1)=1 → `hash = H(0x01, siblings[1], hash)` where `siblings[1] = h01`. `sib_i=2, index=0, n=1`.
* Loop ends. `hash == events_root`. `siblings = [e3, h01]` (length 2).
**Example (odd bundle, carried leaf):**
Bundle with 3 events, verifying event at `bundle_index = 2`, `bundle_size = 3`:
```
events_root = H(0x01, h01, e2)
/ \
h01 e2 ← layer 1: e2 carried up
/ \ /
e0 e1 e2 ← bundle_index: 0, 1, 2
```
* Start: `hash = e2`, `index = 2`, `n = 3`, `sib_i = 0`
* Layer 0 (`n=3`): `index=2 == n-1=2` AND `n` is odd → ODD PROMOTION, no sibling consumed. `index=1, n=2`.
* Layer 1 (`n=2`): `index=1, n-1=1`, last but `n=2` even — normal. LSB(1)=1 → `hash = H(0x01, siblings[0], hash)` where `siblings[0] = h01`. `sib_i=1, index=0, n=1`.
* Loop ends. `hash == events_root`. `siblings = [h01]` (length 1).
With `bundle_size = 1`, the bundle contains one event, the loop body never runs, `siblings == []`, and `events_root == event_id`.
#### CT Inclusion Proof
Proves that a bundle exists at a specific position in the log.
**Structure:**
```
{
tree_size: ,
leaf_index: ,
path: [, ...]
}
```
Where:
* **tree\_size** — number of bundles in the tree when proof was generated
* **leaf\_index** — 0-based position of the bundle in the log
* **path** — sibling hashes from leaf to root
**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
2. **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: ,
tree_size_2: ,
path: [, ...]
}
```
Where:
* **tree\_size\_1** — size of the older (smaller) tree
* **tree\_size\_2** — size of the newer (larger) tree
* **path** — sibling hashes proving consistency
**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: ,
ts: ,
r: ,
sig:
}
```
Where:
* **t** — Unix milliseconds when STH was generated
* **ts** — number of bundles in the tree
* **r** — CT root hash (32 bytes)
* **sig** — Schnorr signature over the STH
**Signature:**
```
message = "enc:sth:" || be64(t) || be64(ts) || r
sig = schnorr_sign(sha256(message), seq_priv)
```
Where `r` is the raw 32-byte root hash (NOT hex-encoded). The message is binary concatenation:
* `"enc:sth:"` = 8 bytes UTF-8
* `be64(t)` = 8 bytes big-endian
* `be64(ts)` = 8 bytes big-endian
* `r` = 32 bytes raw
Total: 56 bytes before SHA-256.
**Wire Format (JSON):**
```json
{
"t": 1706000000000,
"ts": 1000,
"r": "",
"sig": ""
}
```
**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.
***
### Wire Format (JSON)
The normative wire format is JSON.
#### CT Inclusion Proof
```json
{
"ts": 1000,
"li": 42,
"p": ["", ...]
}
```
| Field | Encoding |
| ----- | ---------------------------------------------- |
| ts | Integer (tree\_size) |
| li | Integer (leaf\_index) |
| p | Array of hex strings, 64 chars each (32 bytes) |
#### CT Consistency Proof
```json
{
"ts1": 500,
"ts2": 1000,
"p": ["", ...]
}
```
| Field | Encoding |
| ----- | ---------------------------------------------- |
| ts1 | Integer (tree\_size\_1) |
| ts2 | Integer (tree\_size\_2) |
| p | Array of hex strings, 64 chars each (32 bytes) |
#### Bundle Membership Proof
```json
{
"ei": 2,
"s": ["", ...]
}
```
| Field | Encoding |
| ----- | ---------------------------------------------- |
| ei | Integer (event index within bundle, 0-indexed) |
| s | Array 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.
***
### Bundle Construction (RFC 9162)
How the sequencer assembles events into CT leaves and grows the append-only Merkle tree. Follows [RFC 9162](https://www.rfc-editor.org/rfc/rfc9162.html).
**Properties:**
* **CT root:** Single hash representing the entire event history AND state
* **Inclusion proof:** Proves a specific event exists at a given position in the log
* **Consistency proof:** Proves an earlier log state is a prefix of the current state
**Bundle Structure:**
Events are grouped into bundles (see [`spec.md` §Bundle Configuration](/spec/kernel/spec#bundle-configuration)). Each bundle produces one CT leaf.
```
bundle = {
events: [event_id_0, event_id_1, ..., event_id_N],
state_hash:
}
```
**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 `init` entries have been applied.
**Leaf hash:**
```
events_root = merkle_root(events) // binary Merkle tree of event IDs
leaf_hash = H(0x00, events_root, state_hash)
```
Where:
* `events_root` — Merkle root of event IDs in this bundle
* `state_hash` — SMT root AFTER the last event in this bundle is applied
**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)`
* Built bottom-up by pairing adjacent nodes; if a level has an odd number of nodes, the final unpaired node is **carried up unchanged** to the next level (it is NOT duplicated or padded).
* Example: 3 events → `H(0x01, H(0x01, e0, e1), e2)` (e2 carried up, never duplicated)
**Bundle Event Ordering:**
Events within a bundle are ordered by their sequence number (`seq`). The first event has the lowest seq, the last has the highest. This ordering is deterministic and verifiable.
**Bundle Index Assignment:**
Bundles are numbered sequentially starting from 0. An event's bundle membership is determined by bundle boundaries:
* `bundle_0` contains events from seq=0 until size or timeout reached
* `bundle_N` contains events from `boundary[N-1] + 1` until size or timeout reached
* `boundary[N]` = seq of last event in bundle N
**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):
* Bundles are CT leaves, numbered sequentially (`bundle_0`, `bundle_1`, ...).
* Tree grows as bundles are appended
* The Merkle Tree Hash (MTH) is computed recursively (RFC 9162 §2.1), with NO padding or leaf duplication. For `n` leaves `D`:
* `MTH([]) = 0x00…00` (32 zero bytes) — the sentinel root for an empty CT. **NOTE:** This is `[0u8; 32]`, NOT `sha256("")` — the empty-tree sentinel deliberately differs from the SMT's empty-subtree hash `sha256("")` (see [smt.md](/spec/kernel/smt)).
* `MTH([d0]) = leaf_hash(d0)` — a single leaf is its own hash (it is NOT re-hashed).
* `MTH(D[0:n]) = H(0x01 || MTH(D[0:k]) || MTH(D[k:n]))` for `n > 1`, where `k` is the **largest power of two strictly less than `n`**.
* For non-power-of-two `n` this yields an **unbalanced** tree; the trailing leaves are promoted up the right spine, never padded/duplicated.
* Example: 5 bundles split at `k = 4` → `H(0x01 || MTH([b0..b3]) || MTH([b4]))`
```
CT root (5 bundles, RFC 9162 §2.1 — split at largest power of two < n):
root
/ \
h0123 b4 ← b4 is a lone leaf promoted up; no padding
/ \
h01 h23
/ \ / \
b0 b1 b2 b3
```
This matches RFC 9162 exactly and is required for working inclusion AND consistency proofs. Pad-with-last (duplicating the final leaf to the next power of two) MUST NOT be used — it cannot support RFC 9162 consistency proofs.
**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 (modify SMT): Manifest, Move, Grant, Revoke, Transfer, Gate, AC\_Bundle, Shared, Own, Update, Delete, Pause, Resume, Terminate, Migrate.
Non-state-changing events: Content Events (app-defined customs).
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:
| Mode | Returns | Verifiable | Fresh |
| ---------- | --------------------------------------- | ---------------- | ------------ |
| `verified` | `state_hash` from last finalized bundle | ✅ Yes (CT proof) | May be stale |
| `current` | SMT root including pending events | ❌ No proof | ✅ Fresh |
* **Verified queries**: Use for audits, disputes, high-stakes operations
* **Current queries**: Use for real-time apps (chat, collaboration)
Nodes SHOULD support both modes.
\::: extension-point id=ct-default-query-mode class=impl\_defined\_default
reason: choice between `verified` (paid SMT-proof round-trip) and `current` (no proof) is a per-deployment tradeoff between latency and verification strength
A node's default query mode (when a caller omits the `mode` parameter) is chosen by the implementation. Nodes SHOULD document their choice.
\:::
**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:**
* Client verifies an event is part of the canonical log
* Client verifies the enclave state at any point in history
* Client verifies the log they cached is still valid (consistency with current log)
* Detect if a node is presenting different log histories to different clients
* Checkpoint for migration (CT root proves both log and state)
*Proof serialization formats are specified in [§Wire Format (JSON)](#wire-format-json) above.*
## RBAC v2
This document specifies **RBAC v2**, the role-based access control system used by every ENC enclave. It covers the bitmask encoding of State and trait, the runtime Context abstractions (Self / Sender / Public), the JSON manifest schema, the authorization algorithm, and the per-event processing rules (Move / Grant / Revoke / Transfer / Gate / AC\_Bundle / Lifecycle / KV).
***
### Table of Contents
* [Overview](#overview)
* [Bitmask Encoding](#bitmask-encoding)
* [Contexts](#contexts)
* [SMT Namespace Table](#smt-namespace-table)
* [Appendix: Removed Events](#appendix-removed-events)
* [Manifest Format](#manifest-format)
* [Validation Rules](#validation-rules)
* [Example Manifest](#example-manifest)
* [Authorization Algorithm](#authorization-algorithm)
* [Processing Pipeline](#processing-pipeline)
* [Event Processing](#event-processing)
***
### Overview
The RBAC system controls authorization for all events in an enclave. Every event — content events (message), AC events (Move, Grant, Revoke), KV events (Shared, Own), and lifecycle events (Pause, Terminate) — is authorized through the manifest. See Section 5 for manifest format and Section 6 for validation rules.
#### 1.1 Terminology
The old unified "role" is retired. Three column types replace it:
| Concept | Convention | Examples | Encoding | Changed by | Semantics |
| ----------- | ----------- | ------------------------ | ---------------- | -------------------------------- | --------------------------------------------------------- |
| **State** | UPPER\_CASE | PENDING, MEMBER, BLOCKED | 8-bit enum | Move | WHERE the actor is. Base permissions. Mutually exclusive. |
| **trait** | lower\_case | owner, admin, muted | Flag bits | Grant / Revoke / Transfer / init | WHAT modifies the actor. Additive or deny ops. Ranked. |
| **Context** | PascalCase | Self, Sender, Public | System-evaluated | (implicit) | System condition. Determined at authorization time. |
#### 1.2 Operations
Six operations apply to all event types:
| Op | Meaning | Deny |
| -- | --------------------------------------- | ---- |
| C | Create | \_C |
| R | Read | \_R |
| U | Update | \_U |
| D | Delete | \_D |
| N | Notify (lightweight, human clients) | \_N |
| P | Push (full delivery, service endpoints) | \_P |
Positive ops grant capabilities. Negative (deny) ops revoke capabilities. The sign comes from the schema entry, not the bitmask.
***
***
### Bitmask Encoding
All RBAC state for an identity is stored in a single bitmask value in the SMT (namespace `0x00`), keyed by the identity's public key.
```
bits 0-7: State enum
bits 8+: trait flags
```
#### 2.1 State Enum (bits 0-7)
8-bit integer field. All 8 bits together encode a single value. Mutually exclusive by structure — an identity is in exactly one State.
| Value | Name | Description |
| ----- | ------------- | ------------------------------------------------------------------------ |
| 0 | OUTSIDER | Not in the enclave. No SMT leaf exists. Protocol-reserved. |
| 1-255 | (app-defined) | Custom States defined in manifest. E.g., 1=PENDING, 2=MEMBER, 3=BLOCKED. |
OUTSIDER (`0`) is the only protocol-reserved State. All other States — including blocking/banning — are app-defined.
State values `1`-`255` are assigned sequentially from the manifest `states` array. The first State in the array gets value `1`, the second gets value `2`, etc.
#### 2.2 trait Flags (bits 8+)
Independent flag bits. Each bit represents one trait. Multiple traits can be set simultaneously.
Bit positions are assigned sequentially from the manifest `traits` array: the first trait gets bit `8`, the second gets bit `9`, etc.
Each trait declares a **rank** using the syntax `name(N)` where `N` is a non-negative integer. Lower number = higher rank. Multiple traits can share the same rank (peers). Rank determines targeting authority: an operator can only target identities ranked strictly below them (see §7 Rank Rule).
All traits are managed uniformly:
* Assigned via `init`, `Grant`, or `Transfer`.
* Removed via `Revoke`, `Transfer`, or `Move` (clears all trait flags unless `preserve: true`).
* Rank determines targeting authority (lower number = higher authority).
The bitmask does not distinguish positive from negative traits. A "muted" trait flag at bit `10` is stored identically to an "admin" trait flag at bit `9`. The sign (positive or negative ops) comes from the schema entry, not the bitmask.
#### 2.3 SMT Leaf Lifecycle
* **Join**: `Move(OUTSIDER, X)` creates a new SMT leaf with State=X and no trait flags.
* **State change**: `Move(X, Y)` updates the State enum and clears all trait flags (unless `preserve: true`).
* **Leave**: `Move(X, OUTSIDER)` sets State=0 and clears all traits. If bitmask becomes `0`, leaf is deleted.
* **Service account**: Granting a trait to an OUTSIDER creates a leaf with State=0 and trait bit set.
* **Leaf deletion**: SMT leaf is deleted when the **entire bitmask == 0** (no State, no traits).
* **No leaf**: An identity with no SMT leaf is implicitly OUTSIDER with no traits.
Historical membership is provable via the CT log.
***
***
### Contexts
Contexts are system-evaluated conditions used as columns in the schema. They are not stored — they are determined at authorization time.
#### 3.1 Self
* **Matches when**: The actor is targeting their own identity (`event.from == content.target`).
* **Use case**: AC events — leaving a group (Move Self:C), stepping down from a trait (Revoke Self:C).
#### 3.2 Sender
* **Matches when**: The actor authored the referenced event (`event.from == original_event.from`).
* **Use case**: Content events — editing/deleting own messages (message Sender:UD), removing own reactions (reaction Sender:D), updating own KV slot (Own Sender:U).
#### 3.3 Public
* **Matches when**: Always. Any identity, any State, including OUTSIDER.
* **Use case**: Public read access to group messages, public enclave manifests.
***
***
### SMT Namespace Table
| Namespace | Purpose | Key | Value |
| --------- | ------------- | ---------------------------------- | ---------------------------- |
| `0x00` | RBAC | `H(identity)` | Bitmask (State + traits) |
| `0x01` | `EventStatus` | `H(event_hash)` | Status flags (Update/Delete) |
| `0x02` | KV State | `H(key)` or `H(key \|\| identity)` | `H(content)` |
Gate state lives in `0x02` with the `gate:` key prefix. Lifecycle state lives in `0x02` with the `lifecycle` key.
***
***
### Appendix: Removed Events
Four event types from the v1 spec are removed. Each is replaced by a more general mechanism already in the registry.
| Removed | Replacement | Rationale |
| ------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Move\_Self | Move with `operator: "Self"` | Self is a Context, not an event type. The node enforces `event.from == content.target` when the moves entry has `operator: "Self"`. No expressiveness lost. |
| Revoke\_Self | Revoke with `operator: "Self"` | Same pattern. Self-targeting is an operator constraint, not a separate event type. |
| Grant\_Push | Grant | The node detects P (Push) ops from the schema for the granted trait. If the trait has P ops and the Grant content includes an `endpoint` field, the node registers the push endpoint. No separate event type needed. |
| Force\_Move | (removed, no replacement) | Owner can define all necessary transitions in `moves`. State is an 8-bit enum (not a combinatorial bitmask), so Owner can always move anyone to any State by having the right moves entries. Force\_Move added no capability that moves entries cannot express. |
***
### Manifest Format
#### 5.1 Manifest Sections
A manifest declares all RBAC rules for an enclave. Ten sections:
| Section | Purpose | Entry format |
| ----------- | -------------------------- | ----------------------------------------------------------------------------------------------------- |
| `states` | App-defined States | Array of UPPER\_CASE names. Assigned enum values 1+ sequentially. |
| `traits` | App-defined traits | Array of `name(rank)` strings. Assigned bit positions 8+ sequentially. Lower rank = higher authority. |
| `readers` | Read access | `[{ type, reads }]`. Each entry declares read authority for one column. |
| `init` | Initial identities | `{ identity, state, traits[] }`. Bootstrap SMT at enclave creation. |
| `moves` | State transitions | `{ event, from, to, operator, ops }`. Optional `alias` and `gate`. |
| `grants` | trait assignment rules | `{ event (Grant/Revoke), operator[], scope[], trait[] }` |
| `transfers` | Atomic trait movement | `{ trait, scope[] }`. Operator MUST hold the trait. |
| `slots` | KV state (Shared/Own) | `{ event (Shared/Own), operator, ops, key }` |
| `lifecycle` | Enclave lifecycle | `{ event (Pause/Resume/Migrate/Terminate), operator, ops }` |
| `customs` | App-defined content events | `{ event, operator, ops }` |
#### 5.2 Section Details
**`readers`** — Column-oriented read declarations. Each entry is `{ type, reads, retention? }`:
* `type`: A declared State, trait, or Context.
* `reads`: Array of event type names this column gets R on, or `"*"` for all events.
* `retention` (OPTIONAL; State / trait entries only): `"current"` (default) or `"snapshot"`. Governs the temporal scope at which the node evaluates this reader against the requester's RBAC history. See §5.3 Reader Retention. A `retention` field on a `Context` reader (`Self`, `Sender`, `Public`) is a manifest validation error; Contexts are always evaluated per-event.
**`moves`** — Each entry authorizes one State transition for one operator.
**`grants`** — Each entry authorizes Grant or Revoke of specific traits, scoped to specific States.
* `operator`: Who can grant/revoke (array — Contexts, traits, or States).
* `scope`: Target State scope (array).
* `trait`: Which traits can be granted/revoked (array).
**`transfers`** — Each entry authorizes atomic movement of a trait from one identity to another.
* `trait`: Which trait can be transferred.
* `scope`: Target State scope (array).
* The operator is implicit: MUST hold the trait being transferred.
**`slots`** — KV state entries. Each entry requires a `key` field (lowercase, app-defined).
* `Shared`: Enclave-wide singleton per key. Last write wins.
* `Own`: Per-identity slot per key. Node enforces per-identity isolation.
* Protocol-reserved keys: `gate:*`, `lifecycle`. Apps cannot write to reserved keys.
**`lifecycle`** — Protocol-defined enclave lifecycle events. State stored in `Shared("lifecycle")`.
**`customs`** — App-defined content events (lowercase names). Standard `{ event, operator, ops }` entries.
#### 5.3 Reader Retention
A `readers` entry declares a *column* (a State or trait) that grants read authority on a set of event types. The `retention` field selects between two evaluation modes — declaring it makes the policy explicit instead of implicit.
| `retention` | When the column is required to be in the requester's bitmask | Effect when the requester loses the column |
| --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `"current"` (default) | At query time (the bitmask in the current SMT) | All access (historical and live) stops immediately. Behavior matches Implementations. |
| `"snapshot"` | At the seq of each event being read | Pre-loss events stay accessible; live tail stops at loss. Forward-secrecy on confidentiality is unaffected (it lives in the encryption plugins, not in `readers`). |
The default value of `retention` (when absent) is `"current"`.
The two modes deliberately commute with the encryption plugins ([`ratchet-pair`](/spec/app/plugins/ratchet-pair), [`mls-lazy`](/spec/app/plugins/mls-lazy)) — those rotate epochs on RBAC removal, so post-removal events are opaque to the removed identity regardless of which retention mode is in force. `retention` therefore governs ONLY whether the node serves pre-removal ciphertext to a former member; it never controls confidentiality.
Context readers (`Self`, `Sender`, `Public`) MUST NOT carry `retention`. They are evaluated per-event against `requester` identity at serve time (not over RBAC history); a `Sender` reader thus passes for any event the requester authored, regardless of their current or historical RBAC state in the enclave.
When multiple `readers` entries apply (union semantics — rbac-v2 §4 already declares effective-ops = ⋃ per-column-ops), each entry is evaluated under its own `retention`. A manifest MAY mix modes per event type:
```json
"readers": [
{ "type": "MEMBER", "reads": ["message", "reaction"], "retention": "snapshot" },
{ "type": "MEMBER", "reads": ["notice", "Pause", "Resume"], "retention": "current" }
]
```
Above: a kicked former member retains their conversational transcript (snapshot) but loses access to admin notices and lifecycle events (current).
**Examples.** A transcript-portable group (kicked members keep history):
```json
"readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ]
```
A security-sensitive private group (today's implicit default, now explicit — kicked = audit-trail amnesia):
```json
"readers": [ { "type": "MEMBER", "reads": "*", "retention": "current" } ]
```
The operational algorithm — how the node computes the served seq set per query, how subscriptions split into historical and live phases, and how close reasons signal loss of access — is normative in [`node-api.md` §Read Authorization](/spec/node/node-api#read-authorization).
#### 5.2.1 Naming Convention
* Protocol events: PascalCase (Move, Grant, Shared, Pause, etc.)
* App events: lowercase, optionally `_`-segmented for namespaced records (message, request, sent, reg\_identity, reg\_node, reg\_enclave, etc.)
* States: UPPER\_CASE (MEMBER, PENDING, BLOCKED, etc.)
* traits: lower\_case (admin, muted, etc.)
* Contexts: PascalCase (Self, Sender, Public)
* KV keys: lowercase (topic, profile, etc.)
The convention is non-normative — the kernel does not validate event-name format. Implementations MAY follow stricter local rules (e.g. enforce the regex `^[a-z][a-z0-9_]*$` for `customs` entries) but conformance does not require it.
#### 5.2.2 Common Entry Properties
Any entry in any section MAY include:
* `alias`: Optional. Names the entry for referencing (Gate keys, UI, logs, API).
* `gate`: Optional. `{ operator[] }` — declares who can toggle the gate. Requires `alias`. Gate state stored in `Shared("gate:")`. When gate is closed, the event is rejected before RBAC runs.
* `preserve`: Optional (`moves` only). If `true`, trait flags are kept after the Move. Default `false` (clear all traits).
***
***
### Validation Rules
1. **In and Out** — Every State in `states` MUST appear as `to` in at least one `moves` entry or be assigned in at least one `init` entry (in). Any State with no ops MUST also appear as `from` in at least one `moves` entry (out).
2. **No Stuck Traits** — Every trait in `traits` MUST have at least one assign path (Grant entry or Transfer entry) and at least one remove path (Revoke entry or Transfer entry). Traits only assigned in `init` are exempt from the assign path requirement.
3. **Valid Operators** — Every `operator` MUST be a declared State, declared trait, or a Context (Self, Sender, Public).
4. **Write and Reader Coverage** — Every event MUST have at least one writer authorization path with C ops, and read access MUST be covered by at least one matching `readers` entry. Operator R ops are OPTIONAL and only model profiles that deliberately encode read authority as an operation.
5. **Reserved Keys** — App-defined `slots` keys MUST NOT use reserved prefixes (`gate:`, `lifecycle`).
6. **Gate Requires Alias** — If an entry has `gate`, it MUST have `alias`.
7. **Valid Ranks** — Every trait must declare a rank via `name(N)` syntax. All rank values MUST be non-negative integers.
8. **Complete States** — Every State name referenced in the manifest (`moves` from/to, `grants` scope, `transfers` scope, `init` state) MUST be either declared in `states` or be OUTSIDER.
9. **Naming Convention** — Identifier strings declared in the manifest MUST conform to §5.2.1:
* State names: `^[A-Z][A-Z0-9_]*$`
* trait names (rank-stripped): `^[a-z][a-z0-9_]*$`
* `customs.event`: `^[a-z][a-z0-9_]*$` OR a known protocol event name (`Manifest` / `Grant` / `Revoke` / `Move` / `Transfer` / `Gate` / `Shared` / `Own` / `AC_Bundle` / `Pause` / `Resume` / `Terminate` / `Migrate` / `Update` / `Delete`)
* `slots.key`: `^[a-z][a-z0-9_]*$`
Nodes MUST reject Manifest events whose content fails this rule with `ProcessResult.invalidManifest`. The conformance reference impl is `Enc.Core.ManifestValidator.checkNamingConvention`.
Nodes MUST run all 9 rules on every Manifest event and reject the event (and not apply the manifest to enclave state) if any rule fails. The rejection result is `ProcessResult.invalidManifest`; implementations SHOULD log the specific rule violated.
***
***
### Example Manifest
This is the Group Chat enclave manifest from [group.md](/spec/app/enclaves/group). For other examples, see [personal.md](/spec/app/enclaves/personal) and [dm.md](/spec/app/enclaves/dm).
```json
{
"states": ["PENDING", "MEMBER", "BLOCKED"],
"traits": ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"],
"readers": [
{ "type": "MEMBER", "reads": "*" }
],
"moves": [
{ "event": "Move", "from": "OUTSIDER", "to": "PENDING", "operator": "Self", "ops": ["C"],
"alias": "applications", "gate": { "operator": ["owner", "admin"] } },
{ "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "Self", "ops": ["C"],
"alias": "auto_join", "gate": { "operator": ["owner"] } },
{ "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "admin", "ops": ["C"] },
{ "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] },
{ "event": "Move", "from": "PENDING", "to": "MEMBER", "operator": "admin", "ops": ["C"] },
{ "event": "Move", "from": "PENDING", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] },
{ "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "Self", "ops": ["C"] },
{ "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] },
{ "event": "Move", "from": "MEMBER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] },
{ "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }
],
"grants": [
{ "event": "Grant", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] },
{ "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
{ "event": "Grant", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] },
{ "event": "Revoke", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] },
{ "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
{ "event": "Revoke", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] },
{ "event": "Revoke", "operator": ["Self"], "scope": ["MEMBER"], "trait": ["admin"] }
],
"transfers": [
{ "trait": "owner", "scope": ["MEMBER"] }
],
"slots": [
{ "event": "Shared", "operator": "admin", "ops": ["C", "U"], "key": "topic" },
{ "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "topic" },
{ "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" },
{ "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" }
],
"lifecycle": [
{ "event": "Pause", "operator": "owner", "ops": ["C"] },
{ "event": "Resume", "operator": "owner", "ops": ["C"] },
{ "event": "Migrate", "operator": "owner", "ops": ["C"] },
{ "event": "Terminate", "operator": "owner", "ops": ["C"] }
],
"customs": [
{ "event": "message", "operator": "MEMBER", "ops": ["C"] },
{ "event": "message", "operator": "admin", "ops": ["D"] },
{ "event": "message", "operator": "muted", "ops": ["_C", "_U"] },
{ "event": "message", "operator": "dataview", "ops": ["P"] },
{ "event": "message", "operator": "Sender", "ops": ["U", "D"] },
{ "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] },
{ "event": "reaction", "operator": "MEMBER", "ops": ["C"] },
{ "event": "reaction", "operator": "Sender", "ops": ["D"] },
{ "event": "reaction", "operator": "muted", "ops": ["_C"] },
{ "event": "reaction", "operator": "BLOCKED", "ops": ["_D"] },
{ "event": "notice", "operator": "admin", "ops": ["C", "D"] },
{ "event": "rotate", "operator": "admin", "ops": ["C"] }
],
"init": [
{ "identity": "", "state": "MEMBER", "traits": ["owner", "admin"] }
]
}
```
#### Event-Operator Matrix
| Event | MEMBER | OUTSIDER | PENDING | BLOCKED | owner(0) | admin(1) | muted(2) | dataview(3) | Self | Sender |
| ----------------------- | ------ | -------- | ------- | ------- | -------- | -------- | -------- | ----------- | ---- | ------ |
| message | CR | | | \_U\_D | | D | \_C\_U | P | | UD |
| reaction | CR | | | \_D | | | \_C | | | D |
| notice | R | | | | | CD | | | | |
| rotate | R | | | | | C | | | | |
| Shared(topic) | R | | | | | CU | | P | | |
| Own(profile) | CR | | | | | | | | | U |
| Move(OUTSIDER, PENDING) | R | | | | | | | | C | |
| Gate(applications) | R | | | | C | C | | | | |
| Move(OUTSIDER, MEMBER) | R | | | | | C | | | C | |
| Gate(auto\_join) | R | | | | C | | | | | |
| Move(OUTSIDER, BLOCKED) | R | | | | | C | | | | |
| Move(PENDING, MEMBER) | R | | | | | C | | | | |
| Move(PENDING, OUTSIDER) | R | | | | | C | | | | |
| Move(MEMBER, OUTSIDER) | R | | | | | C | | | C | |
| Move(MEMBER, BLOCKED) | R | | | | | C | | | | |
| Move(BLOCKED, OUTSIDER) | R | | | | | C | | | | |
| Grant(muted) | R | | | | | C | | | | |
| Grant(admin) | R | | | | C | | | | | |
| Grant(dataview) | R | | | | C | | | | | |
| Revoke(muted) | R | | | | | C | | | | |
| Revoke(admin) | R | | | | C | | | | C | |
| Revoke(dataview) | R | | | | C | | | | | |
| Transfer(owner) | R | | | | C | | | | | |
| Pause | R | | | | C | | | | | |
| Resume | R | | | | C | | | | | |
| Migrate | R | | | | C | | | | | |
| Terminate | R | | | | C | | | | | |
Columns: States (MEMBER, OUTSIDER, PENDING, BLOCKED) → traits (owner, admin, muted, dataview) → Contexts (Self, Sender).
***
***
### Authorization Algorithm
The authorization algorithm determines whether an identity can perform a specific operation on a specific event type.
```
isAuthorized(identity_bitmask, event_type, operation, is_self, is_sender) → bool
1. Extract State from bitmask (bits 0-7).
2. Collect allowed ops:
a. State ops (from State column for identity's current State)
b. trait positive ops (OR across all held trait flags)
c. Self ops (if is_self == true: event.from == content.target)
d. Sender ops (if is_sender == true: event.from == original_event.from)
e. Public ops (always applies)
3. Collect denied ops:
negative ops from all sources (State _X, trait _X, Self _X, Sender _X, Public _X)
4. Compute effective:
effective = allowed − denied
5. Return: operation ∈ effective
```
No State gate. traits can extend any State with new capabilities. Safety against invalid State+trait combinations comes from operational constraints:
* Move clears all traits
* Grant validates target State (schema-defined)
#### 4.1 Deny Override
Negative ops always win over positive ops regardless of source. If the effective set has both C (from State) and \_C (from a trait, State, or Context), the result is: C is removed.
***
***
### Processing Pipeline
The full event processing pipeline, from submission to commitment:
```
1. Signature verification (is the event signed by the claimed identity?)
2. Duplicate check (has this event already been committed?)
3. Lifecycle check (is the enclave active, not paused/terminated?)
4. Gate check (is this event type/transition currently enabled?)
5. RBAC authorization (Section 4 algorithm)
6. Rank check (for AC events targeting another identity — see Rank Rule below)
7. Content validation (event-type-specific format checks)
8. State mutation (apply bitmask changes for AC events)
9. SMT update (write new bitmask, compute new root)
10. CT append (add event to the certificate transparency log)
11. Commit (finalize sequence number, return receipt)
```
**Rank Rule.** For any AC event (Move, Grant, Revoke) targeting another identity: if the operator holds any trait, and the target holds any trait, the operator's best rank (lowest rank number among held traits) MUST be strictly less than the target's best rank. If either party holds no traits, or the operator is Self-targeting, the rank check is skipped. Violation → reject (`RANK_INSUFFICIENT`).
***
***
### Event Processing
When reading a target's bitmask from SMT, if no leaf exists, treat the bitmask as `0x00` (OUTSIDER, no traits).
#### 8.1 Move
Changes an identity's State. Clears all traits. The core lifecycle operation.
**Event content**:
```json
{ "target": "", "from": "PENDING", "to": "MEMBER", "preserve": true }
```
The `preserve` field is a manifest entry selector, not a behavioral directive. The node matches the content's `from`, `to`, and `preserve` against the `moves` entries to find the authorizing entry. Two entries with the same `from→to` but different `preserve` values are distinct Moves.
**Processing**:
```
1. Resolve from/to State names to enum values.
2. Match against moves entries: find the entry where from, to, and preserve
all match. If no match → reject (UNAUTHORIZED).
3. Read target's current bitmask from SMT.
4. Verify: current State enum == from value.
If mismatch → reject (STATE_MISMATCH).
5. Compute new bitmask:
a. Set State enum (bits 0-7) to the "to" value.
b. If the matched moves entry has preserve: true → keep all trait flags.
Otherwise (default): clear all trait flags.
6. If entire bitmask == 0: delete SMT leaf.
Else: write new bitmask to SMT.
```
**Authorization**: Move events are authorized via the `moves` section of the manifest. Each entry specifies `from`, `to`, `operator`, `ops`, and optionally `preserve`. The content's `from`, `to`, and `preserve` fields together select the matching entry.
**Move matching**: State is an enum. The check is exact: `current_state == from`. trait bits are ignored. No CAS on the full bitmask — only the State enum is compared.
**Application payload**: Move content MAY carry additional application-defined fields alongside the required `target`, `from`, and `to` fields. The node does not interpret these fields — they are opaque payload for the application layer. Example: DM enclaves piggyback per-contact epoch key-establishment material on `Move(O→F)` via the [`ratchet-pair`](/spec/app/plugins/ratchet-pair) plugin (see [enclaves/dm.md](/spec/app/enclaves/dm)); Group enclaves piggyback MLS commits on admin-created membership Moves via the [`mls-lazy`](/spec/app/plugins/mls-lazy) plugin (see [enclaves/group.md](/spec/app/enclaves/group)).
#### 8.2 Grant
Adds a trait flag to an identity's bitmask.
**Event content**:
```json
{ "target": "", "trait": "admin" }
```
If the trait has P (Push) ops in the schema, the event includes an endpoint:
```jsonc
{ "target": "", "trait": "dataview", "endpoint": "https://..." }
```
**Processing**:
```
1. Resolve trait name to bit position (from manifest traits array).
2. Read target's current bitmask from SMT.
3. Verify: target's current State is in the allowed scope.
If not → reject (INVALID_STATE_FOR_GRANT).
4. Rank check (see §7 Rank Rule).
5. Set the trait bit: new_bitmask = current | (1 << trait_bit).
6. If trait has P ops in schema AND endpoint is provided: register push endpoint (operational state, not in SMT).
7. Write new bitmask to SMT (create leaf if it doesn't exist).
```
**Authorization**: Grant events are authorized via Grant schema entries. Each entry specifies `operator` (who can grant), `scope` (what States the target MUST be in), and `trait` (what traits can be granted).
**No separate `Grant_Push` event type.** The node detects P ops from the schema and handles endpoint registration as part of regular Grant.
#### 8.3 Revoke
Removes a trait flag from an identity's bitmask.
**Event content**:
```json
{ "target": "", "trait": "admin" }
```
**Processing**:
```
1. Resolve trait name to bit position.
2. Read target's current bitmask from SMT.
3. Rank check (see §7 Rank Rule).
4. Clear the trait bit: new_bitmask = current & ~(1 << trait_bit).
5. If new_bitmask == 0: REMOVE the leaf from the SMT (per smt.md §Zero Bitmask).
Else: write new_bitmask to the SMT leaf as 32-byte big-endian bytes.
```
Revoking a trait the target doesn't have is a no-op (bit was already `0`).
A zero-bitmask leaf MUST NOT exist in the SMT (smt.md §Zero Bitmask: "no roles" is semantically identical to "not in tree"). Implementations that write `0` instead of removing the leaf produce divergent SMT roots.
Self is allowed as operator for Revoke (voluntarily drop own trait).
#### 8.4 Transfer
Atomically moves a trait from one identity to another. The operator MUST hold the trait being transferred.
**Event content**:
```json
{ "target": "", "trait": "owner" }
```
**Processing**:
```
1. Verify operator holds the trait being transferred.
2. Verify target is not the operator (no self-transfer).
If same → reject (INVALID_TRANSFER_TARGET).
3. Read target's current bitmask from SMT.
4. Verify target does not already hold the trait.
If already held → reject (TRAIT_ALREADY_HELD).
5. Verify: target's current State is in the allowed scope.
If not → reject (INVALID_STATE_FOR_TRANSFER).
6. Clear trait bit on operator's bitmask.
7. Set trait bit on target's bitmask.
8. Write both bitmasks to the SMT — but if the operator's new bitmask is 0, REMOVE the operator's leaf instead (per smt.md §Zero Bitmask). The target side always writes (Transfer adds a trait, so the target's bitmask is non-zero by construction).
```
Every numbered step above is normative; the listed reject codes (`INVALID_TRANSFER_TARGET`, `TRAIT_ALREADY_HELD`, `INVALID_STATE_FOR_TRANSFER`) MUST be used verbatim.
**Authorization**: Transfer events are authorized via the `transfers` section. Each entry specifies `trait` (which trait can be transferred) and `scope` (which States the target MUST be in).
#### 8.5 Gate
Pre-RBAC check that enables/disables specific events at the enclave level. Gates are open by default.
**Event content**:
```json
{ "gate": "applications", "open": false }
```
**Processing**:
```
1. Verify submitter is an allowed operator for this gate (from manifest).
2. Update gate state in KV Shared: Shared("gate:") = open value.
```
Gate state is checked BEFORE the RBAC authorization algorithm. If a gated event type/transition is closed, the event is rejected before RBAC runs.
#### 8.6 AC\_Bundle
Atomic multi-event bundle. All events succeed or none apply.
**Event content**:
```json
{
"events": [
{ "event": "Move", "target": "", "from": "PENDING", "to": "MEMBER" },
{ "event": "Grant", "target": "", "trait": "admin" },
{ "event": "Grant", "target": "", "trait": "moderator" }
]
}
```
**Processing**:
```
1. For each event in order:
a. Validate against simulated intermediate state.
b. Apply to simulated state.
If any event fails → reject entire bundle.
2. If all pass: apply all changes atomically to SMT.
```
#### 8.7 Lifecycle Events
Lifecycle events control enclave-level state. They store their state in KV Shared (SMT namespace `0x02`) with the reserved `lifecycle` key.
##### Pause
Stops the enclave from accepting new events (except Resume).
**Event content:**
```json
{}
```
(Pause carries an empty content; the event type lives on the commit/event `type` field per spec.md §Commit Structure.)
**Processing:** `processPause(active) ≡ Sum.inr(paused)`; every other input state yields `Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). On success, the node sets `Shared("lifecycle") = "paused"` via `applyPause(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.paused))`.
##### Resume
Lifts a pause, restoring normal operation.
**Event content:**
```json
{}
```
(Resume carries an empty content; the event type lives on the commit/event `type` field per spec.md §Commit Structure.)
**Processing:** `processResume(paused) ≡ Sum.inr(active)`; every other input state yields `Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). On success, the node sets `Shared("lifecycle") = "active"` via `applyResume(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.active))`.
##### Migrate
Initiates migration of the enclave to a new sequencer node. Canonical content schema: see [`spec.md` §Migration](/spec/kernel/spec) — the authoritative shape is `{ new_sequencer, prev_seq, ct_root }`. Reproduced here for reference:
**Event content:**
```json
{
"new_sequencer": "",
"prev_seq": ,
"ct_root": ""
}
```
**Processing:** `processMigrate(active) ≡ Sum.inr(migrated)`; every other input state yields `Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). The node additionally MUST verify that `prev_seq` matches the highest seq finalized by the current sequencer and `ct_root` matches the CT root at that seq (see spec.md §Migration for forced-takeover semantics). On success, the node sets `Shared("lifecycle") = "migrated"` (terminal) via `applyMigrate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.migrated))`.
The `migrated` lifecycle state is terminal — no further events are accepted by the migrated sequencer.
##### Terminate
Permanently shuts down the enclave. Irreversible.
**Event content:**
```json
{}
```
(Terminate carries an empty content; the event type lives on the commit/event `type` field per spec.md §Commit Structure.)
**Processing:** `processTerminate(s) ≡ Sum.inr(terminated)` for every `s ∈ {active, paused, migrated}`, and `processTerminate(terminated) ≡ Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). On success, the node sets `Shared("lifecycle") = "terminated"` via `applyTerminate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.terminated))`.
##### Lifecycle Check
Lifecycle state is checked at step 3 of the processing pipeline (Section 7), before Gate and RBAC checks:
```
Lifecycle check → Gate check → RBAC authorization
```
| State | Accepts events? |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| active | All events |
| paused | Only Resume (from owner) |
| migrated | No events (terminal — sequencer has been replaced; subsequent activity belongs to the migrated sequence and is recorded in the new sequencer's CT) |
| terminated | No events |
#### 8.8 KV Events (Shared / Own)
Mutable key-value state stored in SMT namespace `0x02`. KV events are NOT content events — they form their own category. Authorization uses the same schema matrix as all other events.
##### 8.8.1 Problem
Content events are append-only (the CT is a log). To represent "current topic" or "my display name", apps would need to scan the CT for the latest event. KV events provide first-class mutable state with SMT inclusion proofs for the current value.
##### 8.8.2 Shared
Enclave-wide singleton. One slot per key per enclave. Last write wins.
**Event content:**
```json
{ "key": "topic", "value": "General Discussion" }
```
**SMT key:** `H(0x02 || key_name)`
**SMT value:** `H(content)`
Any actor with C on Shared (for matching key) can overwrite. History preserved in CT.
##### 8.8.3 Own
Per-identity slot. One slot per key per identity. Each identity can only write to their own slot.
**Event content:**
```json
{ "key": "profile", "value": { "display_name": "Alice", "status": "Available" } }
```
**SMT key:** `H(0x02 || key_name || commit.from)`
**SMT value:** `H(content)`
Per-identity isolation enforced by the node — the `from` in the SMT key is always `commit.from`. No identity field in content needed.
##### 8.8.4 Schema Representation
KV events use the standard schema format with an additional `key` field:
```json
{ "event": "Shared", "operator": "admin", "ops": ["C"], "key": "topic" }
{ "event": "Shared", "operator": "MEMBER", "ops": ["R"], "key": "topic" }
{ "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" }
{ "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" }
```
The `key` field constrains which KV slot the operator can access.
**Operations for KV:**
| Op | Meaning |
| -- | --------------------------------- |
| C | Create or overwrite the value |
| R | Read the current value |
| U | Update an existing value |
| D | Clear the value (remove SMT leaf) |
| P | Push delivery on value change |
| N | Notify on value change |
##### 8.8.5 Delete (D)
* **Shared:** Remove SMT leaf at `H(0x02 || key)`. Value cleared.
* **Own:** Remove SMT leaf at `H(0x02 || key || commit.from)`. Only the actor's own slot.
##### 8.8.6 Protocol-Reserved KV Keys
Gate state (Section 8.5) is stored as KV Shared with the `gate:` key prefix:
```
Shared("gate:applications") = true/false
```
Gate and lifecycle KV slots are protocol-managed — written by Gate and lifecycle events, not arbitrary Shared writes. Reserved keys: `gate:*` prefix and `lifecycle`.
##### 8.8.7 Bounding
No dynamic keys. Only keys declared in schema entries can be written:
* **Shared:** bounded by manifest (only declared keys)
* **Own:** bounded by RBAC (one slot per key per identity, identities controlled by Move)
##### 8.8.8 KV vs Content Events
| Aspect | Content Event | KV Event |
| -------- | -------------------- | ------------------------------------- |
| History | Full history matters | Only current value matters |
| SMT | No SMT entry | SMT leaf tracks current hash |
| Proofs | CT inclusion only | SMT inclusion proof for current state |
| Use case | Messages, reactions | Topic, settings, profiles, status |
Both live in the same CT. KV events maintain a "current state" view in SMT `0x02`.
##### 8.8.9 Read API
* Shared: `GET /enclave/{id}/kv/{key}`
* Own: `GET /enclave/{id}/kv/{key}/{identity}`
The SMT provides inclusion proofs for the current value hash.
***
## Sparse Merkle Tree (SMT)
This document specifies the Enclave State SMT: a 168-bit-deep sparse Merkle tree keyed by `namespace || sha256(raw_key)[0:20]`, storing RBAC bitmasks, event status, and KV state. The SMT commits to current enclave state and is bound into the CT log via the per-bundle `state_hash`.
***
### Table of Contents
1. [Tree Structure](#tree-structure)
2. [Namespaces](#namespaces)
3. [Key Construction](#key-construction)
4. [Leaf Values](#leaf-values)
5. [Hash Construction](#hash-construction)
6. [Proofs](#proofs)
7. [Collision Analysis](#collision-analysis)
8. [Performance](#performance)
9. [State-Changing Events](#state-changing-events)
10. [State Binding](#state-binding)
11. [Proof Binding (CT + SMT)](#proof-binding-ct-smt)
12. [Storage](#storage)
***
### Tree Structure
The following dimensions are normative; implementations MUST use these exact values.
| Property | Value |
| ---------------- | ------------------------------------------------------------------------ |
| Total depth | 168 bits (21 bytes) |
| Namespace prefix | 8 bits (1 byte) |
| Entry path | 160 bits (20 bytes) |
| Hash function | **Poseidon2 over BN254** (leaf/node hashing); SHA-256 for key derivation |
| Empty value | `sha256("")` (empty-subtree sentinel, retained) |
The 160-bit entry path matches Ethereum address length, providing the same collision safety properties.
***
### Namespaces
The 1-byte namespace prefix separates independent state domains:
| Namespace | Hex | Purpose |
| ------------ | ----------- | ------------------------------------------------------------------------------------ |
| RBAC | `0x00` | State + trait bitmask per identity |
| Event Status | `0x01` | Update / Delete tracking |
| KV State | `0x02` | Shared / Own application state (see [State-Changing Events](#state-changing-events)) |
| Reserved | `0x03–0xff` | Future use |
Namespaces `0x03` through `0xff` are RESERVED and MUST NOT be written by any current event.
***
### 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. Implementations MUST 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 SHA-256 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 the `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 =
```
RBAC bitmasks (State enum + trait flags packed per [`rbac/overview.md` §1.1](/spec/kernel/rbac)) MUST be encoded as **32-byte big-endian unsigned-integer byte strings**:
* Pad with leading zeros to exactly 32 bytes.
* Example: bitmask `0x100000002` → `0x0000.00100000002` (32 bytes).
These 32 bytes are the SMT leaf value verbatim — they MUST NOT be wrapped in CBOR before hashing. The leaf hash is `leaf_hash = H(0x20 || key || value)` where `value` is the 32-byte byte string directly. In JSON proofs (see [proof.md](/spec/kernel/ct)), the same 32-byte value is hex-encoded as 64 characters.
#### Zero Bitmask
When the bitmask reduces to `0` (State is OUTSIDER AND no trait flags set — see [`rbac/overview.md`](/spec/kernel/rbac)), the leaf MUST be **removed** from the SMT. An identity with bitmask `0` is semantically equivalent to "not in tree". This follows the same pattern as Event Status where "(not in tree) = Active".
A non-membership proof for an identity means "currently has bitmask 0 (OUTSIDER with no traits)"; it does NOT distinguish "never had any State / trait" from "had State / traits, all cleared." The SMT tracks current state only, not history. To prove historical assignment, use a CT inclusion proof for the originating Move or Grant event.
#### Event Status
The Event Status leaf values are normative; implementations MUST encode each status exactly as shown. The length difference unambiguously distinguishes Deleted from Updated.
| Value | Meaning |
| ------------------------------ | ------- |
| (not in tree) | Active |
| `0x00` (1 byte) | Deleted |
| `` (32 bytes) | Updated |
> **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:
>
> * Deleted: CBOR encodes as `0x40 0x00` (1-byte bytestring) or `0x00` (integer zero).
> * Updated: CBOR encodes as `0x58 0x20 <32 bytes>` (32-byte bytestring).
>
> This prevents any collision between deleted status and an `update_event_id` that happens to start with zeros.
#### Proof Interpretation
\| Proof type | SMT proof result | Interpretation |
\|---|---|
\| Event Status | Non-membership (null) | Active OR never existed |
\| Event Status | Membership (`0x00`) | Deleted |
\| Event Status | Membership (32 bytes) | Updated to this event |
\| RBAC | Non-membership (null) | OUTSIDER with no traits (or never had any State / trait) |
\| RBAC | Membership (32 bytes) | Has this State + trait bitmask |
To distinguish "Active" from "never existed" for events, combine with a CT inclusion proof. To prove historical State / trait assignment, use a CT inclusion proof for the originating Move / Grant event.
> **Distinguishing event outcomes.**
>
> For **Event Status proofs**, a non-membership proof (`null`) means:
>
> * The event is currently Active OR it never existed.
> * To distinguish: obtain a CT inclusion proof for the event.
> * If CT inclusion succeeds: event was created → currently Active.
> * If CT inclusion fails: event never existed.
>
> For **RBAC proofs**, a non-membership proof means:
>
> * Identity currently has bitmask `0` (OUTSIDER with no traits).
> * To prove historical State / trait assignment, obtain a CT inclusion proof for the originating Move / Grant event.
***
### Hash Construction
The hash constructions below are normative; implementations MUST use these exact domain separators and operand orders.
#### 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
```
Implementations MUST hardcode the empty-node constant and MUST NOT compute it at runtime.
***
### Proofs
SMT proof structure and verification are defined in [proof.md](/spec/kernel/ct).
***
### Collision Analysis
| Entries | Collision probability |
| ------------ | --------------------- |
| 1 billion | \~10⁻³⁰ |
| 1 trillion | \~10⁻²⁴ |
| 100 trillion | \~10⁻²⁰ |
Same security margin as Ethereum addresses.
***
### Performance
| Operation | Cost |
| ------------------ | --------------------- |
| Single SHA-256 | \~1 μs |
| SMT update | 168 hashes ≈ \~170 μs |
| Proof verification | 168 hashes ≈ \~170 μs |
| Throughput | \~6 000 updates / sec |
> Note: computation is always O(168) regardless of tree sparsity. Empty siblings are still hashed.
***
### State-Changing Events
Only certain events modify the SMT.
#### RBAC Namespace (`0x00`)
Each RBAC event MUST update the SMT exactly as specified below.
| Event | SMT Update |
| ---------- | ------------------------------------------------------------------------------------------------------------------ |
| Manifest | Initialize leaves from `init` entries (State + trait bits). |
| Move | Set State enum (bits 0-7); clear trait flags unless `preserve: true`. |
| Grant | Set trait bit for target identity; optionally registers push endpoint. |
| Revoke | Clear trait bit for target identity. |
| Transfer | Clear trait bit from operator, set trait bit on target (atomic). |
| Gate | `applyGate(tree, α, s) ≡ tree.insert(gateKVKey(α), s)`, where `gateKVKey(α) ≡ buildKVKey("gate:" ++ α)` in NS\_KV. |
| AC\_Bundle | Batch updates (atomic). |
#### KV State Namespace (`0x02`)
| Event | SMT Update |
| ------ | ---------------------------------------------------------------------- |
| Shared | `SMT[H(0x02 \|\| key)] = H(content)` — enclave-wide singleton |
| Own | `SMT[H(0x02 \|\| key \|\| identity)] = H(content)` — per-identity slot |
#### Event Status Namespace (`0x01`)
| Event | SMT Update |
| ------ | ---------------------------------------- |
| Update | `SMT[target_event_id] = update_event_id` |
| Delete | `SMT[target_event_id] = 0` |
> **Update chaining.** Multiple Updates to the same event are allowed. Each Update overwrites the previous value:
>
> * First Update: `SMT[event_A] = update_event_B`.
> * Second Update: `SMT[event_A] = update_event_C`.
> * Final state: `SMT[event_A] = update_event_C`.
>
> 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`.
#### Lifecycle Events (KV State Namespace `0x02`)
| Event | SMT Update |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pause | `applyPause(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.paused))` — writes byte `0x01` to the lifecycle slot. |
| Resume | `applyResume(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.active))` — writes byte `0x00`. |
| Terminate | `applyTerminate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.terminated))` — writes byte `0x02`. |
| Migrate | `applyMigrate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.migrated))` — writes byte `0x03` and binds the new sequencer / checkpoint (see [`spec.md` §9](/spec/kernel/spec)). |
Lifecycle events MUST write the same `Shared("lifecycle")` KV slot used by other Shared singletons (see [`spec.md` §6](/spec/kernel/spec) and [`rbac.md` §8.7](/spec/kernel/rbac)). Formally: `LIFECYCLE_KV_KEY = buildKVKey("lifecycle", none)`, and all four `applyPause / applyResume / applyTerminate / applyMigrate` target this key — only the encoded byte differs. The SMT carries the current lifecycle state directly — it is NOT derived from the event log.
#### Events that do NOT modify SMT
* Content events (e.g., a custom `message` event) MUST NOT modify the SMT directly.
***
### 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
The CT leaf hash construction below is normative.
```
leaf_hash = H(0x00, events_root, state_hash)
```
Where:
* `events_root` — Merkle root of event IDs in this bundle (see [spec.md](/spec/kernel/spec) for bundle structure).
* `state_hash` — SMT root AFTER the last event in this bundle is applied.
> **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:
>
> * Event log integrity (which events exist, in what order).
> * State integrity (what the SMT root was after each event).
>
> This simplifies migration and audit: a single `ct_root` value commits to the entire enclave history and state.
***
### Storage
* Implementations SHOULD store only non-empty nodes.
* Empty subtrees MUST be computed on-demand from the precomputed `empty_hash` constant.
* Historical states MAY be pruned.
***
### Enclave State SMT
The enclave maintains a **single Sparse Merkle Tree (SMT)** that stores both:
* **RBAC state** (namespace `0x00`) — identity → bitmask (State enum + trait flags).
* **Event status state** (namespace `0x01`) — event ID → status (active/updated/deleted).
* **KV state** (namespace `0x02`) — key → value hash (Shared/Own slots, lifecycle, gates).
**Design:**
The SMT uses:
* **Trimmed depth** (shorter than 256 bits) for efficiency
* **Flag bits** to distinguish entry types (RBAC vs Event Status)
*Implementation details (depth, flag encoding, proof format) are specified in [smt.md](/spec/kernel/smt).*
The SMT leaf/node hash is **Poseidon2 over BN254** (SNARK-native), which lets the node emit a **zero-knowledge validity proof** that a state transition `old_root → new_root` is valid under the RBAC rules — verifiable by a client in O(1) without replaying the log. This works either as a bounded per-transition proof or as a single **folded** (Nova IVC) proof attesting the enclave's entire *unbounded* transition history in O(1). See [zk.md](/spec/node/zk) → Folding.
**RBAC Entries:**
* **Path:** derived from `id_pub` (trimmed + flag)
* **Leaf value:** identity bitmask (State enum in bits 0-7, trait flags in bits 8+)
**Event Status Entries:**
* **Path:** derived from `event_id` (trimmed + flag)
* **Leaf value:** status + reference to U/D event
**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.
***
### SMT Proofs
SMT proofs verify state claims (RBAC assignments, event status) against the `state_hash`.
#### Proof Structure
```
{
key: <21 bytes>,
value: ,
bitmap: <21 bytes>,
siblings: [, ...]
}
```
Where:
* **key** — 21-byte SMT key (namespace + truncated path)
* **value** — leaf value (null for non-membership proof)
* **bitmap** — 168 bits indicating which siblings are present (1) vs empty (0)
* **siblings** — only non-empty sibling hashes, in order
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:
* Bitmap bit 0 = 1 (sibling at root level)
* Bitmap bit 10 = 1 (sibling at depth 10)
* Bitmap bit 167 = 1 (sibling at leaf level)
* All other bits = 0
Serialized as 21 bytes (168 bits), with bit 0 = LSB of byte 0. The `siblings` array contains exactly 3 hashes, in **deepest-first** order: index 0 is the sibling at depth 167 (leaf level), index 1 at depth 10, index 2 at depth 0 (root level). This ordering matches the verification walk (leaf-to-root), which consumes `siblings` front-to-back.Reference: `verify` / `_smtVerifyStep` and `prove` / `verify`.
**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:
* Byte 0 (containing bits 0-7) is the first two hex characters
* Byte 20 (containing bits 160-167) is the last two hex characters
Example: Depths 0, 10, 167 have siblings:
* Byte 0 = `0x01` (bit 0 set)
* Byte 1 = `0x04` (bit 10 = bit 2 of byte 1)
* Byte 20 = `0x80` (bit 167 = bit 7 of byte 20)
* Hex: `"010400.80"` (42 chars total)
#### 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.
***
### SMT Proof Wire Format (JSON)
```json
{
"k": "",
"v": "",
"b": "",
"s": ["", ...]
}
```
| Field | Encoding |
| ----- | -------------------------------------------------- |
| k | Hex string, 42 chars (21 bytes) |
| v | Hex string or JSON null (see Value Encoding below) |
| b | Hex string, 42 chars (21 bytes) |
| s | Array of hex strings, 64 chars each (32 bytes) |
**Value Encoding by Proof Type:**
| Proof Type | v Field |
| ---------------------- | ------------------------------------------------ |
| RBAC membership | Hex string, 64 chars (32-byte padded bitmask) |
| Event Status (deleted) | `"00"` (1 byte) |
| Event Status (updated) | Hex string, 64 chars (32-byte update\_event\_id) |
| Non-membership | `null` (JSON null, not string) |
## 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](/spec/kernel/rbac), the storage layer ([`smt.md`](/spec/kernel/smt), [`ct.md`](/spec/kernel/ct), [`zk.md`](/spec/node/zk)), enclave [migration](/spec/node/migration), the [node API](/spec/node/node-api), and the [enclave profiles](/spec/app/enclaves) / [confidentiality plugins](/spec/app/plugins).
***
### Table of Contents
1. [Protocol Summary](#protocol-summary)
2. [Trust Model](#trust-model)
3. [Enclave](#enclave)
4. [Node](#node)
5. [Client](#client)
6. [Signature Schemes](#signature-schemes)
7. [Hash Function](#hash-function)
8. [Hash Encoding](#hash-encoding)
9. [Hash Prefix Registry](#hash-prefix-registry)
10. [Identity Key](#identity-key)
11. [Delegated Sub-keys](#delegated-sub-keys)
12. [Wire Format](#wire-format)
13. [Commit](#commit)
14. [Event Finalization](#event-finalization)
15. [Event](#event)
16. [Receipt](#receipt)
17. [P (Push) and N (Notify)](#p-push-and-n-notify)
18. [U (Update) and D (Delete)](#u-update-and-d-delete)
19. [Event Categories](#event-categories)
20. [Predefined Event Type Registry](#predefined-event-type-registry)
21. [AC Events](#ac-events)
22. [Content Events](#content-events)
23. [Event Type Registry (Appendix)](#event-type-registry-appendix)
24. [Enclave Lifecycle](#enclave-lifecycle)
25. [Manifest](#manifest)
26. [Update](#update)
27. [Delete](#delete)
28. [Templates](#templates)
29. [Migration](#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.
**Data Access Methods:**
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`](/spec/kernel/ct))
***
### 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 `sequencer` field.
* Sequencer discovery is out of scope for the core protocol. The Registry enclave is one discovery mechanism (see [`enclaves/registry.md`](/spec/app/enclaves/registry)); other mechanisms (DNS, well-known URLs, bootstrap config) are equally valid.
**Responsibilities:**
* 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.
**Sequencer change:**
* See [`migration.md`](/spec/node/migration) for 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_priv` can 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:
```text
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](/spec/app/plugins#capability-matrix)) is **custody-conditional**:
* Under `local` custody: every plugin in the catalog is CT-replayable from `identity_priv` offline. The "NO" column on the `Post-compromise security vs. identity_priv` row reflects the protocol's accepted trust-root tradeoff.
* Under `delegated` custody: `identity_priv` is 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 | | s) | Default |
| `"ecdsa"` | ECDSA | SEC 1 v2 §4.1 + RFC 6979 | 64 bytes (r | | s compact) | For hardware / secure-element signers |
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).
**Deterministic Signing:**
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 `r` and `s` MUST be in the range `[1, n-1]` where `n` is 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 `from` field 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_pub` derivation.
#### 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:
1. **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.
2. **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.
3. **`alg` field integrity** — If `alg` is 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]))
```
**CBOR Encoding Rules:**
* 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 `tags` field 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.
**String Prefixes for Signatures:**
Signature contexts use string prefixes (not byte prefixes) for domain separation:
| Context | Prefix String |
| ------------- | ---------------- |
| STH signature | `"enc:sth:"` |
| Session token | `"enc:session:"` |
String prefixes are concatenated with data before hashing: `sha256(prefix || data)`.
***
### 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:
```json
{
"subkey": "<32-byte hex; sub-key's x-only public key>",
"parentId": "<32-byte hex; parent identity x-only public key>",
"expiry": 1706000000,
"ackSig": "",
"authSig": ""
}
```
| 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\|\|", 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\|\|"), parent_priv )`. 65 bytes (\`r | | s | | v`where`v`∈ {27, 28} or {0, 1} with EIP-1559 offset).`ecrecover`MUST yield a 33-byte compressed pub whose x-coordinate equals`parentId\`. The EIP-191 hash makes this signable by MetaMask without granting ENC any access to the parent private key. |
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:
```json
{
"hash": "",
"enclave": "",
"from": "",
"type": "",
"content": "",
"content_hash": "",
"exp": 1706000000000,
"tags": [...],
"cert": { subkey, parentId, expiry, ackSig, authSig },
"sig": "",
"alg": "schnorr"
}
```
* `commit.from` is the **parent identity** (the cert's `parentId`). This is what RBAC, `reg_identity` lookups, and CT membership are keyed to — the sub-key's existence is transparent to authorization at that layer.
* `commit.sig` is the SUB-KEY's Schnorr signature over `commit.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.alg` for 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:
1. `commit.cert.parentId` equals `commit.from` (case-insensitive hex).
2. `commit.cert.expiry > now()` (current wall-clock seconds).
3. `commit.cert.ackSig` is a valid Schnorr signature by `commit.cert.subkey` over `sha256("enc:subkey-ack:v1|" || parentId_hex || "|" || expiry_decimal)`.
4. `commit.cert.authSig` is a valid recoverable ECDSA signature over `eip191_hash("enc:authorize-subkey:v1|" || subkey_hex || "|" || expiry_decimal)`, AND the recovered uncompressed public key's x-only encoding equals `commit.cert.parentId`.
5. `commit.sig` is a valid Schnorr signature by `commit.cert.subkey` over `commit.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.
**CBOR Encoding (commit / event pre-image):**
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](/spec/kernel/smt) §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: ,
enclave: ,
from: ,
type: ,
content_hash: ,
content: ,
exp: ,
tags: [ , ... ],
alg: <"schnorr" | "ecdsa">,
sig:
}
```
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](#tags))
* **alg**: OPTIONAL signature scheme — `"schnorr"` (default if absent) or `"ecdsa"`. See [Signature Schemes](#signature-schemes). NOT bound in `hash` (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.
**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:
* 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.
**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:**
* **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.
```json
[
["", "", "", ...],
...
]
```
**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", ""]` on `dm:sent`, `["enclave_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.
**Example:**
```json
[
["auto-delete", "1706000000000"],
["r", "abc123...", "reply"]
]
```
**Predefined Tags:**
| Tag | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `r` | Reference — references another event. Format: `["r", "", ""]`. 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.
> **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:**
| 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 |
> **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 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:
1. **Expiration check** — reject if `exp` \< current time
2. **Deduplication check** — reject if commit `hash` was already processed (see [Replay Protection](#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:**
* Node maintains a set of **accepted** commit hashes per enclave
* Before accepting a commit, check if `hash` exists in the set
* If duplicate, reject the commit
* Only add hash to set AFTER successful acceptance
* Hashes MAY be garbage collected after `exp + 60000` ms (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 → reject
```
The protocol defines `MAX_EXP_WINDOW = 3600000` (1 hour in milliseconds). Implementations MAY use a shorter window.
This prevents storage DoS attacks where clients submit commits with extremely large `exp` values, forcing indefinite hash retention.
**Clock Skew Tolerance:**
The protocol tolerates bounded clock skew (±60 seconds) between client and sequencer:
| Component | Tolerance | Behavior |
| ------------------ | ---------------- | ----------------------------------------------- |
| Commit expiration | ±60 seconds | Accept commits up to 60s "early" (client ahead) |
| Hash deduplication | +60 seconds | GC buffer after `exp + 60000` ms |
| Bundle timeout | event timestamps | Uses event.timestamp, not wall clock |
| Session expiry | ±60 seconds | Node checks token expiry with tolerance |
Implementations SHOULD sync clocks via NTP and log warnings if skew exceeds 60 seconds.
***
### 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: ,
hash: ,
enclave: ,
from: ,
type: ,
content: ,
exp: ,
tags: [ , ... ],
timestamp: ,
sequencer: ,
seq: ,
alg: <"schnorr" | "ecdsa">,
sig: ,
seq_sig: ,
}
```
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
**Sequencer Continuity:**
For all events except Migrate (forced takeover), the `sequencer` field MUST match the current sequencer recorded in the Manifest. If a different key signs `seq_sig`, the event is invalid and clients MUST reject it. Exception: Migrate in forced takeover mode — the new sequencer finalizes the Migrate event (see Migration section).
**Field inheritance from Commit:**
The following fields are copied directly from the original Commit:
* `hash` — the commit hash (`Event.hash == Commit.hash`).
* `enclave`, `from`, `type`, `content`, `exp`, `tags`, `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: ,
hash: ,
timestamp: ,
sequencer: ,
seq: ,
sig: ,
seq_sig:
}
```
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 `enclave` field. 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](#signature-schemes). The receipt's `seq_sig` is always Schnorr (sequencer signature — see [§Sequencer and Server Signatures](#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
**N (Notify):**
* Node delivers a **lightweight notification** (metadata only, not content).
* Use case: Clients that want to know when to refresh, or services tracking enclave activity
**Registration:**
* 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 `Grant` event itself (`endpoint` field on the Grant content); the node registers it as operational state at Grant time. The Registry enclave is NOT consulted for P/N destinations.
**Transport:**
\::: 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. |
**P (Push) Retry Policy:**
* 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)
**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](#event-structure)). No wrapper is needed — the event already contains the `enclave` field.
**N (Notify) Payload:**
```json
{
"enclave": "",
"event_id": "",
"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)
**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:**
* 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.
**Gap Recovery:**
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.
**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:**
* 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.
**Event Status State:**
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](/spec/kernel/smt#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 Result | Interpretation |
| --------------------- | ---------------------------------- |
| Non-membership (null) | Event is Active OR never existed |
| `0x00` (1 byte) | Event was Deleted |
| `<32-byte id>` | Event was Updated to this event ID |
**Verification Flow:**
1. Call `POST /state` with `namespace: "event_status"` and `key: ` → 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:
* 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.
**Querying:**
* **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 |
**Key rules:**
* 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`](/spec/kernel/rbac).
***
### 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`](/spec/kernel/rbac).
#### 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 `customs` section (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.
```json
{
"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"
]
}
}
```
**Usage:**
To determine if an event is a Content Event:
```
is_content_event = type NOT IN any predefined_types category
```
> **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 for documentation purposes.
For event processing rules, see [`../rbac.md`](/spec/kernel/rbac) 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 && !paused
```
**Mutual 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.
**Lifecycle Events:**
| Event | Transition | Reversible |
| --------- | -------------------------- | ---------- |
| Pause | Active → Paused | Yes |
| Resume | Paused → Active | Yes |
| Terminate | Active/Paused → Terminated | No |
| Migrate | Active/Paused → Migrated | No |
**Behavior in Paused State:**
* 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).
**P/N Delivery in Paused State:**
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`](/spec/kernel/rbac)). Simplified example:
```json
{
"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": "", "state": "MEMBER", "traits": ["owner", "admin"] }
],
"meta": { "description": "a simple group" },
"bundle": { "size": 256, "timeout": 5000 }
}
```
*For production manifest examples, see the app specs: [Personal](/spec/app/enclaves/personal), [DM](/spec/app/enclaves/dm), [Group Chat](/spec/app/enclaves/group).*
**Manifest Content Canonicalization:**
Unlike Content Events, the node parses Manifest content as JSON for validation. For commit hash verification:
* Client sends `content` as UTF-8 JSON string
* Node hashes `content` byte-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.md`](/spec/kernel/rbac) for 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 `meta` field (as JSON) MUST NOT exceed 4 KB (4096 bytes). Nodes MUST reject Manifests with larger `meta` fields.
* **bundle** — Optional bundle configuration (size, timeout). If omitted, defaults apply.
**Schema Immutability:**
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
**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 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 |
**Bundle closing rules:**
* Close when `size` events accumulated, OR
* Close when `timeout` ms passed since bundle opened
* Whichever comes first
**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); 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: 1` for per-event state verification (no bundling).
**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 = 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.
> **Note:** Two Manifests with identical `from`, `content`, and `tags` produce the same enclave ID — 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.
**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`](/spec/app/enclaves/registry).
**Manifest exp Field:**
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:**
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. `states` is not a non-empty array of UPPER\_CASE strings
3. `traits` is not an array of `name(rank)` strings with valid non-negative integer ranks
4. `init` is empty or contains invalid entries (each MUST have `identity`, `state`, `traits[]`)
5. Any identity in `init` is not a valid 32-byte public key
6. Any State in `init` is not declared in `states`
7. Any trait in `init` is not declared in `traits`
8. Manifest fails any validation rule defined in [`rbac/manifest.md` §Validation Rules](/spec/kernel/rbac)
#### 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", ""]` |
#### Semantics
* The target event MUST be a Content Event (AC events cannot be updated).
* The target event (referenced by `r` tag) 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.
**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 `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 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
| Field | Value |
| ------- | ----------------------------------------- |
| type | `Delete` |
| content | JSON object (see below) |
| tags | MUST include `["r", ""]` |
> **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:**
| 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 `r` tag) 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.
**Self Evaluation Example:**
If the manifest customs section defines:
```json
{ "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:
```json
{ "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 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.
***
### 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 |
**v2 Scope:**
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`](/spec/app/enclaves).
***
### Migration
Enclave migration (the `Migrate` event, eager / lazy / fork modes, checkpoint verification, split-brain prevention, backup pattern) is specified in [`migration.md`](/spec/node/migration).
## Enclave Design Patterns
This document catalogs application-level **enclave design patterns** — Shared, Personal, Group Chat, and DM. The patterns are informative: the core protocol does not mandate any of them. They illustrate how the [RBAC v2](/spec/kernel/rbac) state/trait model and the [confidentiality plugins](/spec/app/plugins) compose into recognisable application archetypes.
***
### Table of Contents
1. [Shared Enclave](#shared-enclave)
2. [Personal Enclave](#personal-enclave)
3. [Group Chat](#group-chat)
4. [How to Choose](#how-to-choose)
5. [DM (Direct Message)](#dm-direct-message)
6. [Enclave Kind Metadata](#enclave-kind-metadata)
***
### Shared Enclave
A **Shared Enclave** is an enclave whose data and access-control are intended to be jointly used by **multiple identities**.
**Typical characteristics**
* **Multi-writer / multi-reader** by design
* RBAC is centered on **group roles** (e.g., Admin/Member/Moderator)
* Data is considered **collective** (not owned by a single identity)
**Examples**
* Group chat enclave
* A protocol-level registry/directory enclave
* DAO / project coordination enclave
* Shared public feed with moderators
### Personal Enclave
> For the full RBAC v2 design, see **[Personal Enclave](/spec/app/enclaves/personal)**.
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**
* A single "owner" identity is the **final authority** for RBAC and lifecycle
* Designed for **portable identity-scoped data** with a unified API
* Others can write *into* it (e.g., comments, reactions) but the enclave remains **owner-governed**
**Examples**
* A user's posts / profile / settings enclave
* A user's inbox / notifications enclave
* Personal "data vault" enclave used across apps
Confidentiality is split across two plugins: [`identity-aead`](/spec/app/plugins/identity-aead) seals owner-only `private` events (deterministic HKDF from `identity_priv`), and [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) seals cross-enclave inbound `notice` events (one-shot sender→recipient ECDH with optional handoff sub-encryption for group-invite bootstrap).
For the full RBAC v2 manifest, state transitions, and event-operator matrix, see **[Personal Enclave](/spec/app/enclaves/personal)**.
#### Registration
After creating a personal enclave, the owner can publish a discovery record so others can find it by public key. The Registry enclave is one such mechanism (see [`enclaves/registry.md`](/spec/app/enclaves/registry) → `reg_identity` with the `enclaves.personal` field). Other mechanisms (a well-known URL on the owner's domain, a custom directory enclave) are equally valid; the core protocol does not mandate Registry-based discovery:
```
id_pub → reg_identity → enclaves.personal → enclave_id → reg_enclave → node
```
### Group Chat
A **Group Chat Enclave** is a shared space for multi-party messaging. It uses the RBAC v2 State/trait model with PENDING, MEMBER, and BLOCKED states, plus owner/admin/muted/dataview traits. Multiple join paths (application, auto-join, direct invite), gated transitions, and trait-based moderation. Confidentiality is provided by the [`mls-lazy`](/spec/app/plugins/mls-lazy) plugin (lazy-MLS binary ratchet tree with O(log N) epoch distribution and an additive OR-wrap fallback for sub-key wallets).
For the full design pattern, see **[Group Chat Enclave](/spec/app/enclaves/group)**.
### How to Choose
Use **Shared** when:
* The enclave represents a **group object** (a room, project, DAO, registry)
* Governance and policy are **collective** or moderator-driven
* Many identities are **primary contributors**
Use **Personal** when:
* The enclave represents **identity-scoped state** (my profile, my posts, my inbox)
* You want data to be **portable across apps**
* One identity should remain the **owner-of-record**
### DM (Direct Message)
> For the full RBAC v2 design, see **[DM Enclave](/spec/app/enclaves/dm)**. Confidentiality is provided by the [`ratchet-pair`](/spec/app/plugins/ratchet-pair) plugin (per-contact epochs with a per-sender ratchet inside each epoch).
**DM** is a **sibling enclave kind** — a personal mailbox dedicated to direct messaging. It is distinct from the Personal Enclave (the identity-anchor enclave above) and lives behind its own `enclave_id`. When the [Registry enclave](/spec/app/enclaves/registry) is used for discovery, the DM enclave's id is published under `reg_identity.enclaves.dm`. Per [enclaves/dm.md](/spec/app/enclaves/dm), the DM enclave has its own RBAC schema (states `OWNER`, `FRIEND`, `BLOCKED`; customs `invite`, `message`, `sent`, `rotate`) — entirely separate from the Personal enclave's `public` / `private` / `notice` events.
Each identity therefore maintains TWO enclaves: a Personal (identity, profile, private docs, inbound notices) and a DM (incoming messages from contacts). Cross-enclave notices (e.g. group-invite) land in the Personal `notice` rail; per-contact messages land in the DM `message` rail.
**How it works**
* Alice sends a message event to **Bob's DM enclave**
* Bob replies by sending a message event to **Alice's DM enclave**
* Bob's client MAY also store a **local copy** of his outgoing reply in **Bob's own enclave**.
**Properties**
* Messages are **asymmetric by default** (each direction is a separate write)
* Each participant maintains a **complete local history** inside their own enclave
* Ownership and RBAC are enforced **per enclave**, independently
* No shared or mutually-owned log is required
**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"
### Enclave Kind Metadata
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.
## Encryption Plugins
ENC enclaves carry **opaque ciphertext** end-to-end — the node never sees plaintext, and the enclave's RBAC schema authorizes against roles, not crypto primitives. Confidentiality is therefore an **application-layer concern**: an app picks an *encryption plugin* whose wire format and key schedule it uses to seal event content and tags.
This directory is a **catalog of cryptographic shapes**, not an app dispatcher. Each plugin is named by what it *is* (the cryptographic mechanism), not by the first app that adopted it — so any new app spec can pick the right plugin from this index without renaming or forking.
Each plugin spec is the single source of truth for its wire-and-key contract — two compliant implementations MUST produce byte-identical ciphertexts on the wire given identical inputs.
***
### Table of Contents
1. [Catalog](#catalog)
2. [Capability matrix](#capability-matrix)
3. [Reference consumers (current apps)](#reference-consumers-current-apps)
4. [What each plugin spec contains](#what-each-plugin-spec-contains)
5. [How an app spec references a plugin](#how-an-app-spec-references-a-plugin)
6. [Versioning across the catalog](#versioning-across-the-catalog)
7. [Catalog Implementation Names](#catalog-implementation-names)
***
### Catalog
The catalog lists exactly `CATALOG_SIZE = 4` plugins, one per `PluginID` variant — `ratchet-pair`, `mls-lazy`, `identity-aead`, `ecdh-envelope` — each named by its cryptographic shape (`shapeOf .ratchet_pair = .pairwise`, `shapeOf .mls_lazy = .sharedSecret`, `shapeOf .identity_aead = .singleOwner`, `shapeOf .ecdh_envelope = .oneShotDirected`).
| Plugin | Cryptographic shape | One-line use case |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`ratchet-pair`](/spec/app/plugins/ratchet-pair) | Pairwise: ECDH-derived per-pair epoch + per-sender HKDF ratchet inside the epoch; additive OR-wrap for sub-key recipients. | Any 1:1 confidential thread between two identities (DMs, call setup, file drop, signed receipt). |
| [`mls-lazy`](/spec/app/plugins/mls-lazy) | Shared-secret: binary ratchet tree keyed to sorted member pubs (O(log N) per commit) + per-sender HKDF ratchet + additive OR-wrap fallback. | Any N-party shared confidentiality (group chat, forum, project room, channel). |
| [`identity-aead`](/spec/app/plugins/identity-aead) | Single-owner: deterministic HKDF from `identity_priv` + enclave id; no ECDH, no rotation. | Any event whose only reader is the owning identity (personal vault, per-app prefs, wallet backup). |
| [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) | One-shot directed: ECDH(sender, recipient) + AEAD; OPTIONAL inner sub-encryption for handoff. | Any one-shot sealed message from an outside writer to a single recipient (cross-enclave invite, drop-box submission, anonymous tip, addressed receipt). |
### Capability matrix
The matrix has `capabilityAxes.length = 10` rows (`{confidentiality_vs_node, per_message_key_isolation, per_sender_isolation, FS_epochs, FS_removal, backward_secrecy_add, multi_device, sub_key_wallet, stateless_decryption, PCS_vs_identity_priv}`).
| Capability | `ratchet-pair` | `mls-lazy` | `identity-aead` | `ecdh-envelope` |
| -------------------------------------------- | -------------- | ---------- | --------------- | -------------------- |
| Confidentiality vs. node | ✅ | ✅ | ✅ | ✅ |
| Per-message key isolation | ✅ | ✅ | nonce-only | nonce-only |
| Per-sender / per-counterparty isolation | ✅ | ✅ | n/a (single) | ✅ |
| Forward secrecy across epochs | ✅ | ✅ | NONE | NONE |
| Forward secrecy on removal | n/a | ✅ | n/a | n/a |
| Backward secrecy on add | ✅ | ✅ | n/a | n/a |
| Multi-device via `identity_priv` | ✅ | ✅ | ✅ | ✅ |
| Sub-key wallet support (OR-wrap) | ✅ | ✅ | n/a | ✅ (single recipient) |
| Stateless decryption | ✅ | ✅ | ✅ | ✅ |
| Post-compromise security vs. `identity_priv` | NO | NO | NO | NO |
**CT-replayability is custody-conditional.** Under **local** custody (the identity holds `identity_priv` in memory), every plugin in this catalog is CT-replayable from `identity_priv` — the protocol's accepted trust-root tradeoff, captured by the "NO" column above. Under **delegated** custody (the identity is gated by an external signer oracle: NIP-07, NIP-46, WebAuthn, KMS, HSM, threshold signer), `identity_priv` is unreachable to the client, so offline bulk re-derivation is impossible. Recovery in that case is **decrypt-on-demand**: one signer round-trip per ciphertext, consent-gated by the oracle. The capability matrix above and the multi-device row (`Multi-device via identity_priv`) restate that local-custody guarantee. No plugin in this catalog MAY assume offline bulk re-derivation; the identity model (`spec.md §Identity Custody`) carries the custody descriptor.
PCS-grade variants — those that escape the trust-root tradeoff — require an off-CT side-channel under either custody.
### Role × Suite
A plugin in this catalog is the pair `(role, suite)`. The **role** is the trust-shape (pairwise / single-owner / N-party / one-shot-directed); the **suite** is the concrete ciphersuite (AEAD + nonce length + KDF + wire framing) that lives above the shared `secp256k1` ECDH floor. Roles and suites are **orthogonal** — a role can run over multiple suites, and a suite can host multiple roles. The registry of suite ids lives in [`suites.md`](/spec/app/suites) — a cross-cutting companion doc at the spec root (like `smt.md` / `ct.md`), **not** a fifth entry in this role catalog.
Of the four catalog entries above, three use the `enc-xchacha-v1` suite (XChaCha20-Poly1305, 24-byte nonce) and one — `mls-lazy` — uses `mls-chacha-v1` (ChaCha20-Poly1305, 12-byte nonce per RFC 9420). A future Nostr-bridged pairwise plugin would be `(pairwise, nostr-nip44-v2)`; the role stays the existing one, the suite carries the externally-versioned wire shape.
| Catalog plugin | Role | Suite |
| --------------- | ---------------------- | ---------------- |
| `ratchet-pair` | pairwise | `enc-xchacha-v1` |
| `mls-lazy` | sharedSecret (N-party) | `mls-chacha-v1` |
| `identity-aead` | singleOwner | `enc-xchacha-v1` |
| `ecdh-envelope` | oneShotDirected | `enc-xchacha-v1` |
Suite authentication, no tag-sniffing, sender-side suite selection, and fail-closed behavior are defined in [`suites.md` §Downgrade resistance](/spec/app/suites#downgrade-resistance) and [`suites.md` §Peer capability advertisement](/spec/app/suites#peer-capability-advertisement).
### Reference consumers (current apps)
`referenceConsumers.length = 3` — `enclaves/dm.md` → `ratchet-pair`, `enclaves/group.md` → `mls-lazy`, `enclaves/personal.md` → `identity-aead` + `ecdh-envelope`.
| App | Plugin(s) | Covers |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| [`enclaves/dm.md`](/spec/app/enclaves/dm) | [`ratchet-pair`](/spec/app/plugins/ratchet-pair) | `invite`, `message`, `sent`, `rotate`, `Move(O→F)` epoch payload |
| [`enclaves/group.md`](/spec/app/enclaves/group) | [`mls-lazy`](/spec/app/plugins/mls-lazy) | `message`, `reaction`, `notice`, `rotate`, admin membership `Move` epoch payload |
| [`enclaves/personal.md`](/spec/app/enclaves/personal) | [`identity-aead`](/spec/app/plugins/identity-aead) + [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) | `private` (owner-only) + `notice` (cross-enclave inbound) |
Reference consumers exercise the wire-and-key contract, but the plugins themselves are **app-agnostic** — naming them after the first consumer was a historical accident, corrected by this catalog.
***
### What each plugin spec contains
Every plugin spec follows the same template so reviewers and reimplementers know exactly what to expect — `requiredSections.length = 10`:
1. **Role & status** — what cryptographic shape the plugin provides, its reference consumer(s), and its production-readiness.
2. **Primitives (§1)** — fixed concrete choices: AEAD, KDF, ECDH, CSPRNG, encoding (`primitiveCategories.length = 5`). No negotiation.
3. **Domain separators (§2)** — the exhaustive ASCII list. Anything not on this list is forbidden.
4. **Key schedule** — the full HKDF/ECDH chain producing each key used, with exact `info` strings and lengths.
5. **Wire format** — JSON shape on the wire for every event content the plugin touches, with byte-level encoding (hex/base64).
6. **State-machine concerns** — monotonicity, rotation, recovery, multi-key / OR-wrap fallback where applicable (`stateMachineConcerns.length = 4`).
7. **Error & rejection rules** — what makes a wire value invalid; normative MUST/MAY/SHOULD.
8. **Security properties** — MUST include an explicit YES/NO/OPTIONAL table for FS, PCS, multi-device, etc.
9. **Compliance vectors** — required KAT (known-answer test) categories that an implementation MUST publish.
10. **Versioning** — what triggers a `-v2` and what migration looks like.
***
### How an app spec references a plugin
App specs (`app/*.md`) MUST satisfy three normative requirements (`appSpecRequirements.length = 3`):
1. **Name the plugin(s)** they use for each event type that carries encrypted content or tags. The catalog above is the picker — choose by cryptographic shape, not by which app first used the plugin.
2. **Declare the app-side payload contract** — which event-content / tag fields the plugin's encryption applies to (e.g. "`message.content` is a `ratchet-pair` message envelope; `Move(O→F).content.epoch` is a `ratchet-pair` epoch wrap").
3. **State the security guarantees the app relies on** — minimum properties (multi-device, FS-on-removal, sub-key support, etc.) that the chosen plugin MUST provide. If the app's threat model needs a property the plugin lists as `NO`, the app must say so explicitly.
App specs MUST NOT duplicate the key schedule or wire format — those live here. A drift between an app spec and a plugin spec is a spec bug; the plugin spec wins.
***
### Versioning across the catalog
All plugin specs in this directory are **version `1`** (`CATALOG_VERSION = 1`). The set of v2 triggers has `v2Triggers.length = 7`: anything that changes a domain separator, the AEAD primitive / nonce length, the KDF, member-sort order, tree topology, or a wire field's encoding requires a new plugin file (`-v2.md`) and a new manifest negotiation hook. Versions are negotiated by manifest, never by tag-sniffing (`versionNegotiation = .byManifest`).
A v1 plugin's wire-level domain-separator prefixes are fixed (`v1DomainPrefixes.length = 5`: e.g. `enc:dm:*`, `enc:group:*`, `enc:mls:*`, `enc:personal:*`, `enc-personal-private:`). A v2 of any plugin MAY adopt a generic prefix (e.g. `enc:ratchet-pair:*`, given by `v2GenericPrefixFor`).
***
### Catalog Implementation Names
`referenceImpls.length = 4` — one row per catalog plugin: `ratchet-pair`, `mls-lazy`, `identity-aead`, `ecdh-envelope`.
The reference impls still use their historical slot / package names (`historicalImplNames.length = 5`: `DMCryptoFn`, `GroupCryptoFn`, `plugin-group-mls-lazy`, `PersonalPrivateCryptoFn`, `PersonalNoticeCryptoFn`). The plugin spec name is canonical going forward; implementations MAY add aliases for backward compatibility but new code SHOULD prefer the catalog name.
## Suites (Ciphersuite Registry)
### Table of Contents
1. [Purpose](#purpose)
2. [The shared ECDH floor](#the-shared-ecdh-floor)
3. [Registered suites](#registered-suites)
4. [Suite descriptor](#suite-descriptor)
5. [Wire binding](#wire-binding)
6. [Downgrade resistance](#downgrade-resistance)
7. [Peer capability advertisement](#peer-capability-advertisement)
8. [Adding a new suite](#adding-a-new-suite)
***
### Purpose
A **plugin** in this catalog is the pair `(role, suite)`. The **role** is the trust-shape — pairwise, single-owner, N-party, one-shot-directed — and the **suite** is the concrete ciphersuite that pins the AEAD, the nonce length, the KDF, and the in-circuit framing of the ciphertext above the ECDH floor.
Roles and suites are **orthogonal**. The same pairwise role runs over multiple suites today (`enc-xchacha-v1` in native DM and `nostr-nip44-v2` in Nostr-bridged DM). The same suite slot can host different roles (a pairwise channel and a one-shot directed envelope can both use `enc-xchacha-v1`). The registry below is what makes that orthogonality concrete: a plugin spec says `"role: pairwise, suite: enc-xchacha-v1"`, and the wire envelope carries the suite id as an authenticated field.
Before this registry, the plugin catalog welded suite into role — every plugin opened with a fixed *§Primitives (no negotiation)* table — and the only way to switch a suite was to fork the whole plugin. The mls-lazy plugin's explicit `not XChaCha20` rule (ChaCha20-Poly1305 with 12-byte nonce, per RFC 9420) is the first evidence the catalog already needed factoring; NIP-44 is the second.
### The shared ECDH floor
Every registered suite operates above the same ECDH primitive:
* Curve: `secp256k1`
* Shared output: the 32-byte x-coordinate of the compressed point (`x_only_secret = compressed[1..33]`)
* x-only public-key inputs (32 bytes) are extended with the `0x02` prefix per BIP-340 even-y convention.
This is what makes suites swappable. A pairwise role gets the same `x_only_secret` whether it is wrapped under `enc-xchacha-v1` or `nostr-nip44-v2`; only the KDF and AEAD above the shared floor differ. New suites that introduce a different curve are out of scope of THIS registry and require a separate `enc-curve-vN` registry.
### Registered suites
| Suite id | AEAD | Nonce | KDF | Wire framing | Source / notes |
| ---------------- | ------------------------------------------ | -------- | ---------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| `enc-xchacha-v1` | XChaCha20-Poly1305 (libsodium) | 24 bytes | HKDF-SHA-256, `salt = b""`, `info` = ASCII domain separator | `base64( nonce(24) ‖ ct+tag )` | ENC native pairwise / single-owner / one-shot. Used by `ratchet-pair`, `ecdh-envelope`, `identity-aead` |
| `mls-chacha-v1` | ChaCha20-Poly1305 (RFC 8439) | 12 bytes | HKDF-SHA-256, `salt = SHA-256 zero block`, `info` = ASCII domain | `hex( nonce(12) )` ‖ `hex( ct+tag )` | MLS-style N-party. Used by `mls-lazy`. Matches RFC 9420 conventions (explicitly NOT XChaCha20) |
| `nostr-nip44-v2` | ChaCha20 + HMAC-SHA-256 (encrypt-then-MAC) | 32 bytes | HKDF-SHA-256, `salt = b"nip44-v2"`, `info` = `b""` | `base64( v ‖ nonce(32) ‖ ct ‖ tag )`, `v = 0x02` | Nostr NIP-44 v2. Externally versioned by Nostr; the suite id pins to v2 explicitly. |
Implementations MUST recognize `enc-xchacha-v1` as a registered suite id.
Implementations MUST recognize `mls-chacha-v1` as a registered suite id.
Implementations MUST recognize `nostr-nip44-v2` as a registered suite id.
Each suite id is a stable string. Future revisions of an existing suite (e.g. a NIP-44 v3) get a NEW suite id (`nostr-nip44-v3`), not a mutation of the existing one. The id is the unit of versioning.
### Suite descriptor
The normative descriptor for a suite is the structured record:
```yaml
id: nostr-nip44-v2
aead: chacha20-poly1305 # bytes of ct + 16-byte Poly1305 tag (or 32-byte HMAC for v2)
nonce_len: 32 # bytes
kdf: hkdf-sha256
kdf_salt: "nip44-v2" # ASCII; b"" when empty; SHA-256 zero block when "zero"
kdf_info: ""
ecdh_curve: secp256k1
wire_framing: base64(v || nonce || ct || tag)
external_version: { spec: "NIP-44", revision: "v2" } # optional
```
The id MUST be lowercase ASCII, MAY use `-` separators, and MUST NOT use `_` or whitespace. Suite-id uniqueness is global; a suite intended for one role can be reused under another role unchanged.
### Wire binding
Every encrypted wire envelope MUST carry the suite id as an **authenticated** field. The suite id is the discriminator the receiver uses to dispatch the decryption path; if it is not authenticated, an attacker who replaces only the suite id can force the receiver into a weaker dispatch (downgrade — see §[Downgrade resistance](#downgrade-resistance)).
Authenticated can mean: (a) signed alongside the ciphertext under a credential the receiver verifies, (b) prefixed to the ciphertext and bound through the AEAD's associated-data input, or (c) tagged through HMAC under the shared KDF output. The choice is suite-local; the requirement is that the suite id and the ciphertext succeed or fail TOGETHER.
The receiver MUST NOT tag-sniff (guess the suite from the ciphertext layout) and MUST NOT accept a suite id that is not in the receiver's locally-validated capability set for the sender.
### Downgrade resistance
A downgrade attack is: an active attacker takes a ciphertext encrypted under suite `nostr-nip44-v2` and presents it to the receiver with the suite id rewritten to `enc-xchacha-v1` (or any weaker suite). If the receiver's dispatch accepts a fresh suite id without authentication, the attacker has substituted a path the sender did not consent to.
Normative requirement:
* Suite-id authentication follows §[Wire binding](#wire-binding).
* A receiver MUST reject any envelope whose suite id is not present in the sender's advertised capability set (per §[Peer capability advertisement](#peer-capability-advertisement)).
* Receiver tag-sniffing is forbidden by §[Wire binding](#wire-binding).
### Peer capability advertisement
Senders MUST NOT probe send-side suite selection from client-local heuristics (e.g., browser `localStorage`). The protocol exposes peer capabilities through the `reg_identity` registry: an identity's `reg_identity` content MAY list a `suites` array of suite ids the identity accepts.
```json
{
"id_pub": "",
"suites": ["enc-xchacha-v1", "nostr-nip44-v2"]
}
```
A sender's suite selection process:
1. Resolve the recipient's `reg_identity` (cached or freshly fetched).
2. Intersect the recipient's `suites` with the sender's own capability set.
3. Pick the highest-preference suite in the intersection (sender-local preference order).
4. If the intersection is empty, the sender MUST fail closed — do NOT pick a default.
A sender's preference order is a local policy choice (not protocol). The intersection rule and the fail-closed-on-empty rule are normative.
When a recipient's `reg_identity` does not declare a `suites` array, the recipient MUST be assumed to support `enc-xchacha-v1` only. Bridges (Nostr in particular) advertise the bridged suite (`nostr-nip44-v2`) by publishing a `reg_identity` with that suite listed.
### Adding a new suite
A new suite is added by:
1. Defining a new entry in §[Registered suites](#registered-suites) with the full descriptor.
2. Specifying the wire framing and the authenticated-id binding.
3. Bumping any plugin spec that opts into the new suite (the plugin's spec lists which suite ids it accepts; an existing plugin doc remains valid for its current suite set).
4. Conformance vectors: a per-suite KAT (known-answer test) directory under `claims/vectors/suites//*.json` with at least one encrypt and one decrypt vector exercising the ECDH floor and the AEAD.
Adding a curve below the ECDH floor is OUT of scope; that requires a separate `enc-curve-vN` registry coordinated with this catalog.
## Plugin: `ecdh-envelope`
**Role:** One-shot sender→recipient confidentiality. The sender derives a single AEAD key from `ECDH(sender_priv, recipient_op_pub)` and seals the message in a self-contained envelope. An optional inner sub-encryption under a distinct domain separator carries a secondary handoff secret (e.g. a group's bootstrap epoch). No ratchet, no rotation, no shared state.
**Reference consumer:** [`enclaves/personal.md`](/spec/app/enclaves/personal) — confidentiality for the `notice` content event (cross-enclave addressed messages: group invites, DM invites, future kinds). Any future enclave that takes one-shot writes from external identities and needs to seal them to the owner (drop-box submissions, anonymous tips, signed acknowledgments, file uploads addressed to a recipient) is a candidate consumer.
**Slot type (suggested):** `EcdhEnvelopeCryptoFn` (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses `PersonalNoticeCryptoFn`).
The most important use today is **group invitations** — the sender pairs a `Move(OUTSIDER→MEMBER)` in the source group with an envelope write into the invitee's Personal enclave carrying the group's current `root_secret` so the invitee can decrypt the group's CT without first joining a side-channel.
This document is the **complete wire-and-key contract**. Two compliant implementations MUST agree byte-for-byte on the envelope and the handoff payload for any input set.
***
### Table of Contents
1. [1. Primitives (fixed; no negotiation)](#primitives-fixed-no-negotiation)
2. [2. Domain separators (exhaustive)](#domain-separators-exhaustive)
3. [3. Outer envelope (content only)](#outer-envelope-content-only)
4. [4. Payload field contract](#payload-field-contract)
5. [5. Inner `handoff` sub-encryption (group-invite path secret)](#inner-handoff-sub-encryption-group-invite-path-secret)
6. [6. Cross-enclave reconciliation (recipient client behavior)](#cross-enclave-reconciliation-recipient-client-behavior)
7. [7. Error & rejection rules (normative)](#error-rejection-rules-normative)
8. [8. Security properties](#security-properties)
9. [9. Compliance vectors (required in plugin tests)](#compliance-vectors-required-in-plugin-tests)
10. [10. Versioning](#versioning)
***
### 1. Primitives (fixed; no negotiation)
The primitives below are normative; implementations MUST use the concrete choice specified for every row.
| Primitive | Concrete choice |
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
| AEAD | XChaCha20-Poly1305 — **24-byte nonce, 16-byte Poly1305 tag** |
| KDF | HKDF-SHA-256 — `salt = b""` (empty), `info` = ASCII domain separator, `L = 32` |
| ECDH | secp256k1; `shared = 32-byte x-coordinate`; x-only inputs extended with `0x02` per BIP-340 even-y convention |
| CSPRNG | `globalThis.crypto.getRandomValues` |
| Pubkey encoding | lowercase hex, 32 bytes x-only |
| Outer envelope encoding | `{ ciphertext: , nonce: }` — **two separate hex fields** (not combined base64) |
| `handoff` entry encoding | `{ recipient, ecdh_pub, ciphertext, nonce }` — all lowercase hex |
***
### 2. Domain separators (exhaustive)
Each line below is a normative HKDF `info` literal; implementations MUST emit and accept the case-sensitive ASCII bytes verbatim, and the two MUST differ — a leaked outer-envelope key MUST NOT decrypt a `handoff` and vice versa.
```
enc:personal:notice # outer envelope key (tags + content)
enc:personal:notice:epoch # inner sub-encryption for `handoff` (group-invite path-secret)
```
No other separators are used by this plugin.
***
### 3. Outer envelope (content only)
The `notice` event's `content` is sealed end-to-end from the sender to the Personal-enclave OWNER. The node sees an opaque envelope object (JSON-stringified) — it never sees the plaintext payload. The event's `tags` MUST NOT be sealed by this plugin; they remain the protocol-standard `[[name, value, …], …]` array-of-strings shape (see [`spec.md` §Tags](/spec/kernel/spec)) and MAY carry plaintext routing values.
#### 3.1 Key derivation
The outer `envelope_key` MUST be derived by the code block below.
```
shared = ECDH(sender_op_priv, recipient_op_pub) # 32 bytes
envelope_key = HKDF(IKM=shared, info="enc:personal:notice", L=32)
```
`sender_op_priv` is the sender's **operating private key**: the parent `identity_priv` for an ECDH-capable wallet, or the deterministic `sub_priv` for an ECDH-incapable wallet (notably MetaMask). The matching `sender_op_pub` is published on the wire so the receiver can derive the same shared secret.
`recipient_op_pub` is the OWNER's published **operating key**: their [`reg_identity.sub_pub`](/spec/kernel/spec) when present, otherwise their `id_pub`. Senders MUST consult Registry first; only if the recipient has no distinct sub-key published do they target the parent.
#### 3.2 Envelope formation (sender)
The notice's structured payload is JSON-encoded to a single utf-8 string, then AEAD-sealed once. There is no separate tags/content split inside the AEAD — the whole payload goes through one encryption pass. Envelope formation MUST follow the code block below verbatim (field names, literal `scheme` value, literal boolean `encrypted`, nonce length).
```
plaintext = utf8(JSON(payload)) # see §4 for `payload` shape
nonce = CSPRNG(24)
ct = XChaCha20-Poly1305(envelope_key, nonce, plaintext)
envelope = {
"ciphertext": hex(ct),
"nonce": hex(nonce),
"sender_pub": hex(sender_op_pub),
"scheme": "personal:notice",
"encrypted": true
}
notice.content = JSON.stringify(envelope) # placed in the commit's `content` UTF-8 string
notice.tags = [["enclave_id", ""], ...] # OPTIONAL plaintext routing tags
```
#### 3.3 Wire fields on the `notice` event
```jsonc
{
// protocol commit envelope (clear-text — see spec.md §Commit Structure):
"type": "notice",
"from": "", // identical to envelope.sender_pub
"content": "", // the JSON-encoded envelope object below
"tags": [ ["", "", ...], ... ], // protocol-standard, plaintext
// ... (hash, exp, alg, sig as usual)
}
```
The `content` string parses to:
```jsonc
{
"ciphertext": "",
"nonce": "",
"sender_pub": "", // the ECDH peer pub the receiver consumes
"scheme": "personal:notice", // discriminator so a generic decryptor can dispatch
"encrypted": true // sentinel for clients (plaintext bypass guard)
}
```
The receiver MUST process the wire envelope by the code block below; in particular, it MUST derive `envelope_key` using `env.sender_pub` from the parsed wire envelope, not `commit.from`.
```
env = JSON.parse(notice.content)
shared = ECDH(my_op_priv, fromHex(env.sender_pub))
envelope_key = HKDF(shared, "enc:personal:notice", 32)
payload = JSON.parse(utf8_decode(XChaCha20-Poly1305(envelope_key, fromHex(env.nonce)).decrypt(fromHex(env.ciphertext))))
```
The OWNER tries each of their own operating keys (parent and sub when distinct) — the sender chose one; the receiver tries each. With a single key, only one trial is needed.
`sender_pub` is included explicitly because, for sub-key wallets, it MAY differ from the commit's `from` field (the sender encrypts with whichever op-key it controls; `from` reflects the signing key). Receivers MUST use `env.sender_pub` for ECDH, not `commit.from`.
***
### 4. Payload field contract
The plaintext sealed inside the envelope is a single JSON object. The plugin defines the fields it MUST carry; applications MAY add their own fields under an `x-` prefix.
```jsonc
{
"kind": "group_invite" | "dm_invite" | "x-", // REQUIRED — discriminator
"enclave_id": "", // REQUIRED — hex64; the enclave the notice refers to
"enclave_kind": "group" | "dm" | "", // REQUIRED — manifest hint
"inviter": "", // REQUIRED — identity that produced the addressed action
"topic": "", // OPTIONAL — group display name (for group_invite)
"greeting": "", // OPTIONAL — human-readable message
"manifest_hash": "", // OPTIONAL — sha256 of the source enclave's Manifest event content
"move_ref": "", // OPTIONAL — for client-side cross-verification
"handoff": { ... PathSecretEntry ... }, // OPTIONAL — present for kind == "group_invite"; see §5
"epoch_n": // REQUIRED iff handoff is present — the source enclave's epoch.n that the handoff secret was wrapped at
}
```
| Field | Requirement |
| -------------- | ------------------------------------------------------ |
| `kind` | REQUIRED — discriminator |
| `enclave_id` | REQUIRED — hex64; the enclave the notice refers to |
| `enclave_kind` | REQUIRED — manifest hint |
| `inviter` | REQUIRED — identity that produced the addressed action |
| `epoch_n` | REQUIRED iff `handoff` is present |
The payload MUST include `kind`, `enclave_id`, `enclave_kind`, and `inviter`. When `handoff` is present, `epoch_n` MUST also be present.
Unknown `kind` values MUST be passed through (forward-compat). Required fields MUST be present and validated; missing required fields → notice rejected as malformed.
Applications MAY also surface a subset of these fields via `notice.tags` for plaintext routing — e.g. `["enclave_id", ""]`, `["enclave_kind", "group"]`. Plaintext tags are useful when a relay or indexer needs to route notices without decrypting; they are NOT a security boundary and MUST match the sealed payload when both are present.
***
### 5. Inner `handoff` sub-encryption (group-invite path secret)
For `kind: "group_invite"`, the `handoff` field carries the group's `root_secret` for the epoch the inviter committed (32 bytes), sealed with a **distinct** ECDH-derived key. The receiver derives `epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)` per [`mls-lazy.md` §4](/spec/app/plugins/mls-lazy#4-node-key-derivation). The domain separator differs from §3 so a leaked outer-envelope key cannot replay against a `handoff`, and vice versa.
The payload's REQUIRED `epoch_n` field (§4) records which `epoch.n` this `root_secret` belongs to — the receiver MUST use it both to verify the originating Move (§6) and to index the recovered secret in their per-group epoch map.
#### 5.1 Sender side
Handoff formation MUST follow the code block below verbatim (`dist_key` derivation, nonce length, handoff field set); the sender MUST also set `payload.epoch_n` to the `epoch.n` the committer wrote.
```
root_secret =
shared = ECDH(committer_priv, recipient_op_pub) # 32 bytes
dist_key = HKDF(shared, "enc:personal:notice:epoch", L=32)
nonce = CSPRNG(24)
ct = XChaCha20-Poly1305(dist_key, nonce).encrypt(root_secret)
handoff = {
recipient: lowercase_hex(recipient_op_pub),
ecdh_pub: lowercase_hex(committer_pub),
ciphertext: hex(ct),
nonce: hex(nonce)
}
# also set payload.epoch_n =
```
`committer_priv / committer_pub` is the inviter's identity — the admin who issued the `Move(OUTSIDER→MEMBER)` in the source group. **Backward-compatibility:** when `recipient_op_pub == recipient_id_pub` (no sub-key), a single handoff suffices. When they differ, the inviter SHOULD address the **operating key** the recipient is currently signed in with; conservative implementations MAY emit one handoff per published recipient op key, embedded as an `epoch_or_wraps`-style array. **This plugin's reference impl uses a single addressed entry**; multi-recipient variants are out of scope here (handle at the application layer if needed).
#### 5.2 Receiver side
After decrypting the outer envelope (§3) the OWNER parses the payload (§4), finds `handoff` (when present), and:
```
if handoff.recipient != lowercase_hex(my_op_pub):
skip # not addressed to my operating key
shared = ECDH(my_op_priv, fromHex(handoff.ecdh_pub))
dist_key = HKDF(shared, "enc:personal:notice:epoch", 32)
secret = XChaCha20-Poly1305(dist_key, fromHex(handoff.nonce)).decrypt(fromHex(handoff.ciphertext))
# `secret` MUST be 32 bytes; feed into plugins/mls-lazy.md §4 to derive epoch_secret
```
The receiver MUST skip handoffs whose `recipient` field does not equal lowercase hex of any of their operating keys. The recovered handoff plaintext MUST be exactly 32 bytes.
The recovered secret is the group's `root_secret` at the epoch when the envelope was written. The receiver derives `epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)` (per [`mls-lazy.md` §4](/spec/app/plugins/mls-lazy#4-node-key-derivation)) and stores it in their per-group epoch map. From then forward, normal MLS epoch ratcheting (subsequent `Move` / `rotate` events the recipient sees once they are subscribed to the group) supersedes this bootstrap secret.
#### 5.3 Bootstrap-only contract
The handoff is **bootstrap-only**: it seeds the recipient's first known group epoch. All subsequent group epochs reach them through the group's normal in-CT mechanism ([`plugins/mls-lazy.md`](/spec/app/plugins/mls-lazy) §5 — `encrypted_path_secrets` and §6 `epoch_or_wraps`). The handoff is not re-emitted on future rotations.
***
### 6. Cross-enclave reconciliation (recipient client behavior)
A `notice` is a **claim** — the inviter might not actually have moved the recipient into the source enclave, or the source enclave might not exist. Before surfacing the notice as actionable, the recipient's client MUST:
1. Parse the wire-level envelope from `notice.content` (§3.3) and decrypt it under the receiver's operating key(s).
2. Parse the recovered plaintext as JSON; validate the payload field contract (§4).
3. Look up the source enclave by `payload.enclave_id` via [`reg_enclave`](/spec/node/node-api).
4. Subscribe to the source enclave and **verify the originating action exists** in its CT:
* For `kind: "group_invite"`: search for `Move(OUTSIDER → MEMBER)` targeting the recipient by the inviter (`event.from == payload.inviter`) at or before the bundle whose `epoch.n` matches `payload.epoch_n`.
5. If the originating action is present → surface as actionable.
6. If absent → treat as pending; retry verification within a tolerance window (default 24 h). On timeout, silently discard via `OWNER:D`.
This client-side check is the spec's primary defense against forged notices.
***
### 7. Error & rejection rules (normative)
An implementation MUST:
1. Reject the notice (drop) when `notice.content` is not valid JSON or is missing the envelope shape (`{ciphertext, nonce, sender_pub, scheme: "personal:notice", encrypted: true}`).
2. Reject the notice when AEAD decryption fails for every candidate operating key the receiver holds.
3. Reject when the recovered payload is not valid JSON, or is missing any of `kind`, `enclave_id`, `enclave_kind`, `inviter`.
4. Reject when `kind == "group_invite"` and `epoch_n` is absent.
5. Reject `handoff` (treat as missing) when `handoff.recipient` does not match any of the owner's operating keys.
6. Reject `handoff` decryption when the recovered plaintext is not exactly 32 bytes.
7. **Not** treat `handoff` decryption failure as a notice rejection — surface the notice as actionable text-only and let the cross-enclave verification (§6) catch a forged invite.
***
### 8. Security properties
| Property | Status |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Envelope (content) confidentiality against the node | YES — node sees the opaque envelope object only; `notice.tags` are NOT plugin-sealed and are intentionally plaintext for routing |
| Envelope confidentiality against non-owner readers | YES — `OWNER:R` is the only `R` operator on `notice` in `enclaves/personal.md`'s manifest |
| Sender unforgeability of `inviter` claim | NO at the envelope layer — the envelope only proves "someone with `sender_op_priv` wrote this"; the *claimed* `inviter` field is application data. Verification of the originating action (§6) is the spec's defense. |
| Handoff confidentiality | YES — distinct ECDH + distinct domain separator; addressed to recipient op key. |
| Forward secrecy against identity-key compromise | NO — the CT stores both the outer envelope and the `handoff` ciphertexts; an identity-key compromise recovers them all. Tradeoff inherited from CT replay. |
| Post-compromise security | NO — same reason. |
| Replay protection on notice events | INHERITED from protocol-level commit dedup (`commit.sig` is unique per event); the plugin adds none. |
| Sub-key (multi-wallet) support | YES — sender addresses recipient's published operating key (sub\_pub when present); handoff is similarly addressed. |
***
### 9. Compliance vectors (required in plugin tests)
Implementations MUST publish KAT vectors for:
1. Outer envelope round-trip: encrypt a fixed payload object → decrypt under the recipient's op-priv yields byte-identical JSON plaintext; the envelope JSON object on the wire matches a fixed reference.
2. Handoff round-trip: a sender's wrap with `recipient = parent_pub` AND the same `recipient = sub_pub` yields two distinct ciphertexts; each is recoverable only by the matching priv.
3. Wrong `sender_pub` → outer-envelope AEAD failure → notice rejected per §7.2.
4. Wrong `handoff.recipient` → handoff treated as missing per §7.5 (notice still surfaces as text-only).
***
### 10. Versioning
This plugin is version `1`. Any change to a domain separator, the AEAD primitive, the nonce length, the KDF, or the wire field layout requires a new plugin (`ecdh-envelope-v2`) and a new `kind` discriminator namespace — clients negotiate by manifest, never by field-sniffing.
## Plugin: `identity-aead`
**Role:** Single-owner deterministic confidentiality. The content key is a deterministic HKDF derivation from the owner's `identity_priv` keyed by the enclave id; any device with `identity_priv` re-derives the same key. No ECDH, no ratchet, no rotation. Maximally simple — appropriate when only the owner ever reads.
**Reference consumer:** [`enclaves/personal.md`](/spec/app/enclaves/personal) — confidentiality for the `private` content event (owner-only encrypted documents). Any future event whose only reader is the owning identity (per-app preferences, secret notes, wallet backups, anything `OWNER:R`-exclusive) is a candidate consumer.
**Slot type (suggested):** `IdentityAeadCryptoFn` (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses `PersonalPrivateCryptoFn`).
This document is the **complete wire-and-key contract**.
***
### Table of Contents
1. [1. Primitives (fixed; no negotiation)](#primitives-fixed-no-negotiation)
2. [2. Domain separator (single, enclave-scoped)](#domain-separator-single-enclave-scoped)
3. [3. Key derivation](#key-derivation)
4. [4. Encrypt / decrypt](#encrypt-decrypt)
5. [5. Event wire shape](#event-wire-shape)
6. [6. Updates (U on `private`)](#updates-u-on-private)
7. [7. Error & rejection rules (normative)](#error-rejection-rules-normative)
8. [8. Security properties](#security-properties)
9. [9. Compliance vectors (required in plugin tests)](#compliance-vectors-required-in-plugin-tests)
10. [10. Versioning](#versioning)
***
### 1. Primitives (fixed; no negotiation)
The primitives below are normative; implementations MUST use the concrete choice specified for every row. The content key itself is deterministic — it is derived per §3 and MUST NOT be CSPRNG-sampled.
| Primitive | Concrete choice |
| --------- | --------------------------------------------------------------------------------------------------------------------------- |
| AEAD | XChaCha20-Poly1305 — **24-byte nonce, 16-byte Poly1305 tag** |
| KDF | HKDF-SHA-256 — **`salt = undefined`** (i.e. SHA-256 zero block), `info` = enclave-scoped ASCII separator (see §2), `L = 32` |
| CSPRNG | `globalThis.crypto.getRandomValues` (for the AEAD nonce only — the key itself is deterministic) |
| Encoding | `{ ciphertext: , nonce: }` — two separate hex fields per event |
There is no ECDH and no curve operation in this plugin: the IKM is `identity_priv` directly. (The 32-byte secp256k1 private scalar has full entropy and is itself the secret root for the per-identity content domain.)
***
### 2. Domain separator (single, enclave-scoped)
```
info = "enc-personal-private:" || enclave_id_hex_lowercase
```
The literal prefix MUST be **`enc-personal-private:`**.
`enclave_id_hex_lowercase` MUST be the consumer enclave's id encoded as a lowercase 64-hex string. The enclave id is baked into the `info` so that two distinct enclaves owned by the **same** identity produce independent content keys.
***
### 3. Key derivation
The `content_key` MUST be derived deterministically from `(identity_priv, enclave_id)` by the code block below.
```
info = utf8("enc-personal-private:" || lowercase_hex(enclave_id))
content_key = HKDF(IKM=identity_priv, salt=∅, info, L=32)
```
The `content_key` is fixed for the (identity, enclave) pair and MUST NOT rotate. Implementations MUST NOT cache `content_key` in any persistent store — it MUST be re-derived from `identity_priv` on each encrypt/decrypt. Live-only caching during a session is permitted.
***
### 4. Encrypt / decrypt
#### 4.1 Encrypt
Encryption MUST follow the code block below; the nonce length MUST be exactly 24 bytes.
```
content_key = HKDF(identity_priv, salt=∅, info="enc-personal-private:" || enclave_id_hex, 32)
nonce = CSPRNG(24)
ct = XChaCha20-Poly1305(content_key, nonce).encrypt(utf8(plaintext))
event.content = { ciphertext: hex(ct), nonce: hex(nonce) }
```
#### 4.2 Decrypt
Decryption MUST follow the code block below.
```
content_key = HKDF(identity_priv, salt=∅, info="enc-personal-private:" || enclave_id_hex, 32)
plaintext = utf8_decode(
XChaCha20-Poly1305(content_key, fromHex(nonce)).decrypt(fromHex(ciphertext))
)
```
***
### 5. Event wire shape
`private` events use a JSON `content` whose fields are this plugin's encrypted envelope. The application MAY embed the envelope under a stable key (e.g. `{ "doc": { ciphertext, nonce } }`); implementations MUST agree on the embed shape per application convention. When no application convention dictates otherwise, the envelope MUST be the top-level object below.
```jsonc
{
"ciphertext": "",
"nonce": ""
}
```
The `private` event's RBAC is `OWNER: CRUD` — only the owner reads or writes. The node MUST NOT decrypt.
***
### 6. Updates (U on `private`)
A `private` event MAY be `Update`d by `OWNER:U`. The Update event MUST carry a fresh `(ciphertext, nonce)` pair under the **same `content_key`** (the key is identity-derived and never rotates). The update mechanism is the protocol's standard Update/Delete (`[r,