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

RBAC v2

This document specifies RBAC v2, the role-based access control system used by every ENC enclave. It covers the bitmask encoding of State and trait, the runtime Context abstractions (Self / Sender / Public), the JSON manifest schema, the authorization algorithm, and the per-event processing rules (Move / Grant / Revoke / Transfer / Gate / AC_Bundle / Lifecycle / KV).


Table of Contents


Overview

The RBAC system controls authorization for all events in an enclave. Every event — content events (message), AC events (Move, Grant, Revoke), KV events (Shared, Own), and lifecycle events (Pause, Terminate) — is authorized through the manifest. See Section 5 for manifest format and Section 6 for validation rules.

1.1 Terminology

The old unified "role" is retired. Three column types replace it:

ConceptConventionExamplesEncodingChanged bySemantics
StateUPPER_CASEPENDING, MEMBER, BLOCKED8-bit enumMoveWHERE the actor is. Base permissions. Mutually exclusive.
traitlower_caseowner, admin, mutedFlag bitsGrant / Revoke / Transfer / initWHAT modifies the actor. Additive or deny ops. Ranked.
ContextPascalCaseSelf, Sender, PublicSystem-evaluated(implicit)System condition. Determined at authorization time.

1.2 Operations

Six operations apply to all event types:

OpMeaningDeny
CCreate_C
RRead_R
UUpdate_U
DDelete_D
NNotify (lightweight, human clients)_N
PPush (full delivery, service endpoints)_P

Positive ops grant capabilities. Negative (deny) ops revoke capabilities. The sign comes from the schema entry, not the bitmask.



Bitmask Encoding

All RBAC state for an identity is stored in a single bitmask value in the SMT (namespace 0x00), keyed by the identity's public key.

bits 0-7:    State enum
bits 8+:     trait flags

2.1 State Enum (bits 0-7)

8-bit integer field. All 8 bits together encode a single value. Mutually exclusive by structure — an identity is in exactly one State.

ValueNameDescription
0OUTSIDERNot in the enclave. No SMT leaf exists. Protocol-reserved.
1-255(app-defined)Custom States defined in manifest. E.g., 1=PENDING, 2=MEMBER, 3=BLOCKED.

OUTSIDER (0) is the only protocol-reserved State. All other States — including blocking/banning — are app-defined.

State values 1-255 are assigned sequentially from the manifest states array. The first State in the array gets value 1, the second gets value 2, etc.

2.2 trait Flags (bits 8+)

Independent flag bits. Each bit represents one trait. Multiple traits can be set simultaneously.

Bit positions are assigned sequentially from the manifest traits array: the first trait gets bit 8, the second gets bit 9, etc.

Each trait declares a rank using the syntax name(N) where N is a non-negative integer. Lower number = higher rank. Multiple traits can share the same rank (peers). Rank determines targeting authority: an operator can only target identities ranked strictly below them (see §7 Rank Rule).

All traits are managed uniformly:

  • Assigned via init, Grant, or Transfer.
  • Removed via Revoke, Transfer, or Move (clears all trait flags unless preserve: true).
  • Rank determines targeting authority (lower number = higher authority).

The bitmask does not distinguish positive from negative traits. A "muted" trait flag at bit 10 is stored identically to an "admin" trait flag at bit 9. The sign (positive or negative ops) comes from the schema entry, not the bitmask.

2.3 SMT Leaf Lifecycle

  • Join: Move(OUTSIDER, X) creates a new SMT leaf with State=X and no trait flags.
  • State change: Move(X, Y) updates the State enum and clears all trait flags (unless preserve: true).
  • Leave: Move(X, OUTSIDER) sets State=0 and clears all traits. If bitmask becomes 0, leaf is deleted.
  • Service account: Granting a trait to an OUTSIDER creates a leaf with State=0 and trait bit set.
  • Leaf deletion: SMT leaf is deleted when the entire bitmask == 0 (no State, no traits).
  • No leaf: An identity with no SMT leaf is implicitly OUTSIDER with no traits.

