Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Personal Enclave

This document specifies the personal enclave application profile: an identity anchor that holds the owner's identity, where to find the owner's data, and where others reach the owner for cross-enclave addressed messages. The private layer is sealed with identity-aead; the notice layer is sealed with ecdh-envelope.


Table of Contents

  1. Overview
  2. Manifest
  3. Event-Operator Matrix
  4. Data
  5. Confidentiality
  6. Security Considerations

Overview

A personal enclave is an identity anchor — holding the owner's identity, where to find the owner's data, and where others reach the owner for cross-enclave addressed messages.

Four data layers (Layer = profile | public | private | notice); the storage/access split is fixed by Layer.storage and Layer.access:

  • profileLayer.storage profile = kvSharedSingleton / Layer.access profile = publicReadable. KV Shared singleton. Public identity card. SMT-provable.
  • publicLayer.storage public = contentEvents / Layer.access public = publicReadable. Content events. Dynamic public content.
  • privateLayer.storage private = contentEvents / Layer.access private = ownerOnlyEncrypted. Content events. Encrypted dynamic documents. Owner-only.
  • noticeLayer.storage notice = contentEvents / Layer.access notice = inboundOwnerReadOnly. Content events. Inbound cross-enclave addressed messages (e.g., group invitations). Anyone with the owner's pubkey can write one; only the owner reads.

Manifest

