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

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

  1. Overview
  2. Manifest
  3. State Transitions
  4. Event-Operator Matrix
  5. Content Events
  6. Confidentiality

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 OWNER state.
  • 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

TransitionOperatorMeaning
OUTSIDER → FRIENDOWNERAdd contact
OUTSIDER → BLOCKEDOWNERPreemptive block
FRIEND → OUTSIDEROWNERRemove contact
FRIEND → BLOCKEDOWNERBlock contact
BLOCKED → FRIENDOWNERUnblock to friend
BLOCKED → OUTSIDEROWNERRemove from blocklist

All transitions are OWNER-only. No Self-targeting Moves, no external actors changing state.

Event-Operator Matrix

EventOWNEROUTSIDERFRIENDBLOCKEDSender
inviteRDC
Gate(invites)CR
messageRDC_U_DUD
sentCRU
rotateCR
Move(OUTSIDER, FRIEND)CR
Move(OUTSIDER, BLOCKED)CR
Move(FRIEND, OUTSIDER)CR
Move(FRIEND, BLOCKED)CR
Move(BLOCKED, FRIEND)CR
Move(BLOCKED, OUTSIDER)CR
TerminateCR

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 OUTSIDER state MAY create an invite event; FRIEND, OWNER, and BLOCKED MUST NOT create invites.
  • The owner MUST be able to read and to delete invite events.
  • When the invites gate is closed, invite creation MUST be rejected before RBAC runs.
  • The invite event's content and key-distribution tags MUST be sealed using the ratchet-pair invite envelope (see Confidentiality §App-payload contract below).

message

Incoming DM message.

  • A message event MUST be created by an identity in FRIEND state (FRIEND
    ).
  • The original sender MUST be able to update (Sender
    ) or delete (Sender
    ) their own message events.
  • The OWNER MUST be able to delete message events (OWNER
    ) for local cleanup.
  • After OWNER
    on a message, subsequent Sender
    or Sender
    against that event MUST be rejected with an EVENT_DELETED error because the event's 0x01 EventStatus is terminal.

The sender's sent copy in their own enclave is unaffected.

  • A message event's content MUST be a ratchet-pair per-message envelope.
  • A message event's tags MAY carry the sender's outgoing epoch wrap, with one tag per recipient operating key (REPEATABLE).

sent

Owner's copy of outgoing messages, for cross-device sync.

  • The sent event MUST be readable, creatable, and updatable only by OWNER (OWNER
    , OWNER
    , OWNER
    ).
  • A sent event's content MUST be a ratchet-pair sent envelope.
  • A sent event 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 sent event (OWNER
    ) in the owner's enclave.

rotate

OWNER-only mid-conversation per-contact epoch rotation without a state change.

  • A rotate event MUST be creatable only by OWNER (OWNER
    ) and MUST NOT cause a state change.
  • A rotate event MUST carry a ratchet-pair self-encrypted epoch wrap for device sync.
  • After a rotate, the owner MUST piggyback the new epoch in the epoch tag of the next outgoing message to 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:

EventFieldPlugin envelope
invitecontentinvite envelope (one-shot ECDH-derived)
invitetag ["epoch", <n>, <encrypted_secret>, <ecdh_pub>]epoch wrap (REPEATABLE — one tag per recipient operating key, §plugin §6)
invitetag ["enclave_id", <sender_dm_enclave_id>]plaintext routing
messagecontentmessage envelope (per-sender ratchet)
messagetag ["epoch", …]epoch wrap, REPEATABLE — present whenever the sender is delivering a new epoch to the peer
sentcontentsent envelope (self-encrypted, per-counterparty)
senttag ["to", <recipient_pub>]plaintext addressing — input to the plugin's sent key derivation
rotatecontent.epochepoch wrap (self-encrypted, ecdh_pub == owner_pub)
Move(OUTSIDER→FRIEND)content.epochepoch 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 epoch tag on the next outgoing message (or in the invite for the first contact).
  • The owner's own copy of every epoch (Move, rotate) MUST be self-encrypted with peer_pub == owner_pub so any device with identity_priv recovers 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:

SourceWhat 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 n that 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).