Historical membership is provable via the CT log.



Contexts

Contexts are system-evaluated conditions used as columns in the schema. They are not stored — they are determined at authorization time.

3.1 Self

  • Matches when: The actor is targeting their own identity (event.from == content.target).
  • Use case: AC events — leaving a group (Move Self
    ), stepping down from a trait (Revoke Self
    ).

3.2 Sender

  • Matches when: The actor authored the referenced event (event.from == original_event.from).
  • Use case: Content events — editing/deleting own messages (message Sender
    ), removing own reactions (reaction Sender
    ), updating own KV slot (Own Sender
    ).

3.3 Public

  • Matches when: Always. Any identity, any State, including OUTSIDER.
  • Use case: Public read access to group messages, public enclave manifests.


SMT Namespace Table

NamespacePurposeKeyValue
0x00RBACH(identity)Bitmask (State + traits)
0x01EventStatusH(event_hash)Status flags (Update/Delete)
0x02KV StateH(key) or H(key || identity)H(content)

Gate state lives in 0x02 with the gate: key prefix. Lifecycle state lives in 0x02 with the lifecycle key.



Appendix: Removed Events

Four event types from the v1 spec are removed. Each is replaced by a more general mechanism already in the registry.

RemovedReplacementRationale
Move_SelfMove with operator: "Self"Self is a Context, not an event type. The node enforces event.from == content.target when the moves entry has operator: "Self". No expressiveness lost.
Revoke_SelfRevoke with operator: "Self"Same pattern. Self-targeting is an operator constraint, not a separate event type.
Grant_PushGrantThe node detects P (Push) ops from the schema for the granted trait. If the trait has P ops and the Grant content includes an endpoint field, the node registers the push endpoint. No separate event type needed.
Force_Move(removed, no replacement)Owner can define all necessary transitions in moves. State is an 8-bit enum (not a combinatorial bitmask), so Owner can always move anyone to any State by having the right moves entries. Force_Move added no capability that moves entries cannot express.

Manifest Format

5.1 Manifest Sections

A manifest declares all RBAC rules for an enclave. Ten sections:

SectionPurposeEntry format
statesApp-defined StatesArray of UPPER_CASE names. Assigned enum values 1+ sequentially.
traitsApp-defined traitsArray of name(rank) strings. Assigned bit positions 8+ sequentially. Lower rank = higher authority.
readersRead access[{ type, reads }]. Each entry declares read authority for one column.
initInitial identities{ identity, state, traits[] }. Bootstrap SMT at enclave creation.
movesState transitions{ event, from, to, operator, ops }. Optional alias and gate.
grantstrait assignment rules{ event (Grant/Revoke), operator[], scope[], trait[] }
transfersAtomic trait movement{ trait, scope[] }. Operator MUST hold the trait.
slotsKV state (Shared/Own){ event (Shared/Own), operator, ops, key }
lifecycleEnclave lifecycle{ event (Pause/Resume/Migrate/Terminate), operator, ops }
customsApp-defined content events{ event, operator, ops }

5.2 Section Details

readers — Column-oriented read declarations. Each entry is { type, reads, retention? }:

  • type: A declared State, trait, or Context.
  • reads: Array of event type names this column gets R on, or "*" for all events.
  • retention (OPTIONAL; State / trait entries only): "current" (default) or "snapshot". Governs the temporal scope at which the node evaluates this reader against the requester's RBAC history. See §5.3 Reader Retention. A retention field on a Context reader (Self, Sender, Public) is a manifest validation error; Contexts are always evaluated per-event.

moves — Each entry authorizes one State transition for one operator.

grants — Each entry authorizes Grant or Revoke of specific traits, scoped to specific States.

  • operator: Who can grant/revoke (array — Contexts, traits, or States).
  • scope: Target State scope (array).
  • trait: Which traits can be granted/revoked (array).

transfers — Each entry authorizes atomic movement of a trait from one identity to another.

  • trait: Which trait can be transferred.
  • scope: Target State scope (array).
  • The operator is implicit: MUST hold the trait being transferred.