{
  "states": ["OWNER"],
  "traits": ["dataview(1)"],
  "readers": [
    { "type": "OWNER", "reads": "*", "retention": "current" }
  ],
 
  "moves": [],
 
  "grants": [
    { "event": "Grant", "operator": ["OWNER"], "scope": ["OUTSIDER"], "trait": ["dataview"] },
    { "event": "Revoke", "operator": ["OWNER"], "scope": ["OUTSIDER"], "trait": ["dataview"] }
  ],
 
  "transfers": [],
 
  "slots": [
    { "event": "Shared", "operator": "OWNER", "ops": ["C", "U", "D"], "key": "profile" },
    { "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "profile" }
  ],
 
  "lifecycle": [
    { "event": "Terminate", "operator": "OWNER", "ops": ["C"] }
  ],
 
  "customs": [
    { "event": "public", "operator": "OWNER", "ops": ["C", "U", "D"] },
    { "event": "public", "operator": "dataview", "ops": ["P"] },
    { "event": "private", "operator": "OWNER", "ops": ["C", "U", "D"] },
    { "event": "notice", "operator": "OUTSIDER", "ops": ["C"],
      "alias": "notices", "gate": { "operator": ["OWNER"] } },
    { "event": "notice", "operator": "OWNER", "ops": ["D"] },
    { "event": "notice", "operator": "dataview", "ops": ["P"] }
  ],
 
  "init": [
    { "identity": "<owner_pub>", "state": "OWNER", "traits": [] }
  ]
}

Design Rationale

States: ["OWNER"] — single State for the sole human identity. OUTSIDER (0) is implicit.

Traits: ["dataview(1)"] — push delivery for service accounts.

Operators:

  • OWNER (State) — all ops. Read on all events via readers. CUD on content and KV. D on notice (cleanup). Administrative: Grant, Revoke, Terminate.
  • OUTSIDER (State) — C on notice only. Anyone with the owner's pubkey can write one inbound message; cannot read or update. Gated by notices (OWNER toggle).
  • dataview (trait) — P on profile, public, and notice events for push delivery.

No moves — OWNER is bootstrapped via init. The owner never changes state. Validation rule 1 ("In and Out") is satisfied by init — OWNER is reachable at enclave creation.

No transfers — a personal enclave IS the owner's identity; ownership cannot be transferred.

Lifecycle: Terminate only. No Pause/Resume — single owner, no security value.

Event-Operator Matrix

EventOWNEROUTSIDERdataview(1)
publicCRUDP
privateCRUD
noticeRDCP
Gate(notices)CR
Shared(profile)CRUDP
Grant(dataview)CR
Revoke(dataview)CR
TerminateCR

Columns: States (OWNER, OUTSIDER) → traits (dataview).

Data

KV Shared: profile

Public identity card. Singleton. SMT-provable.

Cross-application fields: display_name, bio, avatar. Recommended for interoperability across apps. Apps can add additional fields — the protocol does not constrain the internal structure.

Content event: public

Dynamic public content. Owner creates. Supports U (update) and D (delete).

The protocol does not prescribe content structure. Apps define their own content types freely.

Content event: private

Dynamic private documents. Owner-only. Encrypted. Supports U (update) and D (delete).

Each event's content is encrypted before submission. The node stores opaque ciphertext. Apps use content events with U for granular updates to independent documents — no single-blob limitation.

The protocol does not prescribe what documents are stored. Apps define their own document types freely.

Content event: notice

Inbound cross-enclave addressed message. The Personal enclave's inbox.

notice is the rail by which any external enclave reaches the owner with an addressed message — most importantly, group invitations. When a Group admin executes Move(OUTSIDER → MEMBER) to invite the owner, the admin's client SHOULD pair that Move with a notice write into the invitee's Personal enclave so the invitee discovers the invitation. Without this companion write, the invitee is silently a member of a group they have no signal pointing them to. The same rail covers any other future cross-enclave addressed flow (channel invites, role assignments, etc.) by varying the kind field.

Authorization:

  • OUTSIDER:C — anyone with the owner's pubkey can create one notice. The sender is by definition not in the owner's Personal enclave; OUTSIDER
    is the only path for them.
  • OWNER:D — the owner deletes notices after acting on them. Read access for OWNER comes via readers: [{ "type": "OWNER", "reads": "*" }].
  • dataview:P — push delivery so the owner's client wakes on arrival.
  • Gate notices — OWNER can close the inbox at any time; gated commits are rejected before RBAC evaluation.

No OWNER

— the owner is not the producer; producers are external senders. No OWNER
/ Sender
— notices are receipts, not stateful documents. If the sender's situation changes, they write a new notice.

Encrypted content (plaintext tags). The content field is sealed by the ecdh-envelope plugin to the recipient via secp256k1 ECDH between the sender's operating private key (parent identity_priv or deterministic sub_priv when present in reg_identity) and the recipient's published operating public key (reg_identity.sub_pub when present, else id_pub). The node sees the opaque envelope object in content plus the commit envelope (sender pubkey signature, target enclave_id). The kind of notice, the source enclave_id, the inviter, and any handoff secrets are all inside the sealed payload — hidden from the node. The tags field is NOT sealed — it remains the protocol-standard [[key, value, …], …] plaintext array and MAY carry routing / indexing hints (e.g. ["enclave_id", "<hex>"]).

Required payload fields (sealed inside notice.content):

  • kind — discriminator. Initial registry: group_invite, dm_invite. Apps may add experimental kinds with the x- prefix.
  • enclave_id — the source enclave the notice refers to (e.g., the group the owner was invited to).
  • enclave_kind — manifest hint: "group", "dm", etc.
  • inviter — pubkey of the identity that produced the addressed action (e.g., the admin who performed the Move).

Optional payload fields (also sealed inside notice.content):

  • handoff — kind-specific cryptographic material. For group_invite: the source group's root_secret for the current epoch, sealed via the plugin's inner sub-encryption under the enc:personal:notice:epoch domain (separately keyed from the outer envelope — see plugins/ecdh-envelope §5). The receiver derives epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32) per plugins/mls-lazy §4 — REQUIRED for the invitee to decrypt the group's CT.
  • epoch_n — REQUIRED whenever handoff is present. The source enclave's epoch.n the handoff-wrapped secret belongs to. Used by Reconciliation (below) and by the receiver's per-group epoch map.
  • topic — group display name; lets the invitee's app render the group before subscribing.
  • greeting — human-readable text from the inviter.
  • manifest_hash — hash of the source enclave's manifest. Lets the invitee verify what kind of enclave they are joining before joining.
  • move_ref — pointer to the originating Move event in the source enclave's CT, for client-side cross-verification.

