Enclaves
An enclave is the unit of sovereignty in ENC: a single append-only log of signed events, governed by a manifest that says exactly who may do what. A node can host billions of them; each is independent, independently verifiable, and addressed by a deterministic id derived from its manifest and its owner's key.
Everything an app does — a post, a DM, a group message, a registry entry — is an event committed to an enclave. Whether that commit is accepted is decided entirely by the manifest's RBAC (role- based access control). The node enforces it on every event, forever; no app code can override it.
The manifest is the constitution
A manifest declares roles and the operations each role may perform on each event type. Three kinds of role, by casing:
| Kind | Casing | Meaning | Example |
|---|---|---|---|
| State | UPPER_CASE | Where an identity is — mutually exclusive, changed by a Move. | OWNER, MEMBER, BLOCKED |
| trait | lower(rank) | What an identity carries — additive flags, granted/revoked. Lower rank = higher authority. | admin(1), muted(2), dataview(3) |
| Context | PascalCase | A condition evaluated by the node at authorization time — never stored. | Self, Sender, Public |
Each rule grants (or denies) ops on an event. The op letters:
| Op | Meaning | Op | Meaning | |
|---|---|---|---|---|
| C | Create | P | Push (full event delivery to a service endpoint) | |
| R | Read | N | Notify (lightweight ping to a human client) | |
| U | Update | _X | Deny the op X — deny always wins | |
| D | Delete |
The sections
A manifest is { name, description, manifest: { … } }. The inner object groups rules by what they
govern:
| Section | Governs | Entry shape |
|---|---|---|
states | the State enum | ["OWNER", "MEMBER", …] |
traits | the trait flags + rank | ["admin(1)", "dataview(3)"] |
readers | who can read | { type, reads } — reads is event names or "*" |
customs | app content events | { event, operator, ops, alias?, gate? } |
slots | key/value state | { event: "Shared"|"Own", operator, ops, key } |
moves | State transitions | { event: "Move", from, to, operator, ops, gate? } |
grants | trait assignment | { event: "Grant"|"Revoke", operator[], scope[], trait[] } |
transfers | atomic trait handover | { trait, scope[] } |
lifecycle | enclave lifecycle | { event: "Pause"|"Resume"|"Migrate"|"Terminate", operator, ops } |
init | bootstrap identities | { identity, state, traits[] } |
Read a rule like a sentence. From the Personal template:
{ "event": "public", "operator": "OWNER", "ops": ["C", "U", "D"] } // the OWNER may create/update/delete public posts
{ "event": "public", "operator": "dataview", "ops": ["P"] } // the dataview role may Push public posts onward
{ "event": "private", "operator": "OWNER", "ops": ["C", "U", "D"] } // only the OWNER touches private notesShared vs Own slots: a Shared slot is one enclave-wide value (a group topic); an Own
slot is one value per identity (each member's profile). A gate on a move/custom narrows
when it applies (e.g. an outsider may apply to join, but only if an admin later approves).
Templates ship with the SDK
Four reference enclaves cover the common shapes. Each is a real manifest you can deploy as-is or fork.
| Template | States · traits | Shape | Distinctive rules |
|---|---|---|---|
| Registry | (none) · owner, dataview | Public, permissionless directory | Public may Create reg_node/reg_enclave/reg_identity; Sender may U/D its own |
| Personal | OWNER · dataview | Single-owner identity anchor | owner-only public/private; a profile KV slot; a notice inbox open to outsiders but gated to the owner |
| DM | OWNER,FRIEND,BLOCKED | Two-party mailbox | FRIEND may write message; BLOCKED is denied (_U,_D); owner manages the friend/block state |
| Group | PENDING,MEMBER,BLOCKED · owner(0),admin(1),muted(2),dataview(3) | Multi-party room | join via application/auto-join/invite; admin moderates; muted denied message C; Pause/Migrate/Terminate lifecycle |
Each ships in its app SDK's MANIFESTS (e.g. PersonalSdk.MANIFESTS.enclaves.Personal). Pick the
closest, then keep or trim its rules.
Author a custom enclave
A manifest is just data — write the object, then deploy it. Here's a minimal Blog: one owner, public posts the world can read via a dataview, and private drafts only the owner sees.
export const blog = {
name: 'Blog',
description: 'single-owner blog — public posts + private drafts',
manifest: {
states: ['OWNER'], // one role: you
traits: ['dataview(1)'], // a service role that may fan posts out
readers: [{ type: 'OWNER', reads: '*' }], // the owner reads everything
grants: [ // the owner may (un)appoint a dataview
{ event: 'Grant', operator: ['OWNER'], scope: ['OUTSIDER'], trait: ['dataview'] },
{ event: 'Revoke', operator: ['OWNER'], scope: ['OUTSIDER'], trait: ['dataview'] },
],
customs: [
{ event: 'public', operator: 'OWNER', ops: ['C', 'U', 'D'] }, // your posts
{ event: 'public', operator: 'dataview', ops: ['P'] }, // dataview may Push them onward
{ event: 'private', operator: 'OWNER', ops: ['C', 'U', 'D'] }, // owner-only drafts
],
init: [{ identity: '<owner_pub>', state: 'OWNER', traits: [] }], // bootstrap: you, as OWNER
},
}<owner_pub> is a placeholder the deploy step fills with your key. The rule of thumb: start from
the events your app writes (public, private), then add exactly the roles those rules reference.
Deploy it to a node
Minting is two steps: flatten the authored manifest into the wire RBAC form, then createEnclave on the node. The id is derived deterministically from the manifest + your key, so minting is idempotent — same inputs, same enclave, every time.
// NODE_URL=http://localhost:8787 node deploy-blog.mjs
import { flattenEnclaveManifest } from '@enc-protocol/protocol-runtime'
import { NetworkAdapter } from '@enc-protocol/client/network-adapter.js'
import { createIdentity } from '@enc-protocol/client'
import { blog } from './blog.manifest.mjs'
const NODE_URL = process.env.NODE_URL || 'http://localhost:8787'
const owner = createIdentity() // or load your real key
// authored manifest → wire RBAC manifest for THIS owner
const wire = flattenEnclaveManifest(blog).enclaveManifest(owner.publicKeyHex)
const { enclave_id, sequencer } = await new NetworkAdapter(NODE_URL, '', owner).createEnclave(wire)
console.log('enclave:', enclave_id, '\nsequencer:', sequencer) // seq pubkey — clients need it to readThe wire manifest the node stores is the flattened RBAC: a flat schema of { event, role, ops }
rows plus states, traits, and initial_state:
{ "enc_v": 1, "nonce": "…", "RBAC": {
"schema": [
{ "event": "public", "role": "OWNER", "ops": ["C","U","D"] },
{ "event": "public", "role": "dataview", "ops": ["P"] },
{ "event": "private", "role": "OWNER", "ops": ["C","U","D"] }
],
"states": ["OWNER"], "traits": ["dataview(1)"],
"initial_state": { "OWNER": ["<owner pubkey>"] }
} }Wiring a dataview at mint time
If a dataview should receive pushes, give it the dataview role and
the URL to push to in one shot — write it straight into the wire initial_state before createEnclave.
Each initial_state role entry may be a bare pubkey or { identity, endpoint }; when an endpoint
is present, the node registers the push route the moment the enclave is minted (no separate Grant):
const wire = flattenEnclaveManifest(blog).enclaveManifest(owner.publicKeyHex)
wire.RBAC.initial_state.dataview = [
{ identity: dataviewPubHex, endpoint: 'https://your-dataview.workers.dev/push' },
]
await new NetworkAdapter(NODE_URL, '', owner).createEnclave(wire)You now have a live enclave, owned by your key, governed by rules the node enforces on every commit.
Next: wire it into an app with the SDK →, or walk the full build in the Personal app tutorial.