slots — KV state entries. Each entry requires a key field (lowercase, app-defined).

  • Shared: Enclave-wide singleton per key. Last write wins.
  • Own: Per-identity slot per key. Node enforces per-identity isolation.
  • Protocol-reserved keys: gate:*, lifecycle. Apps cannot write to reserved keys.

lifecycle — Protocol-defined enclave lifecycle events. State stored in Shared("lifecycle").

customs — App-defined content events (lowercase names). Standard { event, operator, ops } entries.

5.3 Reader Retention

A readers entry declares a column (a State or trait) that grants read authority on a set of event types. The retention field selects between two evaluation modes — declaring it makes the policy explicit instead of implicit.

retentionWhen the column is required to be in the requester's bitmaskEffect when the requester loses the column
"current" (default)At query time (the bitmask in the current SMT)All access (historical and live) stops immediately. Behavior matches Implementations.
"snapshot"At the seq of each event being readPre-loss events stay accessible; live tail stops at loss. Forward-secrecy on confidentiality is unaffected (it lives in the encryption plugins, not in readers).

The default value of retention (when absent) is "current".

The two modes deliberately commute with the encryption plugins (ratchet-pair, mls-lazy) — those rotate epochs on RBAC removal, so post-removal events are opaque to the removed identity regardless of which retention mode is in force. retention therefore governs ONLY whether the node serves pre-removal ciphertext to a former member; it never controls confidentiality.

Context readers (Self, Sender, Public) MUST NOT carry retention. They are evaluated per-event against requester identity at serve time (not over RBAC history); a Sender reader thus passes for any event the requester authored, regardless of their current or historical RBAC state in the enclave.

When multiple readers entries apply (union semantics — rbac-v2 §4 already declares effective-ops = ⋃ per-column-ops), each entry is evaluated under its own retention. A manifest MAY mix modes per event type:

"readers": [
  { "type": "MEMBER", "reads": ["message", "reaction"], "retention": "snapshot" },
  { "type": "MEMBER", "reads": ["notice", "Pause", "Resume"], "retention": "current" }
]

Above: a kicked former member retains their conversational transcript (snapshot) but loses access to admin notices and lifecycle events (current).

Examples. A transcript-portable group (kicked members keep history):

"readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ]

