open-standards/wai/extensions/live-messaging.md
Transaction Science 01f3aac11f
Some checks failed
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 4s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 8s
wai-web wasm32 / does this change touch it (pull_request) Has been cancelled
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Has been cancelled
wai-quantum studio and desktop / does this change touch it (pull_request) Has been cancelled
wai-quantum studio and desktop / studio · browser (pull_request) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (pull_request) Has been cancelled
wai-pack / wai-pack · does this change touch it (pull_request) Has been cancelled
wai-pack / wai-pack · linux (pull_request) Has been cancelled
wai-pack / wai-pack · linux-arm64 (pull_request) Has been cancelled
wai-pack / wai-pack · macos (pull_request) Has been cancelled
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been cancelled
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been cancelled
wai-pack / wai-pack · dependency licences (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Has been cancelled
WAI energy receipts / does this change touch it (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI conformance / does this change touch it (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Has been cancelled
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Has been cancelled
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (pull_request) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
live messaging: regenerate the corpus, a removed member's held-back message
The corpus is regenerated from its seeds since history entries name whether
the principal was admitted. The room test now has a removed member post a
message it sealed before its removal: the service refuses it (not_a_member)
and a verifier refuses it (sender_not_member). The crate README names the
messages and call pages and their e2e scenarios. Clippy notes in the slice's
files are resolved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:38:21 -04:00

835 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WAI Extension: Live Messaging (conversations and calls on live rooms)
> Status: Draft. Registers two records. `wai.live.message`: a **conversation** is a room of
> [live-rooms](live-rooms.md) whose authority signs a conversation statement, `WMC1`, and
> which is end-to-end encrypted ([confidentiality](confidentiality.md)). Each member publishes
> its messages on a **messages track** of its own: each message one object, sealed under the
> room's MLS group and signed into the member's receipt chain as a room's media track is. A
> message is a signed record, `WMG1`, of one of five kinds: text, a reply in a thread, a
> reaction, an edit and a retraction, the last four naming the message they concern by its
> link hash. Membership is the room's: the clock records who the authority admits and
> removes, its proposals order every change, the members commit them, and the clock signs an
> **epoch statement**, `WME1`, naming each epoch's roster. Delivery and reading are signed
> acknowledgements, `WMA1`. A **history store** keeps the conversation's log, ciphertext,
> receipts and signed records only, so that a member that was offline catches up, and serves
> each principal only what it is owed. `wai.live.call`: a **call** is a room with a call
> descriptor, `WCT1`, signed by its authority; ringing is an invitation, `WCI1`, signed by the
> caller and held in the callee's inbox at the room service; accepting, declining, cancelling
> and a missed call are signed answers, `WCA1`; a call between two principals and a call among
> many are the same mechanism, and its media is the room's own audio and video tracks. Every
> record verifies offline. No new transport and no new receipt. The reference implementation
> is the `live_messaging` module of `wai-rs` (feature `live_messaging`); the corpus is
> [`live-conformance/messaging/`](../live-conformance/messaging/), and its second
> implementation is `live-conformance/live_messaging_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: the live family this extension builds on, as merged
> ([live-rooms](live-rooms.md), [live-events](live-events.md),
> [confidentiality](confidentiality.md), [live-recording](live-recording.md),
> [live-moderation](live-moderation.md)), [jwp-receipts](jwp-receipts.md) and
> [moq-streaming-format](moq-streaming-format.md); RFC 9420 (MLS) and RFC 9750 (the MLS
> architecture); `draft-ietf-moq-transport-22` (MOQT), an Internet-Draft, a section number of
> which in this text is that revision's; RFC 8032, RFC 3629 and FIPS 180-4.
## 1. Scope
In scope: the conversation statement, and how a conversation's lifecycle records membership
(§3); the messages track, its objects, their sealing and their receipts (§4); the message
record and its kinds (§5); threads, reactions, edits and retractions (§6); membership,
commits and the epoch statement (§7); acknowledgements (§8); the history store: what it holds,
what it takes, what each principal is owed (§9); calls: the call descriptor, the invitation,
the answers, the inbox and the media (§10); verification of a conversation (§11).
Out of scope: a transport; electronic mail, calendars and address books; interworking with
telephone networks; push notification services; how a principal obtains a grant; and how a
member's device keeps its group state between sessions (§7.5 states what it needs).
Relationship to the core spec: `wai.live.message` and `wai.live.call` are **records**
([SPEC](../SPEC.md) §5): signed records a WAI tool emits and verifies. They name no payload
format and are not dispatchable.
Relationship to [confidentiality](confidentiality.md): a conversation is an end-to-end
encrypted room, and everything that extension fixes holds in it, but for two rules: a
conversation's lifecycle records membership rather than presence (§3.4), and any member may
commit, the log ordering the commits (§7.2). This extension adds the messages track to the
tracks confidentiality §1 seals. A message is not a live-events record: the events tracks of
a conversation stay in the clear as confidentiality §1 leaves them, and carry the clock's
lifecycle alone.
## 2. Terms
- **Room**, **room id**, **room authority**, **clock**, **principal**, **grant**: as in
live-rooms §2.
- **Member**, **epoch**, **leaf index**, **roster**, **KID**: as in confidentiality §2 and
§5.7. An epoch is named by its **generation** and its number (confidentiality §5.1).
- **Conversation**: a room whose authority signs a conversation statement (§3) and a
confidentiality record (confidentiality §3).
- **Messages track**: a member's track of messages (§4.1). Its owner is the message's
**author**.
- **Message**: one object of a messages track. Its plaintext is a message record (§5).
- **Link hash** of a message: the link hash of its group receipt (jwp-receipts §3 step 3),
`BLAKE3("moq-jwp:receipt-link\x01" ‖ canonical_bytes(G))`. It names the message in the
clear, and every reference (§6) names a message by it.
- **Log**: the history store's sequence of the conversation's entries, each at a
**position** from 0 (§9.1).
- **Latest epoch** at a position: the epoch of the last epoch statement before that position
in the log.
- **Call**, **caller**, **invitee**: a room with a call descriptor (§10.1), the principal it
names as placing the call, each principal it names as invited.
- **Inbox**: the invitations and answers a room service holds for one principal (§10.4).
Integers are little-endian, as in the records of live-rooms. A *long* field is a u16 length
and that many bytes. Every signature is Ed25519 (RFC 8032), verified strictly as
[SPEC](../SPEC.md) §7 *Signatures* requires: a key that fails step 1 or 2 is `bad_key`, and a
signature that fails a later step is `bad_signature`.
Every record of this extension is read the same way: a reader reads exactly the bytes it is
given as one record, in the order of its layout, and refuses at the first of these that
holds: fewer than 4 bytes, `truncated`; a magic other than the record's, `malformed`; no byte
after the magic, `truncated`; a version other than 1, `unknown_version`; the bytes end before
a field the layout requires, `truncated`; bytes follow `sig`, `malformed`. The **digest** of a
record is SHA-256 (FIPS 180-4) over its bytes from `magic` up to `sig`, and its signature is
over its domain followed by those same bytes.
## 3. The conversation statement
### 3.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMC1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id (live-rooms §3.2) |
| `max_text` | 2 | u16: the most bytes of a text body (§5.3), from 1 to 16 384 |
| `max_reaction` | 1 | u8: the most bytes of a reaction body (§5.3), from 1 to 64 |
| `retention_days` | 2 | u16: the most days the history store keeps a message (§9.4); 0 states no bound |
| `sig` | 64 | §3.2 |
### 3.2 Signature
Ed25519 by the room descriptor's `authority` over the 22 bytes `wai:live-conversation`
(ASCII) and 0x01, followed by the bytes the digest covers.
### 3.3 Reading and rules
A verifier is given the room's descriptor, its confidentiality record (or none) and the
statement. It reads the descriptor (live-rooms §3.4: its reason, at `descriptor`), then, at
`record`: none given is `not_encrypted`; the record is checked as confidentiality §3.3 checks
it, with its reason. Then, at `statement`, in this order:
1. The statement reads (§2): `truncated`, `malformed` or `unknown_version`.
2. `max_text` is from 1 to 16 384 and `max_reaction` from 1 to 64: otherwise
`conversation_invalid`.
3. `room` is the room id: `wrong_room`.
4. The signature verifies under the descriptor's `authority` (§3.2): `bad_key` or
`bad_signature`.
A room has at most one conversation statement: a conversation that needs other bounds is
another room. A conversation is never recorded: its descriptor's recording `mode` is 0, which
confidentiality §3.3 step 5 already requires.
### 3.4 Membership is the room's lifecycle
In a conversation the room's clock records **membership**, not presence:
- **`join`**: when the authority admits a principal to the conversation, naming the grant it
admits it under (live-rooms §7.2). Every grant of a conversation may subscribe, so it is
keyed (confidentiality §2), and its principal is to be a member.
- **`leave`**: reason 1 when the member leaves the conversation, reason 2 when the authority
removes it. A member whose device is offline, or whose connection is lost, stays a member:
the clock records no `leave` for it, and reason 4 is not used. A grant's end is not a
`leave` either: a member whose grant ends asks the authority for another, and its
membership goes on.
The group follows the lifecycle as confidentiality §5.4 has it: the clock proposes the Add of
each principal it records joining and the Remove of each it records leaving. A grant of a
conversation authorises its principal's requests (to the history store, §9; to a relay,
live-rooms §10): it is not what makes it a member.
## 4. The messages track
### 4.1 Naming
A member `P` of conversation `R` publishes one messages track. With `R` and `P` as 64
lowercase hexadecimal digits, its Track Namespace is `[R, P, "messages"]` and its Track Name
is `0`; its receipts track (moq-streaming-format §4.1) is in the same namespace, named
`0_receipts`. The names keep live-rooms §6.1's rules. A messages track is not a media track:
it has no layer, profile or slot, and it is never composed. A grant of a conversation maps
onto one scope beyond live-rooms §10.2's, after its step 4: `[[2, 6], [R, P, "messages",
nil]]` (`PUBLISH_NAMESPACE` and `PUBLISH` of its messages track and that track's receipts
track); a relay that honours a room's grants refuses a PUBLISH under `[R, P, "messages"]` from
any session but one whose SETUP proved a grant for `P` in `R`.
A room service **MAY** carry a conversation's messages tracks itself, as confidentiality §15.2
lets it carry the group's messages: an object it carries keeps its Full Track Name, its Group
ID, its Object ID and its Publisher Priority, which its sealing binds (confidentiality §4.7),
and its receipts-track object, so nothing below changes with the carrier.
### 4.2 Objects
Each group of a messages track holds exactly one object, Object ID 0, of Publisher Priority
64: one message. The groups are numbered from 0 with no gap: the author's *n*th message is
group *n* − 1, whatever epoch it is sealed in.
Every object is sealed as confidentiality §4 seals the object of a media track, under the
author's keys of its current epoch (confidentiality §5.7), and opened as confidentiality §6
opens one. Its Immutable Properties hold the Key ID property alone; it carries no envelope
flag and no other property. The original payload (confidentiality §4.4) is the message record
(§5). Its plaintext **MAY** end with an Encrypted Properties List holding padding, which hides
a message's exact length from the store and every relay, and **MAY** carry
`WAI_INNER_DIGEST`; a receiver reads the list as confidentiality §4.4 reads it. A message is
sealed only in the latest epoch the author holds; one whose epoch has since ended is sealed
again in the new one before it is posted (§9.2).
### 4.3 Receipts
A messages track carries receipts exactly as a room's media track does
(moq-streaming-format §4.1, §5; confidentiality §8.1): each group's object is signed by the
author's key into a group receipt of one object ([jwp-receipts](jwp-receipts.md) §2.2, §2.3),
its content hash taken over the sealed payload, ciphertext and tag, as jwp-receipts §2.1 takes
it over an envelope; the group's receipts, in the binary form of jwp-receipts §2.7.3, are the
receipts track's object of the same group. Its receipts' `track_alias` (jwp-receipts §2.4.1)
is 5, the messages track's receipt alias.
The group receipt is in the **labelled** form (jwp-receipts §2.3, `moq-jwp:group-sign\x02`),
whose signing payload covers the parent link: a message's place in its author's chain is then
signed, and a reference by link hash (§6) names one message and its whole chain before it. A
group in the legacy form is refused `receipt_unlabelled`. Its figure is the unmetered marker:
`joules_total` 0, class tag 0, coverage `Whole`; its object receipt's leaf is labelled
unmetered (`leaf_version` 2, tag 0), `joules_micro` 0. A reader shows the marker as
unmetered, never as a figure of zero joules. Each group receipt's parent is the link hash of
the group receipt before it on the track, and group 0's is none.
### 4.4 What a relay and a store see
A relay, and the history store, see each message's track, its Group ID, its Key ID (and so the
epoch and the author's leaf), its size, its receipt and its link hash; they open nothing. The
plaintext, and with it the kind, the body and every reference, is the members' alone.
## 5. The message record
### 5.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMG1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `author` | 32 | the author's Ed25519 key: the messages track's owner |
| `group` | 8 | u64: the Group ID of the object the record is sealed in |
| `t` | 8 | u64: the room clock (live-events §2), in microseconds, when the author sent it |
| `kind` | 1 | §5.3 |
| `ref` | 32 | the link hash of the message it concerns (§6); 32 zero bytes for a text |
| `body` | long | §5.3 |
| `sig` | 64 | §5.2 |
`room`, `author` and `group` repeat the object's position, so that a record opened from one
place cannot be shown as another's (`message_mismatch`, §11.3). `t` is the author's claim, as
live-events §11 has every `t`, and it does not decrease along the author's track
(`time_reversed`).
### 5.2 Signature
Ed25519 by `author` over the 17 bytes `wai:live-message` (ASCII) and 0x01, followed by the
bytes the digest covers. The record is signed inside the seal, so a member that opened it can
show it to anyone, who verifies it with the author's key alone.
### 5.3 Kinds and bodies
| `kind` | name | `ref` | `body` |
|---|---|---|---|
| 1 | `text` | 32 zero bytes | UTF-8 (RFC 3629), 1 to `max_text` bytes |
| 2 | `reply` | the thread's root (§6.1) | as `text` |
| 3 | `reaction` | the message reacted to (§6.2) | UTF-8, 1 to `max_reaction` bytes |
| 4 | `edit` | the message edited (§6.3) | as `text`: the message's new text |
| 5 | `retraction` | the message retracted (§6.4) | empty |
A kind outside the table is `unknown_kind`. A body that is not UTF-8 is `bad_utf8`; one longer
than its bound is `text_too_long`; an empty body where the table asks for one, and a body
beside a retraction, are `body_invalid`. A `text` with a `ref` that is not zero, and any other
kind with a zero `ref`, are `ref_invalid`.
## 6. References
A reply, a reaction, an edit and a retraction each name an earlier message of the
conversation by its link hash (§2), its **target**. The target is a message accepted before
it in the verification order (§11.3): otherwise `ref_unknown`, which includes a message the
verifier refused, and one later in the log. A target that a retraction accepted before has
retracted is `ref_retracted`, for every kind.
### 6.1 Threads
A reply's target is the **root** of its thread: a `text`. A target of another kind is
`thread_not_root`, so a thread is one level deep, and every reply of a thread names the same
root. A thread is its root and its replies, in the verification order.
### 6.2 Reactions
A reaction's target is a `text` or a `reply`: otherwise `target_kind`. Its body is the
reaction, a short string the sink shows as it is. A member's second reaction with the same body
to one target, while its first is not retracted, is `reaction_repeated`. Removing a reaction
is retracting it (§6.4).
### 6.3 Edits
An edit's target is a `text` or a `reply` (`target_kind`) whose author is the edit's
(`edit_not_author`): a member edits only what it wrote. The target's **current text** is the
body of the last edit accepted for it, or its own body when there is none.
A verifier keeps the target and every edit of it, each verified with its own receipt and
signature, and states the target's current text and its number of edits. The original stays in
the conversation, verified: an edit changes what a sink shows, and nothing a member holds. A
sink **MUST** show a message with its current text, and **MUST** show that it was edited.
### 6.4 Retractions
A retraction's target is a `text`, a `reply` or a `reaction` (`target_kind`) whose author is
the retraction's (`retract_not_author`).
A retraction **states**; it removes nothing from verification. The verifier keeps the target in
the conversation, verified as it was, with its receipt, its link hash and its place in its
author's chain, and states it **retracted**, naming the retraction by its link hash. A
retracted message has no current text: a sink **MUST NOT** show its body, its edits or (for a
reaction) the reaction, and **MAY** show that a message was retracted. Its link hash still
chains the author's track, so the messages after it verify, and a reference to it is
`ref_retracted` (§6). The ciphertext stays in the history store until its retention ends (§9.4):
a retraction is the author's statement, and a member that opened the message before keeps what
it opened.
## 7. Membership and epochs
### 7.1 The group
A conversation's group is confidentiality §5's: one MLS group (RFC 9420) at a time, whose
group ID is the room id and a generation, whose credentials are the principals' room keys
(confidentiality §5.2), whose one external sender is the room's clock (§5.3 there), which
proposes every Add and Remove the lifecycle calls for (§5.4 there) and holds no epoch's
secret; each epoch's per-sender keys are derived by the exporter (§5.7 there). The room
service carries the group's messages in one order, as confidentiality §15.2 has it, in the
conversation's log (§9.1).
### 7.2 Who commits
In a conversation, **any member that a commit does not remove may commit the clock's pending
proposals**, and the log orders the commits: the first commit for an epoch that the clock's
checks pass is carried, every other for that epoch is refused, and a member applies a commit
only when the log returns it (confidentiality §15.2). This replaces confidentiality §5.5's rule
of the lowest leaf; every other check of §5.5 holds: a commit holds by reference the clock's
first *k* proposals for the epoch, each once (`proposal_not_clock`, `commit_mismatch`), and
carries an UpdatePath. A commit by a sender that is not a member of the epoch, or that the
commit removes, is `commit_not_committer`.
Why: confidentiality §5.5's rule heals by removing a committer that stops responding, through a
`leave` for its lost connection. A conversation records no such `leave` (§3.4), so a lowest
member that is offline would hold every change back until it returned. Any member committing,
in the log's order, keeps every change moving while one member is online, and the log makes
two commits for one epoch impossible to apply both.
### 7.3 The epoch statement
Each time the log carries a GroupInfo that creates the group, or a commit, the clock signs an
**epoch statement** for the epoch it opens and the log carries it right after. Members check
each against the epoch their group reached; an offline verifier, which runs no MLS, takes each
epoch's roster from it.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WME1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `generation` | 4 | u32: the group's generation (confidentiality §5.1) |
| `epoch` | 8 | u64: the epoch it opens, below 2^40 |
| `opened_by` | 32 | SHA-256 of the framed GroupInfo (epoch 0) or the framed commit that opened it, as the log carries them (confidentiality §5.1) |
| `prev` | 32 | the digest of the conversation's previous epoch statement; 32 zero bytes for the first |
| `roster` | 4 + 36 × *n* | a u32 count *n*, then each member: its leaf index (u32, below 2^24) and its key (32), in ascending order of leaf index |
| `sig` | 64 | Ed25519 by the descriptor's `clock` over the 15 bytes `wai:live-epoch` (ASCII) and 0x01, followed by the bytes the digest covers |
A verifier takes the conversation's epoch statements in the log's order, and refuses at the
first of these that fails, at the statement's position:
1. It reads (§2): `truncated`, `malformed` or `unknown_version`.
2. Its `room` is the room id: `wrong_room`.
3. Its signature verifies under the descriptor's `clock`: `bad_key` or `bad_signature`.
4. `epoch` is below 2^40; the roster has at least one member, its leaf indexes strictly
ascending and each below 2^24, and no key twice: otherwise `epoch_invalid`.
5. `prev` is the digest of the epoch statement before it in the log, or 32 zero bytes for the
first: `epoch_unlinked`.
6. The first is epoch 0 of any generation; each later is the next epoch of the same
generation, or epoch 0 of a higher one: `epoch_out_of_order`.
A member **MUST** refuse an epoch statement whose generation, epoch or roster is not the one
its group reached when it applied the GroupInfo or commit the statement names, and then stop
reading the log: the log is not the conversation's. The clock follows the group from its
public handshake messages and checks every commit as a member does (confidentiality §5.3,
§15.2), so the roster it states is the one every member computes (confidentiality §5.6).
### 7.4 Removal
A principal is **removed** at the first epoch statement after one that lists it, whose roster
does not list it. The epoch it opens is the first whose keys exclude it (confidentiality §12),
and from its position on the history store takes nothing of the principal's (§9.2) and serves
it nothing more (§9.3).
### 7.5 A member that was offline
A member keeps its group state while its device is offline, and when it returns reads the log
from the position it stopped at: every handshake message in order, each commit applied as it
comes, each epoch statement checked against the epoch it reaches, and each message opened with
the keys of the epoch its KID names. Since the store takes a message only in the latest epoch
(§9.2), every message of an epoch stands in the log before the epoch statement of the next, and
a member deletes an epoch's keys once it has read that statement (confidentiality §5.8). How a
device keeps its group state between sessions is the device's: a member that has lost it is a
principal whose keys are gone, and it opens nothing it held before, as confidentiality §12 says
of any member.
## 8. Acknowledgements
A member acknowledges that it holds, or has shown, every message of one author's track up to a
group, with a record it signs in the clear. An acknowledgement names messages by their link
hash and position alone, never their content.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WMA1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `member` | 32 | the acknowledging member's key |
| `author` | 32 | the author of the track it acknowledges |
| `through` | 8 | u64: the last group of `author`'s track it acknowledges |
| `link` | 32 | the link hash of `author`'s message at `through` |
| `kind` | 1 | 1 **delivered**: the member holds every message of the track up to `through`, opened; 2 **read**: it has shown them |
| `t` | 8 | u64: the room clock, in microseconds, when it was signed |
| `sig` | 64 | Ed25519 by `member` over the 21 bytes `wai:live-message-ack` (ASCII) and 0x01, followed by the bytes the digest covers |
An acknowledgement is cumulative: it covers every group of the track up to `through`. A
verifier checks each, in the log's order, and refuses one at the first of these that fails:
1. It reads (§2): `truncated`, `malformed` or `unknown_version`.
2. Its `room` is the room id: `wrong_room`.
3. Its signature verifies under `member`: `bad_key` or `bad_signature`.
4. `kind` is 1 or 2: `ack_invalid`.
5. `member` is in the roster of the latest epoch at its position: `ack_not_member`.
6. `author`'s track holds, earlier in the log, a message at group `through` that passed
§11.3 step 6, whose link hash is `link`: `ack_unknown`. A verifier without the epoch's secret
checks an acknowledgement as one with it does.
7. `through` is at least that of the last acknowledgement of the same `member`, `author` and
`kind` the verifier accepted: `ack_regressed`.
A refused acknowledgement changes nothing. For each author and each member, the verifier states
the last group acknowledged delivered and the last acknowledged read. A read implies delivery:
a member that has shown a message holds it.
## 9. The history store
### 9.1 What it holds
A history store keeps one **log** per conversation: one sequence of entries, each at a position
from 0, in the order the store took them:
- the clock's lifecycle records, as it signed them (live-events §3);
- the group's handshake messages, framed (confidentiality §5.1): the clock's proposals, each
GroupInfo, commit and Welcome a member posts;
- the clock's epoch statements (§7.3);
- messages: for each, its author, its Group ID, its property list as carried, its sealed
payload and its receipts-track object (§4);
- acknowledgements (§8).
It holds **no plaintext and no key**: nothing in it opens without an epoch's secret, which only
members derive (confidentiality §5.7), and a Welcome is encrypted to the member it adds. What it
holds of each message is what a relay forwards (§4.4). A store **MUST NOT** take a message as
anything else, nor keep a plaintext a member sends it.
### 9.2 What it takes
A store takes an entry only after the checks a verifier of the conversation makes that need no
key, against the log as it stands; a post that fails one is refused with its reason and not
taken:
- a handshake message: confidentiality §15.2's checks, with §7.2's rule of who commits;
- a message, posted by a request its author authorised (live-rooms §10.5.3), in this order:
the author is in the roster of the latest epoch (`sender_not_member`; `epoch_unknown` before
any epoch); its Key ID reads (confidentiality §6 step 1); its KID names the latest epoch
(`epoch_stale`), at a leaf the roster gives the author (`key_id_unknown`,
`sender_not_owner`); its Group ID is the author's next (`message_gap`); its receipts verify
as §11.3 step 6 has them;
- an acknowledgement, posted by a request its member authorised: §8's checks.
The store answers a post only once the entry is on stable storage: an entry it acknowledged
taking survives the store's restart. It keeps every entry at its position; a restart neither
reorders nor renumbers the log.
### 9.3 What a principal is owed
The store serves a principal `P` the log from a position it names, authorised by a grant of the
room for `P` and its proof of possession over a challenge the store issued (live-rooms §10.5.3).
`P` is **owed**:
- every **membership entry** (lifecycle record, handshake message, epoch statement) from the
conversation's start up to, and including, the epoch statement that removes it (§7.4), once
the clock has recorded its `join`. A principal admitted and not yet a member reads them to
follow the group from outside until its Welcome (confidentiality §15.2);
- every **content entry** (message, acknowledgement) at a position whose latest epoch lists
`P` in its roster: what was sealed and acknowledged while it was a member, and nothing
before or after.
`P` is **admitted** while the clock's last lifecycle record of it is a `join`. The store
answers with the entries owed from that position, each with its position, and with the
positions it withheld. It refuses the request `not_a_member` when `P` is not admitted and is
owed no content entry at or after that position: a principal never admitted, and a removed
member asking past its removal. An admitted principal with nothing new, a member's or one
waiting for its Welcome, is answered with nothing, not refused.
This is how a member proves it is owed a range: its key, by the proof; its membership in each
epoch, by the clock's epoch statements the store holds, which list it. A store **MUST NOT**
serve a content entry to a principal the rule does not owe it, whatever grant it presents: the
ciphertext would open nothing for it, but its sizes, timing and authors are the members'
metadata.
### 9.4 Retention
A store keeps every entry while the conversation lasts, but that it **MAY** drop a message
older than the statement's `retention_days`, counted from when the store took it, as a whole
entry: its position stays, empty, and a reader is told the position was dropped. It never drops
a lifecycle record, a handshake message, an epoch statement or a message's receipts while the
conversation lasts: a member's chain of link hashes, and every reference into it, stays
checkable after a message's ciphertext is gone.
## 10. Calls
A **call** is a room of live-rooms with a call descriptor. Its media is the room's own: each
participant that enters publishes its audio and video tracks (live-rooms §6.1) under a grant,
and the clock records its `join` and `publish` as in any room. A call between two principals
and a call among many differ only in how many invitees the descriptor names.
### 10.1 The call descriptor
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCT1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the call's room id |
| `caller` | 32 | the caller's key |
| `media` | 1 | bit 0 audio, bit 1 video: what the call carries |
| `ring_ms` | 4 | u32: how long an invitation rings, in milliseconds, from 1 000 to 300 000 |
| `invitees` | 1 + 32 × *n* | a u8 count *n* from 1 to 32, then each invitee's key |
| `sig` | 64 | Ed25519 by the descriptor's `authority` over the 14 bytes `wai:live-call` (ASCII) and 0x01, followed by the bytes the digest covers |
A verifier reads the room's descriptor (live-rooms §3.4, at `descriptor`), then the call
descriptor, at `call`, in this order: it reads (§2); `media` is 1, 2 or 3, `ring_ms` within
its bounds, the invitees 1 to 32, no two the same and none the caller, otherwise
`call_invalid`; `room` is the room id, `wrong_room`; its signature verifies, `bad_key` or
`bad_signature`.
### 10.2 The invitation
The caller rings each invitee with an invitation it signs.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCI1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the call's room id |
| `call` | 32 | the call descriptor's digest |
| `caller` | 32 | the caller's key |
| `callee` | 32 | the invitee rung |
| `t` | 8 | u64: the room clock, in microseconds, from which it rings |
| `sig` | 64 | Ed25519 by `caller` over the 21 bytes `wai:live-call-invite` (ASCII) and 0x01, followed by the bytes the digest covers |
It rings until `t` + `ring_ms` × 1 000, its **deadline**. A verifier takes the invitations in
order and refuses at the first of these that fails, at the invitation's index: it reads;
`room`, `call` and `caller` are the room id, the descriptor's digest and its caller
(`invitation_mismatch`); its signature verifies under `caller` (`bad_key`, `bad_signature`);
`callee` is an invitee (`callee_not_invited`); no earlier invitation rang it
(`already_invited`).
A callee's device **MUST** verify the invitation and the call descriptor it names, and their
signatures, before it rings: an invitation that does not verify is not shown.
### 10.3 Answers
Every invitation ends with one signed answer:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCA1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the call's room id |
| `invitation` | 32 | the invitation's digest |
| `signer` | 32 | the key that signs it |
| `kind` | 1 | 1 **accept**, 2 **decline**, 3 **cancel**, 4 **missed** |
| `t` | 8 | u64: the room clock, in microseconds |
| `sig` | 64 | Ed25519 by `signer` over the 21 bytes `wai:live-call-answer` (ASCII) and 0x01, followed by the bytes the digest covers |
An accept or a decline is the callee's, a cancel the caller's, and a missed call the room
clock's: the clock signs `missed` for an invitation unanswered at its deadline, so a missed
call is recorded rather than inferred from silence. A verifier takes the answers in order and
refuses at the first of these that fails, at the answer's index:
1. It reads (§2); its `room` is the room id (`wrong_room`).
2. `invitation` is the digest of an invitation it holds: `answer_unknown`.
3. `kind` is 1 to 4: `answer_invalid`.
4. `signer` is the one its kind names: the invitation's `callee` for 1 and 2, its `caller` for
3, the descriptor's `clock` for 4: `answer_wrong_signer`.
5. Its signature verifies under `signer`: `bad_key` or `bad_signature`.
6. No earlier answer ended that invitation: `already_answered`.
7. `t` is at least the invitation's `t`: `answer_early`. An accept, a decline or a cancel is
before the deadline (`answer_late`); a missed call is at or after it (`missed_early`).
Each invitation's state is then `ringing` (no answer yet), `accepted`, `declined`,
`cancelled` or `missed`, with the answer's `t`.
### 10.4 The inbox
A room service that keeps calls holds, for each principal, an **inbox**: every invitation that
names it as callee, the call descriptor each names, and every answer to them, the clock's
`missed` among them. It serves a principal its inbox on a proof of possession of its key over a
challenge the service issued (live-rooms §10.5.3), in which the proof's `grant` is 32 zero
bytes: a callee holds no grant of a call it has not answered. The service takes an invitation
into an inbox only when §10.2's checks pass and the caller's request presented the caller's
grant and proof; it takes an answer only when §10.3's pass and the request proved the signer's
key. Nothing in an inbox is the service's statement but the `missed` answers: every other
record in it is signed by the principal it names.
### 10.5 Media
A principal enters a call's room as a publisher under a grant (live-rooms §4), which the
authority issues to the caller, and to an invitee only once it holds the invitee's accept. Its
audio and video tracks are the room's (live-rooms §6.1), and in an end-to-end encrypted call
are sealed as confidentiality has it. A call ends as a room does: when its last participant
leaves.
## 11. Verifying a conversation
### 11.1 Inputs
A verifier is given the room's descriptor, its confidentiality record, the conversation
statement, and the log's epoch statements, messages and acknowledgements in the log's order,
each with its position. It is not given the lifecycle records or the handshake messages: the
epoch statements carry what it needs of them (§7.3). To open the messages it is given, for each
epoch whose messages it opens, that epoch's application secret (confidentiality §5.7); a member
holds those of its own epochs. A message of an epoch whose secret it is not given is checked
through step 6 below and stated `sealed`.
### 11.2 The statement and the epochs
It checks the descriptor, the record and the statement (§3.3), and every epoch statement
(§7.3) at its place in the log, and refuses the conversation at the first failure, naming
where: `descriptor`, `record`, `statement`, or the epoch statement's position.
### 11.3 Each message
Each message, in the log's order, is **accepted** or **refused** with a reason; a refusal does
not stop the verification, and a refused message is not a target (§6). It runs these steps and
stops at the first that fails:
1. An epoch statement precedes it: `epoch_unknown`.
2. The author is in the roster of the latest epoch: `sender_not_member`. A removed member's
message after its removal fails here.
3. The property list reads and holds one Key ID inside the Immutable Properties
(confidentiality §6 step 1): `properties_malformed`, `key_id_missing`.
4. The KID's epoch (confidentiality §5.7) is the latest epoch's number: `epoch_stale`. Its leaf
index is in that epoch's roster (`key_id_unknown`), and the member there is the author
(`sender_not_owner`).
5. Its Group ID is the group after the author's last message that passed this step and step
6, or 0 for its first: `message_gap`. A refused post takes no group (§9.2), so its author
posts that group again.
6. Its receipts-track object reads, holds one group receipt and one object receipt of that
group (`malformed`), in the labelled form (`receipt_unlabelled`), signed by the author's
key, with `track_alias` 5, both naming the Group ID, the object receipt Object ID 0
(`moqt_mismatch`); and it verifies by jwp-receipts §3 against the sealed payload, with the
reason that section names: the group's signature, the content hash, the object's proof, the
group's total, and the chain, the group receipt's parent the link hash of the group receipt
of the author's last message that passed this step, and group 0's none. The verifier keeps
that receipt alone (jwp-receipts §3 step 3): a parent where none is held, and a parent that
is not its link hash, are `parent_not_held`; none where one is held is `chain_break`.
7. With the epoch's secret, it derives the author's track base key for the namespace
`[R, author, "messages"]` (confidentiality §5.7) and the key and salt of the object, Track
Name `0`, under the KID (§4.5 there), and opens the sealed payload with the nonce and AAD of
Object ID 0 at priority 64 (§4.6, §4.7, §4.10 there): `bad_tag`. The plaintext reads
(§4.4 there): `plaintext_malformed`; where it carries `WAI_INNER_DIGEST`, the digest is the
original payload's (§6 step 11 there): `inner_digest_mismatch`. Without the secret, the
message is `sealed`, and steps 8 to 13 are not run.
8. The original payload reads as a message record (§2: `truncated`, `malformed`,
`unknown_version`) and its signature verifies under `author` (`bad_key`, `bad_signature`).
9. Its `room`, `author` and `group` are the room id, the track's owner and the Group ID:
`message_mismatch`.
10. Its `t` is at least that of the author's last message that passed this step:
`time_reversed`.
11. Its kind and body keep §5.3: `unknown_kind`, `bad_utf8`, `text_too_long`, `body_invalid`,
`ref_invalid`.
12. Its target keeps §6 for its kind: `ref_unknown`, `ref_retracted`, `thread_not_root`,
`target_kind`, `reaction_repeated`, `edit_not_author`, `retract_not_author`.
13. It is accepted, and applied: a text is a message of the conversation; a reply joins its
root's thread; a reaction is added to its target's; an edit sets its target's current
text; a retraction marks its target retracted, and a retracted reaction leaves its
target's reactions.
A message refused at steps 1 to 6 does not advance its author's track: the next is checked
against the last message that passed step 6, its group and its group receipt. One refused at
steps 7 to 12 has a receipt that verified, and its group and link hash chain the track as any
other.
### 11.4 Each acknowledgement
Each acknowledgement, in the log's order, by §8.
### 11.5 The outcome
A verification that refuses states the reason and where (§11.2). One that does not states:
- every epoch: its generation, its number and its roster;
- every message, at its position: its author, its Group ID, its link hash where its
receipts-track object reads, and its outcome:
accepted, `sealed`, or the reason it was refused; for one accepted, its kind, its `t` and
its target, and, for a text or a reply, its status (`shown`, `edited` or `retracted`), its
current text (none once retracted), its number of edits, its reactions (each member and
body, in the order accepted, retracted ones left out), the retraction that retracted it, and
for a root the replies of its thread; for an accepted reaction, whether it was retracted;
- every acknowledgement, at its position: accepted, or its reason;
- for each author, for each member, the last group acknowledged delivered and read.
## 12. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `conversation_invalid` | §3.3 step 2 |
| `not_encrypted` | §3.3 |
| `epoch_invalid` | §7.3 step 4 |
| `epoch_unlinked` | §7.3 step 5 |
| `epoch_out_of_order` | §7.3 step 6 |
| `epoch_unknown` | §9.2, §11.3 step 1 |
| `sender_not_member` | §9.2, §11.3 step 2 |
| `epoch_stale` | §4.2, §9.2, §11.3 step 4 |
| `message_gap` | §4.2, §9.2, §11.3 step 5 |
| `receipt_unlabelled` | §4.3, §11.3 step 6 |
| `message_mismatch` | §5.1, §11.3 step 9 |
| `body_invalid` | §5.3, §11.3 step 11 |
| `ref_invalid` | §5.3, §11.3 step 11 |
| `ref_unknown` | §6, §11.3 step 12 |
| `ref_retracted` | §6, §6.4, §11.3 step 12 |
| `thread_not_root` | §6.1 |
| `target_kind` | §6.2, §6.3, §6.4 |
| `reaction_repeated` | §6.2 |
| `edit_not_author` | §6.3 |
| `retract_not_author` | §6.4 |
| `ack_invalid` | §8 step 4 |
| `ack_not_member` | §8 step 5 |
| `ack_unknown` | §8 step 6 |
| `ack_regressed` | §8 step 7 |
| `not_a_member` | §9.3 |
| `call_invalid` | §10.1 |
| `invitation_mismatch` | §10.2 |
| `callee_not_invited` | §10.2 |
| `already_invited` | §10.2 |
| `answer_unknown` | §10.3 step 2 |
| `answer_invalid` | §10.3 step 3 |
| `answer_wrong_signer` | §10.3 step 4 |
| `already_answered` | §10.3 step 6 |
| `answer_early` | §10.3 step 7 |
| `answer_late` | §10.3 step 7 |
| `missed_early` | §10.3 step 7 |
It also raises codes other extensions define: `truncated`, `malformed`, `unknown_version`,
`wrong_room`, `bad_key` and `bad_signature` for a record that does not read, is of another room
or does not verify; `unknown_kind`, `bad_utf8`, `text_too_long` and `time_reversed` as
live-events §9 names them, for a message's kind, body and `t`; live-rooms §11's for a
descriptor; confidentiality §13's for the confidentiality record (§3.3), for a message's Key ID
and opening (`properties_malformed`, `key_id_missing`, `key_id_unknown`, `sender_not_owner`,
`bad_tag`, `plaintext_malformed`, `inner_digest_mismatch`), and for a commit (`commit_not_committer`,
`proposal_not_clock`, `commit_mismatch`, §7.2); and jwp-receipts §3.3's for a message's
receipts (§11.3 step 6).
## 13. Conformance
[`live-conformance/messaging/`](../live-conformance/messaging/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_live_messaging_vectors gen`. Its keys and
epoch secrets are derived from fixed seeds; the epoch secrets stand where an MLS exporter
gives them (confidentiality §14's boundary: the corpus runs no MLS), and every message is
sealed under them by confidentiality §4, so a verifier opens every message it is given a
secret for. It has:
- **Conversations**, each a descriptor, a confidentiality record, a statement, the epoch
secrets given and the log's entries, with the outcome §11.5 states:
- accepted: three members exchanging texts; a thread with replies and reactions; an edit and
a retraction, of a text and of a reaction; a member removed, its message after the removal
refused `sender_not_member` and its earlier messages accepted; a member that joins later;
a message of an epoch whose secret the verifier is not given, stated `sealed`;
acknowledgements of delivery and reading, and one of each refusal of §8;
- one message refused for each reason of §11.3 that a message can raise, each a control of
one defect: an edit of another author's message, a retraction of another's, a reaction to
a retracted message, a reply to a reply, a reaction repeated, a body that breaks §5.3, a
record whose position differs from its object's, a record signed by another key, a Key ID
of a stale epoch and of another member's leaf, a gap, a receipt in the legacy form, a
receipt whose chain breaks, a payload changed after it was signed, and a sealed payload
that does not open;
- refused: each reason of §3.3 and §7.3.
- **History**, in the file's `history`: requests against a conversation's log, each naming the
requester, whether it is admitted, and the position it reads from, with the content
positions it is owed or `not_a_member`: a member, a member that joined later (owed nothing sealed before it joined),
a removed member before and after its removal, and a principal never a member.
- **Calls**, in the file's `calls`: a call between two principals rung and accepted; a call
among three in which one accepts, one declines and one is missed; a call cancelled; and one
refused for each reason of §10.1 to §10.3.
- **Records**, in the file's `records`: each record of this extension, and a copy of it for each
reading refusal of §2.
A conforming verifier gives every vector the outcome the corpus states.
`wai_live_messaging_vectors verify` and the test `tests/live_messaging.rs` check that the file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, and that §12 lists
exactly the reference's codes. `live-conformance/live_messaging_verify.py` is the second
implementation, written from this text: it shares no code with `wai-rs`; it reads descriptors
with the live-rooms corpus's own second implementation (`live_rooms_verify.py`), checks
confidentiality records, reads Key IDs and opens sealed objects with the confidentiality
corpus's (`e2ee_verify.py`), verifies signatures with WAI's strict rule written from RFC 8032
(`claim-conformance/_ed25519.py`) and computes BLAKE3 with `claim-conformance/_blake3.py`; it
gives every vector the same outcome, and requires every mutated copy of the corpus to fail.
The room service `joule-moq-room` keeps conversations and calls (`[messaging]`, `[call]`), and
its tests run them with members keying through real MLS groups (`joule-moq-mls`) and sealing
with `joule-moq`'s secure objects: three members exchange messages, one is offline across a
restart of the service and catches up from its history store, a thread, a reaction, an edit and
a retraction are verified by `wai-rs`, a removed member opens nothing sealed after its removal
and is refused what it is not owed, and a call rings, is accepted and carries audio and video,
while another is missed.
## 14. Security and privacy considerations
- **Who reads.** The members of an epoch, and no one else: not the relay, not the store, not
the clock, not the authority (confidentiality §12). The authority decides who is admitted, so
an authority that wants to read admits a principal of its own, which every epoch statement
then lists.
- **Who wrote.** Every message is signed twice by its author: its record inside the seal, so a
member can show what it opened to anyone; its receipt over the ciphertext in the clear, so the
store and every relay hold each author to its own track. Within the group every member holds
every sender's keys (confidentiality §12); the owner check (§11.3 step 4) and the two
signatures are what keep a member from writing as another.
- **Removal.** A removed member keeps the keys of the epochs it held and what it opened in them.
It opens nothing of a later epoch, the store takes nothing more from it and serves it nothing
sealed after its removal (§9.2, §9.3), and its earlier messages stay verified.
- **What retraction and editing are.** A statement by the author that a sink honours. A member
that opened a message before its retraction, or its text before an edit, keeps them; the
original record verifies for anyone who holds it. A sender that needs a message never to
persist does not send it.
- **Metadata.** The store, and a relay, see who posts, when, how much and in which epoch, the
membership over time, the acknowledgements and the link hashes (§4.4, §8); acknowledgements
are in the clear so that a sender and the store can check them. A member that needs its
acknowledgements unseen sends none: acknowledging is a member's act, and this extension adds
no consent step to taking part in a conversation. Where a regime asks the operator of a store
for a lawful basis for what it keeps, the operator records it as a `lawful-basis` claim
([jwp-receipts](jwp-receipts.md) §2.4.6).
- **The store's order.** The store orders the log and can withhold or delay an entry, as a
relay can; it cannot forge or change one: every entry is signed by its author, the clock or a
member, each message's chain is signed, each epoch statement chained, and a member checks each
epoch statement against its own group (§7.3).
- **The clock's statements.** An epoch statement is the clock's; a member checks it against the
MLS group it holds, and an offline verifier takes it as the clock's signed word, as it takes
the clock's lifecycle (live-rooms §13).
- **Calls.** An invitation is the caller's, and its call descriptor the authority's: a device
rings only for an invitation whose signatures verify (§10.2), so a party that is neither
cannot make a device ring. A missed call is the clock's statement that no answer reached it by
the deadline. An inbox is served only on a proof of its owner's key.
- **Replay.** A message record names its room, author and group, and its seal binds its track
and position (confidentiality §4.7); an acknowledgement and an answer name their room; an
invitation names its call's digest. None can be moved to another conversation or call.