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.
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:R. Only OWNER reads.
- Sender:UD 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).
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:C). - The original sender MUST be able to update (Sender:U) or delete (Sender:D) their own
messageevents. - The
OWNERMUST be able to deletemessageevents (OWNER:D) for local cleanup. - After OWNER:D on a
message, subsequent Sender:U or Sender:D 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:C, OWNER:R, OWNER:U). - 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:D in the recipient's enclave), the client MUST update the corresponding
sentevent (OWNER:U) 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:C) 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.
Notes:
- 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).