A security-sensitive private group (today's implicit default, now explicit — kicked = audit-trail amnesia):

"readers": [ { "type": "MEMBER", "reads": "*", "retention": "current" } ]

The operational algorithm — how the node computes the served seq set per query, how subscriptions split into historical and live phases, and how close reasons signal loss of access — is normative in node-api.md §Read Authorization.

5.2.1 Naming Convention

  • Protocol events: PascalCase (Move, Grant, Shared, Pause, etc.)
  • App events: lowercase, optionally _-segmented for namespaced records (message, request, sent, reg_identity, reg_node, reg_enclave, etc.)
  • States: UPPER_CASE (MEMBER, PENDING, BLOCKED, etc.)
  • traits: lower_case (admin, muted, etc.)
  • Contexts: PascalCase (Self, Sender, Public)
  • KV keys: lowercase (topic, profile, etc.)

The convention is non-normative — the kernel does not validate event-name format. Implementations MAY follow stricter local rules (e.g. enforce the regex ^[a-z][a-z0-9_]*$ for customs entries) but conformance does not require it.

5.2.2 Common Entry Properties

Any entry in any section MAY include:

  • alias: Optional. Names the entry for referencing (Gate keys, UI, logs, API).
  • gate: Optional. { operator[] } — declares who can toggle the gate. Requires alias. Gate state stored in Shared("gate:<alias>"). When gate is closed, the event is rejected before RBAC runs.
  • preserve: Optional (moves only). If true, trait flags are kept after the Move. Default false (clear all traits).


Validation Rules

  1. In and Out — Every State in states MUST appear as to in at least one moves entry or be assigned in at least one init entry (in). Any State with no ops MUST also appear as from in at least one moves entry (out).
  2. No Stuck Traits — Every trait in traits MUST have at least one assign path (Grant entry or Transfer entry) and at least one remove path (Revoke entry or Transfer entry). Traits only assigned in init are exempt from the assign path requirement.
  3. Valid Operators — Every operator MUST be a declared State, declared trait, or a Context (Self, Sender, Public).
  4. Write and Reader Coverage — Every event MUST have at least one writer authorization path with C ops, and read access MUST be covered by at least one matching readers entry. Operator R ops are OPTIONAL and only model profiles that deliberately encode read authority as an operation.
  5. Reserved Keys — App-defined slots keys MUST NOT use reserved prefixes (gate:, lifecycle).
  6. Gate Requires Alias — If an entry has gate, it MUST have alias.
  7. Valid Ranks — Every trait must declare a rank via name(N) syntax. All rank values MUST be non-negative integers.
  8. Complete States — Every State name referenced in the manifest (moves from/to, grants scope, transfers scope, init state) MUST be either declared in states or be OUTSIDER.
  9. Naming Convention — Identifier strings declared in the manifest MUST conform to §5.2.1:
    • State names: ^[A-Z][A-Z0-9_]*$
    • trait names (rank-stripped): ^[a-z][a-z0-9_]*$
    • customs.event: ^[a-z][a-z0-9_]*$ OR a known protocol event name (Manifest / Grant / Revoke / Move / Transfer / Gate / Shared / Own / AC_Bundle / Pause / Resume / Terminate / Migrate / Update / Delete)
    • slots.key: ^[a-z][a-z0-9_]*$ Nodes MUST reject Manifest events whose content fails this rule with ProcessResult.invalidManifest. The conformance reference impl is Enc.Core.ManifestValidator.checkNamingConvention.

Nodes MUST run all 9 rules on every Manifest event and reject the event (and not apply the manifest to enclave state) if any rule fails. The rejection result is ProcessResult.invalidManifest; implementations SHOULD log the specific rule violated.



Example Manifest

This is the Group Chat enclave manifest from group.md. For other examples, see personal.md and dm.md.

{
  "states": ["PENDING", "MEMBER", "BLOCKED"],
  "traits": ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"],
  "readers": [
    { "type": "MEMBER", "reads": "*" }
  ],
 
  "moves": [
    { "event": "Move", "from": "OUTSIDER", "to": "PENDING", "operator": "Self", "ops": ["C"],
      "alias": "applications", "gate": { "operator": ["owner", "admin"] } },
    { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "Self", "ops": ["C"],
      "alias": "auto_join", "gate": { "operator": ["owner"] } },
    { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "PENDING", "to": "MEMBER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "PENDING", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "Self", "ops": ["C"] },
    { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "MEMBER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }
  ],
 
  "grants": [
    { "event": "Grant", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] },
    { "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
    { "event": "Grant", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] },
    { "event": "Revoke", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] },
    { "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
    { "event": "Revoke", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] },
    { "event": "Revoke", "operator": ["Self"], "scope": ["MEMBER"], "trait": ["admin"] }
  ],
 
  "transfers": [
    { "trait": "owner", "scope": ["MEMBER"] }
  ],
 
  "slots": [
    { "event": "Shared", "operator": "admin", "ops": ["C", "U"], "key": "topic" },
    { "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "topic" },
 
    { "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" },
    { "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" }
  ],
 
  "lifecycle": [
    { "event": "Pause", "operator": "owner", "ops": ["C"] },
    { "event": "Resume", "operator": "owner", "ops": ["C"] },
    { "event": "Migrate", "operator": "owner", "ops": ["C"] },
    { "event": "Terminate", "operator": "owner", "ops": ["C"] }
  ],
 
  "customs": [
    { "event": "message", "operator": "MEMBER", "ops": ["C"] },
    { "event": "message", "operator": "admin", "ops": ["D"] },
    { "event": "message", "operator": "muted", "ops": ["_C", "_U"] },
    { "event": "message", "operator": "dataview", "ops": ["P"] },
    { "event": "message", "operator": "Sender", "ops": ["U", "D"] },
    { "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] },
 
    { "event": "reaction", "operator": "MEMBER", "ops": ["C"] },
    { "event": "reaction", "operator": "Sender", "ops": ["D"] },
    { "event": "reaction", "operator": "muted", "ops": ["_C"] },
    { "event": "reaction", "operator": "BLOCKED", "ops": ["_D"] },
 
    { "event": "notice", "operator": "admin", "ops": ["C", "D"] },
 
    { "event": "rotate", "operator": "admin", "ops": ["C"] }
  ],
 
  "init": [
    { "identity": "<owner_pub>", "state": "MEMBER", "traits": ["owner", "admin"] }
  ]
}

Event-Operator Matrix

EventMEMBEROUTSIDERPENDINGBLOCKEDowner(0)admin(1)muted(2)dataview(3)SelfSender
messageCR_U_DD_C_UPUD
reactionCR_D_CD
noticeRCD
rotateRC
Shared(topic)RCUP
Own(profile)CRU
Move(OUTSIDER, PENDING)RC
Gate(applications)RCC
Move(OUTSIDER, MEMBER)RCC
Gate(auto_join)RC
Move(OUTSIDER, BLOCKED)RC
Move(PENDING, MEMBER)RC
Move(PENDING, OUTSIDER)RC
Move(MEMBER, OUTSIDER)RCC
Move(MEMBER, BLOCKED)RC
Move(BLOCKED, OUTSIDER)RC
Grant(muted)RC
Grant(admin)RC
Grant(dataview)RC
Revoke(muted)RC
Revoke(admin)RCC
Revoke(dataview)RC
Transfer(owner)RC
PauseRC
ResumeRC
MigrateRC
TerminateRC

Columns: States (MEMBER, OUTSIDER, PENDING, BLOCKED) → traits (owner, admin, muted, dataview) → Contexts (Self, Sender).



Authorization Algorithm

The authorization algorithm determines whether an identity can perform a specific operation on a specific event type.

isAuthorized(identity_bitmask, event_type, operation, is_self, is_sender) → bool
 
1. Extract State from bitmask (bits 0-7).
 
2. Collect allowed ops:
   a. State ops (from State column for identity's current State)
   b. trait positive ops (OR across all held trait flags)
   c. Self ops (if is_self == true: event.from == content.target)
   d. Sender ops (if is_sender == true: event.from == original_event.from)
   e. Public ops (always applies)
 
3. Collect denied ops:
   negative ops from all sources (State _X, trait _X, Self _X, Sender _X, Public _X)
 
4. Compute effective:
   effective = allowed − denied
 
5. Return: operation ∈ effective

No State gate. traits can extend any State with new capabilities. Safety against invalid State+trait combinations comes from operational constraints:

  • Move clears all traits
  • Grant validates target State (schema-defined)

4.1 Deny Override

Negative ops always win over positive ops regardless of source. If the effective set has both C (from State) and _C (from a trait, State, or Context), the result is: C is removed.



Processing Pipeline

The full event processing pipeline, from submission to commitment:

1. Signature verification (is the event signed by the claimed identity?)
2. Duplicate check (has this event already been committed?)
3. Lifecycle check (is the enclave active, not paused/terminated?)
4. Gate check (is this event type/transition currently enabled?)
5. RBAC authorization (Section 4 algorithm)
6. Rank check (for AC events targeting another identity — see Rank Rule below)
7. Content validation (event-type-specific format checks)
8. State mutation (apply bitmask changes for AC events)
9. SMT update (write new bitmask, compute new root)
10. CT append (add event to the certificate transparency log)
11. Commit (finalize sequence number, return receipt)

Rank Rule. For any AC event (Move, Grant, Revoke) targeting another identity: if the operator holds any trait, and the target holds any trait, the operator's best rank (lowest rank number among held traits) MUST be strictly less than the target's best rank. If either party holds no traits, or the operator is Self-targeting, the rank check is skipped. Violation → reject (RANK_INSUFFICIENT).



Event Processing

When reading a target's bitmask from SMT, if no leaf exists, treat the bitmask as 0x00 (OUTSIDER, no traits).

8.1 Move

Changes an identity's State. Clears all traits. The core lifecycle operation.

Event content:

{ "target": "<target_pub>", "from": "PENDING", "to": "MEMBER", "preserve": true }

The preserve field is a manifest entry selector, not a behavioral directive. The node matches the content's from, to, and preserve against the moves entries to find the authorizing entry. Two entries with the same from→to but different preserve values are distinct Moves.

Processing:

1. Resolve from/to State names to enum values.
2. Match against moves entries: find the entry where from, to, and preserve
   all match. If no match → reject (UNAUTHORIZED).
3. Read target's current bitmask from SMT.
4. Verify: current State enum == from value.
   If mismatch → reject (STATE_MISMATCH).
5. Compute new bitmask:
   a. Set State enum (bits 0-7) to the "to" value.
   b. If the matched moves entry has preserve: true → keep all trait flags.
      Otherwise (default): clear all trait flags.
6. If entire bitmask == 0: delete SMT leaf.
   Else: write new bitmask to SMT.

Authorization: Move events are authorized via the moves section of the manifest. Each entry specifies from, to, operator, ops, and optionally preserve. The content's from, to, and preserve fields together select the matching entry.

Move matching: State is an enum. The check is exact: current_state == from. trait bits are ignored. No CAS on the full bitmask — only the State enum is compared.

Application payload: Move content MAY carry additional application-defined fields alongside the required target, from, and to fields. The node does not interpret these fields — they are opaque payload for the application layer. Example: DM enclaves piggyback per-contact epoch key-establishment material on Move(O→F) via the ratchet-pair plugin (see enclaves/dm.md); Group enclaves piggyback MLS commits on admin-created membership Moves via the mls-lazy plugin (see enclaves/group.md).

8.2 Grant

Adds a trait flag to an identity's bitmask.

Event content:

{ "target": "<target_pub>", "trait": "admin" }

If the trait has P (Push) ops in the schema, the event includes an endpoint:

{ "target": "<target_pub>", "trait": "dataview", "endpoint": "https://..." }

Processing:

1. Resolve trait name to bit position (from manifest traits array).
2. Read target's current bitmask from SMT.
3. Verify: target's current State is in the allowed scope.
   If not → reject (INVALID_STATE_FOR_GRANT).
4. Rank check (see §7 Rank Rule).
5. Set the trait bit: new_bitmask = current | (1 << trait_bit).
6. If trait has P ops in schema AND endpoint is provided: register push endpoint (operational state, not in SMT).
7. Write new bitmask to SMT (create leaf if it doesn't exist).

Authorization: Grant events are authorized via Grant schema entries. Each entry specifies operator (who can grant), scope (what States the target MUST be in), and trait (what traits can be granted).

No separate Grant_Push event type. The node detects P ops from the schema and handles endpoint registration as part of regular Grant.

8.3 Revoke

Removes a trait flag from an identity's bitmask.

Event content:

{ "target": "<target_pub>", "trait": "admin" }

Processing:

1. Resolve trait name to bit position.
2. Read target's current bitmask from SMT.
3. Rank check (see §7 Rank Rule).
4. Clear the trait bit: new_bitmask = current & ~(1 << trait_bit).
5. If new_bitmask == 0: REMOVE the leaf from the SMT (per smt.md §Zero Bitmask).
   Else: write new_bitmask to the SMT leaf as 32-byte big-endian bytes.

Revoking a trait the target doesn't have is a no-op (bit was already 0).

A zero-bitmask leaf MUST NOT exist in the SMT (smt.md §Zero Bitmask: "no roles" is semantically identical to "not in tree"). Implementations that write 0 instead of removing the leaf produce divergent SMT roots.

Self is allowed as operator for Revoke (voluntarily drop own trait).

8.4 Transfer

Atomically moves a trait from one identity to another. The operator MUST hold the trait being transferred.

Event content:

{ "target": "<target_pub>", "trait": "owner" }

Processing:

1. Verify operator holds the trait being transferred.
2. Verify target is not the operator (no self-transfer).
   If same → reject (INVALID_TRANSFER_TARGET).
3. Read target's current bitmask from SMT.
4. Verify target does not already hold the trait.
   If already held → reject (TRAIT_ALREADY_HELD).
5. Verify: target's current State is in the allowed scope.
   If not → reject (INVALID_STATE_FOR_TRANSFER).
6. Clear trait bit on operator's bitmask.
7. Set trait bit on target's bitmask.
8. Write both bitmasks to the SMT — but if the operator's new bitmask is 0, REMOVE the operator's leaf instead (per smt.md §Zero Bitmask). The target side always writes (Transfer adds a trait, so the target's bitmask is non-zero by construction).

Every numbered step above is normative; the listed reject codes (INVALID_TRANSFER_TARGET, TRAIT_ALREADY_HELD, INVALID_STATE_FOR_TRANSFER) MUST be used verbatim.

Authorization: Transfer events are authorized via the transfers section. Each entry specifies trait (which trait can be transferred) and scope (which States the target MUST be in).

8.5 Gate

Pre-RBAC check that enables/disables specific events at the enclave level. Gates are open by default.

Event content:

{ "gate": "applications", "open": false }

Processing:

1. Verify submitter is an allowed operator for this gate (from manifest).
2. Update gate state in KV Shared: Shared("gate:<id>") = open value.

Gate state is checked BEFORE the RBAC authorization algorithm. If a gated event type/transition is closed, the event is rejected before RBAC runs.

8.6 AC_Bundle

Atomic multi-event bundle. All events succeed or none apply.

Event content:

{
  "events": [
    { "event": "Move", "target": "<pub>", "from": "PENDING", "to": "MEMBER" },
    { "event": "Grant", "target": "<pub>", "trait": "admin" },
    { "event": "Grant", "target": "<pub>", "trait": "moderator" }
  ]
}

Processing:

1. For each event in order:
   a. Validate against simulated intermediate state.
   b. Apply to simulated state.
   If any event fails → reject entire bundle.
2. If all pass: apply all changes atomically to SMT.

8.7 Lifecycle Events

Lifecycle events control enclave-level state. They store their state in KV Shared (SMT namespace 0x02) with the reserved lifecycle key.

Pause

Stops the enclave from accepting new events (except Resume).

Event content:
{}

(Pause carries an empty content; the event type lives on the commit/event type field per spec.md §Commit Structure.)

Processing: processPause(active) ≡ Sum.inr(paused); every other input state yields Sum.inl(invalidState) (reject with INVALID_LIFECYCLE_STATE). On success, the node sets Shared("lifecycle") = "paused" via applyPause(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.paused)).

Resume

Lifts a pause, restoring normal operation.

Event content:
{}

(Resume carries an empty content; the event type lives on the commit/event type field per spec.md §Commit Structure.)

Processing: processResume(paused) ≡ Sum.inr(active); every other input state yields Sum.inl(invalidState) (reject with INVALID_LIFECYCLE_STATE). On success, the node sets Shared("lifecycle") = "active" via applyResume(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.active)).

Migrate

Initiates migration of the enclave to a new sequencer node. Canonical content schema: see spec.md §Migration — the authoritative shape is { new_sequencer, prev_seq, ct_root }. Reproduced here for reference:

Event content:
{
  "new_sequencer": "<new_sequencer_pub>",
  "prev_seq":      <last_seq_finalized_by_old_sequencer>,
  "ct_root":       "<ct_root_at_prev_seq>"
}

Processing: processMigrate(active) ≡ Sum.inr(migrated); every other input state yields Sum.inl(invalidState) (reject with INVALID_LIFECYCLE_STATE). The node additionally MUST verify that prev_seq matches the highest seq finalized by the current sequencer and ct_root matches the CT root at that seq (see spec.md §Migration for forced-takeover semantics). On success, the node sets Shared("lifecycle") = "migrated" (terminal) via applyMigrate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.migrated)).

The migrated lifecycle state is terminal — no further events are accepted by the migrated sequencer.

Terminate

Permanently shuts down the enclave. Irreversible.

Event content:
{}

(Terminate carries an empty content; the event type lives on the commit/event type field per spec.md §Commit Structure.)

Processing: processTerminate(s) ≡ Sum.inr(terminated) for every s ∈ {active, paused, migrated}, and processTerminate(terminated) ≡ Sum.inl(invalidState) (reject with INVALID_LIFECYCLE_STATE). On success, the node sets Shared("lifecycle") = "terminated" via applyTerminate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.terminated)).

