Skip to content
ENC Protocol

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:

KindCasingMeaningExample
StateUPPER_CASEWhere an identity is — mutually exclusive, changed by a Move.OWNER, MEMBER, BLOCKED
traitlower(rank)What an identity carries — additive flags, granted/revoked. Lower rank = higher authority.admin(1), muted(2), dataview(3)
ContextPascalCaseA 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:

OpMeaningOpMeaning
CCreatePPush (full event delivery to a service endpoint)
RReadNNotify (lightweight ping to a human client)
UUpdate_XDeny the op X — deny always wins
DDelete

The sections

A manifest is { name, description, manifest: { … } }. The inner object groups rules by what they govern:

SectionGovernsEntry shape
statesthe State enum["OWNER", "MEMBER", …]
traitsthe trait flags + rank["admin(1)", "dataview(3)"]
readerswho can read{ type, reads }reads is event names or "*"
customsapp content events{ event, operator, ops, alias?, gate? }
slotskey/value state{ event: "Shared"|"Own", operator, ops, key }
movesState transitions{ event: "Move", from, to, operator, ops, gate? }
grantstrait assignment{ event: "Grant"|"Revoke", operator[], scope[], trait[] }
transfersatomic trait handover{ trait, scope[] }
lifecycleenclave lifecycle{ event: "Pause"|"Resume"|"Migrate"|"Terminate", operator, ops }
initbootstrap 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 notes

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

TemplateStates · traitsShapeDistinctive rules
Registry(none) · owner, dataviewPublic, permissionless directoryPublic may Create reg_node/reg_enclave/reg_identity; Sender may U/D its own
PersonalOWNER · dataviewSingle-owner identity anchorowner-only public/private; a profile KV slot; a notice inbox open to outsiders but gated to the owner
DMOWNER,FRIEND,BLOCKEDTwo-party mailboxFRIEND may write message; BLOCKED is denied (_U,_D); owner manages the friend/block state
GroupPENDING,MEMBER,BLOCKED · owner(0),admin(1),muted(2),dataview(3)Multi-party roomjoin 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.

blog.manifest.mjs
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.

deploy-blog.mjs
// 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 read

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