DM Enclave
This document specifies the DM (direct-messaging) enclave application profile: a personal mailbox where the owner reads their own enclave and others write to it, with end-to-end confidentiality provided by the ratchet-pair plugin.
Table of Contents
Overview
A DM enclave is a personal mailbox for direct messaging. Each user has one DM enclave for ALL conversations. The owner reads from their own enclave; others write to it.
- Alice's enclave holds messages from Bob, Charlie, and anyone else she's friends with.
- Each contact is a separate identity in the SMT (Bob=FRIEND, Charlie=FRIEND, etc.).
- Friends can send messages but cannot read — no FRIEND. Only OWNER reads.
- Sender isolates per-sender: Bob can edit/delete Bob's messages, not Charlie's.
Conversation between Alice and Bob spans two enclaves: Bob writes to Alice's enclave, Alice writes to Bob's enclave. Each owner reads from their own.
Manifest
{
"states": ["OWNER", "FRIEND", "BLOCKED"],
"traits": [],
"readers": [
{ "type": "OWNER", "reads": "*", "retention": "current" }
],
"moves": [
{ "event": "Move", "from": "OUTSIDER", "to": "FRIEND", "operator": "OWNER", "ops": ["C"] },
{ "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "OWNER", "ops": ["C"] },
{ "event": "Move", "from": "FRIEND", "to": "OUTSIDER", "operator": "OWNER", "ops": ["C"] },
{ "event": "Move", "from": "FRIEND", "to": "BLOCKED", "operator": "OWNER", "ops": ["C"] },
{ "event": "Move", "from": "BLOCKED", "to": "FRIEND", "operator": "OWNER", "ops": ["C"] },
{ "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "OWNER", "ops": ["C"] }
],
"grants": [],
"transfers": [],
"slots": [],
"lifecycle": [
{ "event": "Terminate", "operator": "OWNER", "ops": ["C"] }
],
"customs": [
{ "event": "invite", "operator": "OUTSIDER", "ops": ["C"],
"alias": "invites", "gate": { "operator": ["OWNER"] } },
{ "event": "invite", "operator": "OWNER", "ops": ["D"] },
{ "event": "message", "operator": "OWNER", "ops": ["D"] },
{ "event": "message", "operator": "FRIEND", "ops": ["C"] },
{ "event": "message", "operator": "Sender", "ops": ["U", "D"] },
{ "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] },
{ "event": "sent", "operator": "OWNER", "ops": ["C", "U"] },
{ "event": "rotate", "operator": "OWNER", "ops": ["C"] }
],
"init": [
{ "identity": "<owner_pub>", "state": "OWNER", "traits": [] }
]
}The manifest JSON above is normative: every field, every entry, and every ops list is part of the contract implemented by conforming DM implementations.
Design Rationale
States: ["OWNER", "FRIEND", "BLOCKED"]
- OUTSIDER (0) = no relationship
- OWNER (1) = the enclave owner, bootstrapped via init
- FRIEND (2) = accepted contact, can send messages
- BLOCKED (3) = blocked by owner
No PENDING state — the owner adds contacts directly. Move(OUTSIDER→FRIEND) is a unilateral owner decision. No Self-targeting Moves needed.
No traits — DM is private messaging, no service accounts or push delivery needed.
Operators:
OWNER(State) — reads everything via readers. Manages all contacts (Moves). Deletes invites. Creates rotate events. Admin: Gate, Terminate.FRIEND(State) — can create messages.Sender(Context) — message edit/delete by original sender.OUTSIDER(State) — can create invite events (gated).
Owner-initiated model — Each owner controls who enters their own enclave. The owner adds a contact by Move(OUTSIDER→FRIEND), then notifies the other party via an invite event in their enclave. The other party adds back when ready. No cross-enclave state coupling.
Gate on invites — The invites gate lets the owner close their inbox to new invite events. When closed, invite creation is rejected before RBAC runs.
No moves to OWNER — OWNER is bootstrapped via init, never changes state.
- All state transitions in a DM enclave MUST be operated by
OWNER. - No Move event in a DM enclave MUST NOT target the
OWNERstate. - A DM enclave MUST NOT define any Self-targeting Moves.
- Identities in OUTSIDER, FRIEND, or BLOCKED states MUST NOT read events in a DM enclave.
State Transitions
| Transition | Operator | Meaning |
|---|---|---|
| OUTSIDER → FRIEND | OWNER | Add contact |
| OUTSIDER → BLOCKED | OWNER | Preemptive block |
| FRIEND → OUTSIDER | OWNER | Remove contact |
| FRIEND → BLOCKED | OWNER | Block contact |
| BLOCKED → FRIEND | OWNER | Unblock to friend |
| BLOCKED → OUTSIDER | OWNER | Remove from blocklist |
All transitions are OWNER-only. No Self-targeting Moves, no external actors changing state.
Event-Operator Matrix
| Event | OWNER | OUTSIDER | FRIEND | BLOCKED | Sender |
|---|---|---|---|---|---|
| invite | RD | C | |||
| Gate(invites) | CR | ||||
| message | RD | C | _U_D | UD | |
| sent | CRU | ||||
| rotate | CR | ||||
| Move(OUTSIDER, FRIEND) | CR | ||||
| Move(OUTSIDER, BLOCKED) | CR | ||||
| Move(FRIEND, OUTSIDER) | CR | ||||
| Move(FRIEND, BLOCKED) | CR | ||||
| Move(BLOCKED, FRIEND) | CR | ||||
| Move(BLOCKED, OUTSIDER) | CR | ||||
| Terminate | CR |
Columns: States (OWNER, OUTSIDER, FRIEND, BLOCKED) → Contexts (Sender).
The DM event–operator matrix above is normative: dmAuthorize(event, operator, op) = true iff the matrix cell for (event, operator) contains op (closed-default — any (event, operator, op) triple not listed MUST be denied).
Content Events
invite
Contact initiation from an OUTSIDER.
- Only identities currently in
OUTSIDERstate MAY create aninviteevent;FRIEND,OWNER, andBLOCKEDMUST NOT create invites. - The owner MUST be able to read and to delete
inviteevents. - When the
invitesgate is closed,invitecreation MUST be rejected before RBAC runs. - The
inviteevent'scontentand key-distributiontagsMUST be sealed using theratchet-pairinviteenvelope (see Confidentiality §App-payload contract below).
message
Incoming DM message.
- A
messageevent MUST be created by an identity inFRIENDstate (FRIEND). - The original sender MUST be able to update (Sender) or delete (Sender) their own
messageevents. - The
OWNERMUST be able to deletemessageevents (OWNER) for local cleanup. - After OWNER on a
message, subsequent Sender or Sender against that event MUST be rejected with anEVENT_DELETEDerror because the event's0x01 EventStatusis terminal.
The sender's sent copy in their own enclave is unaffected.
- A
messageevent'scontentMUST be aratchet-pairper-message envelope. - A
messageevent'stagsMAY carry the sender's outgoingepochwrap, with one tag per recipient operating key (REPEATABLE).
sent
Owner's copy of outgoing messages, for cross-device sync.
- The
sentevent MUST be readable, creatable, and updatable only byOWNER(OWNER, OWNER, OWNER). - A
sentevent'scontentMUST be aratchet-pairsentenvelope. - A
sentevent MUST include a["to", "<recipient_pub>"]tag identifying the counterparty, which the plugin uses to derive the per-counterparty key. - When the sender retracts a message (Sender in the recipient's enclave), the client MUST update the corresponding
sentevent (OWNER) in the owner's enclave.
rotate
OWNER-only mid-conversation per-contact epoch rotation without a state change.
- A
rotateevent MUST be creatable only byOWNER(OWNER) and MUST NOT cause a state change. - A
rotateevent MUST carry aratchet-pairself-encrypted epoch wrap for device sync. - After a
rotate, the owner MUST piggyback the new epoch in theepochtag of the next outgoingmessageto the target contact (lazy cross-enclave delivery).
When to rotate (client-side heuristics — not protocol-enforced): after N messages from a contact, after time elapsed, after sub-key rotation, on manual user action.
Confidentiality
DM event content and key-distribution material MUST be end-to-end sealed; the node MUST see only opaque ciphertext.
Canonical plugin: plugins/ratchet-pair — the canonical confidentiality plugin for the DM enclave MUST be ratchet-pair.
App-payload contract
The plugin's wire envelopes appear on these event content / tag fields:
| Event | Field | Plugin envelope |
|---|---|---|
invite | content | invite envelope (one-shot ECDH-derived) |
invite | tag ["epoch", <n>, <encrypted_secret>, <ecdh_pub>] | epoch wrap (REPEATABLE — one tag per recipient operating key, §plugin §6) |
invite | tag ["enclave_id", <sender_dm_enclave_id>] | plaintext routing |
message | content | message envelope (per-sender ratchet) |
message | tag ["epoch", …] | epoch wrap, REPEATABLE — present whenever the sender is delivering a new epoch to the peer |
sent | content | sent envelope (self-encrypted, per-counterparty) |
sent | tag ["to", <recipient_pub>] | plaintext addressing — input to the plugin's sent key derivation |
rotate | content.epoch | epoch wrap (self-encrypted, ecdh_pub == owner_pub) |
Move(OUTSIDER→FRIEND) | content.epoch | epoch wrap (self-encrypted, for device sync) |
The app-payload contract above is normative: every DM implementation MUST realise dmAppPayloadContract — for each PayloadContractRow(event, field, envelope, plaintext?, repeatable?) it MUST place the envelope on (event, field) exactly, mark plaintext rows as plaintext, and treat repeatable rows as zero-or-more.
- A new epoch MUST be delivered to the counterparty lazily via the
epochtag on the next outgoingmessage(or in theinvitefor the first contact). - The owner's own copy of every epoch (Move, rotate) MUST be self-encrypted with
peer_pub == owner_pubso any device withidentity_privrecovers it from CT replay.
Initial contact (semantic flow)
Cross-enclave bidirectional handshake; only OWNER reads each enclave, so all key delivery is cross-enclave write + same-enclave OWNER read.
Bob's enclave Alice's enclave
│ │
1. │ epoch₀ᵇᵃ generated │
2. │ Move(O→F, Alice) │
│ carries epoch₀ᵇᵃ self-wrapped │
│ │
3. │ ──── invite (OUTSIDER:C) ─────────────► │
│ tags: [enclave_id] │
│ [epoch wrap → Alice's op key] │ (REPEATABLE per plugin §6)
│ content: greeting (invite envelope) │
│ 4. │ OWNER:R reads invite
│ │ → recovers epoch₀ᵇᵃ
│ 5. │ epoch₀ᵃᵇ generated
│ 6. │ Move(O→F, Bob)
│ │ carries epoch₀ᵃᵇ self-wrapped
│ │
8. │ ◄──── message (FRIEND:C) ──────────── 7.│
│ tags: [epoch wrap → Bob's op key] │
│ content: per-sender ratcheted │
│ OWNER:R reads message │
│ → recovers epoch₀ᵃᵇ │
▼ ▼
Bob holds: epoch₀ᵇᵃ + epoch₀ᵃᵇ Alice holds: epoch₀ᵇᵃ + epoch₀ᵃᵇNotation: epoch₀ᵇᵃ = epoch 0 in Bob's enclave for the (Bob, Alice) pair. Each contact gets an independent per-pair epoch.
- Each user MUST act in their own enclave first (sovereign Move + rotate), then write to the other's enclave (notification: invite or message); if the cross-enclave write fails, the client MUST retry, since the local state is already committed.
- After step 4, Alice is FRIEND in Bob's enclave and holds
epoch₀ᵇᵃ, so she MAY message Bob before completing step 6; full bidirectional begins only after both sides complete. Move(BLOCKED → FRIEND)MUST be a pure state change and MUST NOT carry an epoch; the unblocked contact uses whatever epoch they last received, and the owner delivers a fresh epoch lazily on the next outgoing message.
Epoch recovery (multi-device join / fresh login)
On sync, the client replays the owner's CT to rebuild the per-contact epoch map:
| Source | What the client does |
|---|---|
Self-encrypted wraps (own Move(O→F), rotate) | Unwrap each with the owner's identity_priv against ecdh_pub == owner_pub (plugin §3.2). |
Participant-encrypted wraps (received invite, message epoch tags — REPEATABLE) | For each tag, try ECDH(my_op_priv, sender_ecdh_pub). Keep the secret the AEAD verifies; skip mismatches (a parent-only login skips sub-wrapped tags; a sub login skips parent-wrapped tags). |
The epoch-recovery procedure above is normative: on sync, the client MUST execute dmRecoverEpochMap — apply both EpochRecoverySource.selfEncrypted and EpochRecoverySource.participantEncrypted row handlers above to the owner's CT to rebuild the per-contact epoch map.
- The recovered epoch map MUST be per-contact, per-epoch; the current epoch for each contact MUST be the highest
nthat satisfies the plugin's strict monotonicity rule (§5).
Security guarantees the DM app relies on
The DM enclave MUST rely on the ratchet-pair plugin (see ratchet-pair §9) for per-message key isolation, per-contact key isolation, per-epoch forward secrecy, multi-device sync via identity_priv, sub-key wallet support, and strict per-contact epoch.n monotonicity.
The DM enclave MUST NOT require post-compromise security against identity_priv compromise; the CT-replay tradeoff is accepted (the same constraint applies to every CT-based ENC enclave). Apps that need PCS-grade DMs MUST swap to a future ratchet plugin that ships a side-channel for key material outside the CT (out of scope for ratchet-pair).