Lifecycle Check

Lifecycle state is checked at step 3 of the processing pipeline (Section 7), before Gate and RBAC checks:

Lifecycle check → Gate check → RBAC authorization
StateAccepts events?
activeAll events
pausedOnly Resume (from owner)
migratedNo events (terminal — sequencer has been replaced; subsequent activity belongs to the migrated sequence and is recorded in the new sequencer's CT)
terminatedNo events

8.8 KV Events (Shared / Own)

Mutable key-value state stored in SMT namespace 0x02. KV events are NOT content events — they form their own category. Authorization uses the same schema matrix as all other events.

8.8.1 Problem

Content events are append-only (the CT is a log). To represent "current topic" or "my display name", apps would need to scan the CT for the latest event. KV events provide first-class mutable state with SMT inclusion proofs for the current value.

8.8.2 Shared

Enclave-wide singleton. One slot per key per enclave. Last write wins.

Event content:
{ "key": "topic", "value": "General Discussion" }

SMT key: H(0x02 || key_name) SMT value: H(content)

Any actor with C on Shared (for matching key) can overwrite. History preserved in CT.

8.8.3 Own

Per-identity slot. One slot per key per identity. Each identity can only write to their own slot.

Event content:
{ "key": "profile", "value": { "display_name": "Alice", "status": "Available" } }

SMT key: H(0x02 || key_name || commit.from) SMT value: H(content)

Per-identity isolation enforced by the node — the from in the SMT key is always commit.from. No identity field in content needed.

8.8.4 Schema Representation

KV events use the standard schema format with an additional key field:

{ "event": "Shared", "operator": "admin", "ops": ["C"], "key": "topic" }
{ "event": "Shared", "operator": "MEMBER", "ops": ["R"], "key": "topic" }
{ "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" }
{ "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" }

The key field constrains which KV slot the operator can access.

Operations for KV:
OpMeaning
CCreate or overwrite the value
RRead the current value
UUpdate an existing value
DClear the value (remove SMT leaf)
PPush delivery on value change
NNotify on value change

8.8.5 Delete (D)

  • Shared: Remove SMT leaf at H(0x02 || key). Value cleared.
  • Own: Remove SMT leaf at H(0x02 || key || commit.from). Only the actor's own slot.

8.8.6 Protocol-Reserved KV Keys

Gate state (Section 8.5) is stored as KV Shared with the gate: key prefix:

Shared("gate:applications") = true/false

Gate and lifecycle KV slots are protocol-managed — written by Gate and lifecycle events, not arbitrary Shared writes. Reserved keys: gate:* prefix and lifecycle.

8.8.7 Bounding

No dynamic keys. Only keys declared in schema entries can be written:

  • Shared: bounded by manifest (only declared keys)
  • Own: bounded by RBAC (one slot per key per identity, identities controlled by Move)

8.8.8 KV vs Content Events

AspectContent EventKV Event
HistoryFull history mattersOnly current value matters
SMTNo SMT entrySMT leaf tracks current hash
ProofsCT inclusion onlySMT inclusion proof for current state
Use caseMessages, reactionsTopic, settings, profiles, status

Both live in the same CT. KV events maintain a "current state" view in SMT 0x02.

8.8.9 Read API

  • Shared: GET /enclave/{id}/kv/{key}
  • Own: GET /enclave/{id}/kv/{key}/{identity}

The SMT provides inclusion proofs for the current value hash.