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
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:
- profile —
Layer.storage profile = kvSharedSingleton/Layer.access profile = publicReadable. KV Shared singleton. Public identity card. SMT-provable. - public —
Layer.storage public = contentEvents/Layer.access public = publicReadable. Content events. Dynamic public content. - private —
Layer.storage private = contentEvents/Layer.access private = ownerOnlyEncrypted. Content events. Encrypted dynamic documents. Owner-only. - notice —
Layer.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 bynotices(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
| Event | OWNER | OUTSIDER | dataview(1) |
|---|---|---|---|
| public | CRUD | P | |
| private | CRUD | ||
| notice | RD | C | P |
| Gate(notices) | CR | ||
| Shared(profile) | CRUD | P | |
| Grant(dataview) | CR | ||
| Revoke(dataview) | CR | ||
| Terminate | CR |
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 viareaders: [{ "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 thex-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. Forgroup_invite: the source group'sroot_secretfor the current epoch, sealed via the plugin's inner sub-encryption under theenc:personal:notice:epochdomain (separately keyed from the outer envelope — seeplugins/ecdh-envelope§5). The receiver derivesepoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)perplugins/mls-lazy§4 — REQUIRED for the invitee to decrypt the group's CT.epoch_n— REQUIRED wheneverhandoffis present. The source enclave'sepoch.nthehandoff-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:
| Event | Layer | Plugin |
|---|---|---|
Shared(profile) | Plaintext | — |
public | Plaintext | — |
private | Owner-only sealed (content) | identity-aead |
notice | Recipient-only sealed (content; tags plaintext) | ecdh-envelope |
App-payload contract
| Event | Field | Sealing |
|---|---|---|
Shared(profile) | all fields | plaintext |
public | content | plaintext |
private | content (JSON {ciphertext, nonce}) | identity-aead — domain separator enc-personal-private:<enclave_id_hex_lowercase>) |
notice | content (JSON envelope {ciphertext, nonce, sender_pub, scheme: "personal:notice", encrypted: true}) | ecdh-envelope outer envelope — domain enc:personal:notice (see plugin §3) |
notice | tags | MUST 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(noRoperator other than OWNER onprivateper this manifest). ✅ - Multi-device support via deterministic HKDF from
identity_priv. ✅ - Per-event key isolation (unique random nonces). ✅
- No PCS against
identity_privcompromise — compromise reveals every past, present, and futureprivatedocument. Accepted by design (identity key is the trust root for owner-only data).
From ecdh-envelope §8:
- Confidentiality of
notice.contentagainst the node and any third-party indexer (including dataview pushers). ✅notice.tagsare 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_inviteby 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
noticesGate. 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.