Companion-write contract is SHOULD, not MUST. The originating action (e.g., the Group's Move) and the notice write are two separate commits in two separate enclaves; cross-enclave atomicity is not enforceable at the protocol layer. Recipient clients MUST tolerate either half landing alone — see "Reconciliation" below.

Reconciliation. On reading a notice, the recipient's client MUST fetch the referenced source enclave's CT and confirm a corresponding action exists (for kind: "group_invite", a Move(OUTSIDER → MEMBER) targeting the recipient by inviter at or before the bundle whose epoch.n == payload.epoch_n) before surfacing the notice as actionable. A notice without a corresponding source-enclave action is treated as pending; clients retry verification within a tolerance window (default RECONCILIATION_PENDING_WINDOW_MS = 24 * 60 * 60 * 1000 ms = 24h) before discarding via OWNER:D. This client-side check is the spec's primary defense against forged or stale invitations.

Spam considerations: see the Security section below.

Confidentiality

The four Personal data layers split across two encryption plugins plus a plaintext layer:

EventLayerPlugin
Shared(profile)Plaintext
publicPlaintext
privateOwner-only sealed (content)identity-aead
noticeRecipient-only sealed (content; tags plaintext)ecdh-envelope

App-payload contract

EventFieldSealing
Shared(profile)all fieldsplaintext
publiccontentplaintext
privatecontent (JSON {ciphertext, nonce})identity-aead — domain separator enc-personal-private:<enclave_id_hex_lowercase>)
noticecontent (JSON envelope {ciphertext, nonce, sender_pub, scheme: "personal:notice", encrypted: true})ecdh-envelope outer envelope — domain enc:personal:notice (see plugin §3)
noticetagsMUST remain plaintext protocol-standard [[key, value, …], …] — MAY carry routing hints (e.g. ["enclave_id", "<hex>"]); MUST match the sealed payload when both are present
notice (kind == "group_invite")sealed payload.handoff ({recipient, ecdh_pub, ciphertext, nonce})ecdh-envelope inner sub-encryption — domain enc:personal:notice:epoch (carries the source group's 32-byte root_secret; see plugin §5)

For a notice carrying kind == "group_invite", the inner handoff is decrypted as part of joining the group, not as part of reading the notice. The recovered 32-byte root_secret is fed through HKDF(root_secret, "enc:mls:epoch", 32) to obtain epoch_secret per mls-lazy.md §4. After this bootstrap, the invitee receives every subsequent group epoch via the group's normal in-CT distribution — the ecdh-envelope plugin is bootstrap-only.

Security guarantees the personal app relies on

From identity-aead §8:

  • Owner-only confidentiality of private (no R operator other than OWNER on private per this manifest). ✅
  • Multi-device support via deterministic HKDF from identity_priv. ✅
  • Per-event key isolation (unique random nonces). ✅
  • No PCS against identity_priv compromise — compromise reveals every past, present, and future private document. Accepted by design (identity key is the trust root for owner-only data).

From ecdh-envelope §8:

  • Confidentiality of notice.content against the node and any third-party indexer (including dataview pushers). ✅ notice.tags are intentionally plaintext for routing and MUST NOT carry secrets.
  • Per-recipient isolation (each notice keyed to one recipient's published operating key). ✅
  • Group-invite handoff sub-encryption keyed independently from the outer envelope, so a future leak of the outer envelope material does not directly disclose the group's path secret. ✅
  • No PCS — sender→recipient identity-ECDH is one-shot, no ratchet. Cured for group_invite by the group's normal epoch rotation immediately after the invitee joins.

The Security Considerations section below covers the app-layer consequences of these choices (spam surface, dataview metadata, forward-secrecy tradeoff).

Security Considerations

Open OUTSIDER:C write surface

The notice event is intentionally writable by any external party who knows the owner's pubkey. This is required: the rail's purpose is cross-enclave addressed delivery, and senders are by definition outside the owner's Personal enclave. The trade-offs:

  • Sender must already know the recipient's pubkey. The protocol does not advertise pubkeys; mass enumeration depends on the readability of registries and other public CTs (see Registry app spec).
  • Spam mitigation is not in this manifest beyond the notices Gate. OWNERs close the gate to stop new notices entirely. Selective filtering (per-sender rate limits, content-size caps, inbox-depth-scaled fees) is a node-implementation concern, not a manifest concern.
  • Phishing defense is in the recipient's client: the client MUST verify the claimed enclave_id's originating action exists in the source CT before surfacing the notice as actionable (see "Reconciliation" above). A notice that points to a non-existent or unrelated source action is silently discarded.

Metadata leak via dataview

dataview:P on notice is required for the rail to be useful — the owner's client wakes on arrival rather than polling. As a consequence, dataview push subscribers learn:

  • That a notice arrived (timing).
  • The sender's pubkey (from the commit envelope's signature).

They do NOT learn the notice's kind, enclave_id, inviter claim, or any handoff material — those are inside the ECDH-sealed payload. Owners who run their own dataview avoid the metadata leak; owners who delegate dataview to a third-party indexer accept it. This matches the DM design philosophy.

Forward secrecy

The notice envelope uses identity ECDH with no ratchet. A future compromise of the recipient's identity key reveals every historical notice — including the bootstrap path secret for any group the owner was invited to. This matches DM's documented trade-off and is acceptable for v2 because the path secret is rotated forward by the group's normal epoch ratchet immediately after the invitee joins. Higher-stakes future kinds MAY require a different envelope.