De-mls boundary contract

de-mls is a library that runs one conversation: MLS group state, consensus-driven
membership, and the timing that holds the two together. It performs no I/O and
keeps no clock. An application drives it.

Terms used throughout:

  • Application — whatever consumes de-mls: the chat client, gateway or
    service that owns identity, transport and storage.
  • Conversation — one group, one handle. de-mls runs exactly one per group.
  • Steward — the member whose turn it is to produce a commit.
  • Round — the window in which candidate commits are collected and one of
    them is chosen.
  • Bootstrap — the protocol state a joiner needs alongside the MLS welcome.

This document states what de-mls guarantees at each of its boundaries — to the
application, to the consensus service, to MLS — and what the application does to
hold up its side. Only what crosses a boundary is in scope: no internals,
no module layout, no file structure.

Principles

Each names a property of the boundary. The first seven are de-mls’s to
guarantee. The eighth is not de-mls’s alone.

  1. Passivity. de-mls has no network and no clock. It cannot send, receive,
    or notice that time has passed, and it moves only when the application calls it.

  2. Fixed outputs. Everything de-mls produces is one of three things: an
    event, a payload for the application to publish, or an answer to a call the
    application made. Nothing reaches the application by another route.

  3. Facts are events. An answer says whether the call was taken and, if not,
    what blocked it and when to try again. That is all it says, and it requires
    nothing of the application. Events say what happened in the group and what to
    do next, and most of them require something. For example error from add_member
    answers for the request; "member added" is an event.

  4. Sole ownership. Storage, identity and transport belong to the
    application; the group handle and every protocol decision belong to de-mls.
    If something seems to need two owners, the boundary is in the wrong place.

  5. Mechanism, not policy. de-mls is the computation protocol based on RFC:
    from the inputs it works out the result — who stewards, whether a vote passed,
    which commit lands. What to do about a result is the application’s:
    whether this person belongs in the group, whether a score warrants a removal,
    whether to act on recovery. de-mls makes the change; asking for it is the application’s.

  6. Declared defaults. Some deadlines cannot wait for an answer, so de-mls
    has a default. The event announcing the choice carries that default and the
    moment it applies. A default the application cannot see is a decision made in its name.

  7. Determinism. Members holding the same state and the same inputs reach the
    same result: the same commit merged, the same steward for an epoch, the same
    members added or removed.

  8. Input agreement — no single owner. Determinism holds only where members
    compute over the same inputs, and no layer can promise that on its own. The
    property is composed: the transport delivers best-effort and sets the latency
    and the loss; de-mls holds each round open long enough that what was sent has
    a chance to arrive; the application sizes those windows, since only it can see
    the network they are measured against. MLS adds nothing here — it says nothing
    about who received what. Together the layers produce a probability, not a
    certainty, and that probability is not quantified. Where it falls
    short, two members compute correctly over different inputs and merge
    different commits; the group has split, which comparing epoch authenticators
    reveals and recomputation does not repair.

A. Application ↔ de-mls

de-mls runs only when the application calls it. There are four reasons to call:

What happened The call
A conversation is needed create / join / restore
The user did something send, add, remove, leave, vote, recovery actions
A packet arrived handle_packet
A deadline came due tick

de-mls has deadlines of its own — a vote closing, a commit window ending, a
silent steward that has to be covered for — but no clock to watch them with. So
every call hands back the time remaining until the next one, and the application sets its
timer to it. Stop calling tick and nothing expires: the group stops making
progress.

Every one of these takes the application’s MLS provider and signer, borrowed for
the call and not held after it.

After any call, the application collects the same two things. Whichever of
the four reasons prompted it, the outputs are the same — events, and payloads to
publish — so one piece of code handles everything that comes back.

Both have to be taken, and they are not equally urgent. An undrained event
buffer only delays the application. An undrained outbound queue stalls the
group: nothing de-mls produced ever reaches anyone, and no error says so.

Conversations are independent. Nothing passes between them inside de-mls,
and holding several is the application’s arrangement, not de-mls’s.

One caller at a time. A conversation is driven by one caller at a time;
de-mls has no internal concurrency and takes no locks on the application’s
behalf.

Bytes out

Outbound { conversation, echo_tag, payload }
Field What it is
payload A complete de-mls message, opaque to the application
conversation The group it belongs to
echo_tag The sending instance, in the clear

The application never encrypts. A payload is finished when it arrives —
ready for the wire, protected already, addressed already. Wrapping it in the
application’s own framing is expected; unwrapping, re-encoding, splitting or
merging it is not.

Publish promptly. Windows are measured in real time (principle 8), so a
payload held back costs the group agreement.

Nothing is retried. de-mls does not resend, does not track what arrived, and
never learns that something was lost. Fan-out, retries, reachability and
batching belong to the application, and a payload dropped on its side is simply
gone.

conversation is there for batching. Draining one conversation at a time,
the application already knows where its payloads go. Funnelling many into one
publish path, the field saves re-keying them.

Echoes are dropped first. handle_packet begins by asking whether the
packet is the node’s own. If it is, the call returns having done nothing: no
decryption, no state touched, no event. The echo tag is what makes that question
cheap to answer — it rides in the clear and comes back on handle_packet, so
the check happens before any work.

Events out

enum Event {
    Required(...),   // the protocol needs something
    Optional(...),   // de-mls has a default and proceeds without an answer
    Info(...),       // nothing to do
}
Kind Examples
Required a message to deliver; a welcome to carry to the joiners; the member set changed; the conversation closed
Optional a vote is due; recovery opened; a score moved
Info phase changed, commit applied, consensus resolved, round progress, a self-driven step failed

An event reports something already done. By the time the application sees
it the state has changed: MembersChanged says the member set is different now,
not that it is about to be. Nothing done in response undoes it.

Every Optional event carries its default and its deadline — what de-mls
will do if the application says nothing, and when it will do it (principle 6).

Events come in the order they happened, and hold their meaning only when
handled in that order: a welcome belongs to the commit that preceded it.

Events are neither dropped nor reordered. They accumulate until the
application takes them, and de-mls never waits.

Handle only the Required events and the application is correct
unresponsive, but never wrong. A conversation closes exactly once, as one
Required event, whether the group removed the member or the member left.

Refusals

A refusal answers a request that cannot be honoured now. It names the state that
blocked the call and the earliest moment the call would be accepted.

Nothing happened. A refused call leaves no trace: no half-opened proposal,
no buffered intent, no event. The conversation stands exactly as it did.

Retrying the same call is the whole remedy. There is no forcing variant and
no second route; once the blocking state clears, the identical call goes
through.

Only requests are refused. handle_packet and tick always run. A refusal
answers a call the user’s action started.

A deadline that has passed refuses too. A vote cast after its session closed
is refused as too late: the default has already applied, and there is nothing
left to answer.

Queries

Events say what changed; queries say where things stand — who is in the group,
what phase it is in, the epoch, whether this member stewards, what the scores
are.

A query reads current state, including changes whose events have not been
drained. Draining first keeps the application’s view and its answers in step.

A query is invisible. It changes nothing, sends nothing, and no other member
can tell that one happened.

Queries report standing, not change. They rebuild a view from nothing — a
screen being drawn, a process that has just restarted — where events report the
transitions.

B. Application ↔ de-mls ↔ consensus

Consensus counts votes. It decides nothing about the group.

The plug-in supplies two things: somewhere to keep proposals and votes, and a
key to sign them with.

  • de-mls decides when to ask and who may answer. Eligible voters follow from
    MLS membership, which only de-mls sees. They are passed in per session, and
    the plug-in never keeps them.
  • Each conversation is its own scope. No session, proposal or vote crosses
    from one conversation into another.
  • The application never talks to consensus. de-mls calls it; it never calls
    back and never reaches the network itself. Whatever has to travel between members leaves
    through the ordinary outbound queue.
  • Decisions cross the boundary; deliberation does not. A resolved outcome
    arrives as an event, during a call the application made — consensus never
    wakes it.
  • Consensus always has a configuration, and every member of a group holds the
    same one.
    It is specified when the group is created and when a member joins,
    shared with new joiners in the sync message, and any later change is agreed
    and delivered through the group.

C. Application ↔ de-mls ↔ MLS

The MLS group lives inside de-mls and advances together with the rest of the
conversation’s state on every commit. The application supplies what MLS needs in
order to exist: a storage provider, a signer, a credential, a group configuration, key packages.

  • Writes go through de-mls; reads are open. de-mls exposes the MLS group for
    reading, and the application reads it through OpenMLS’s own API. Every operation that
    changes the tree goes through de-mls.
  • One provider per identity, borrowed per call. Two copies of a
    storage-bearing provider are two divergent groups. de-mls does not hold the
    provider between calls.
  • Key package creation and management is outside the scope of de-mls. de-mls
    doesn’t construct key packages; when adding a member it receives one as
    input. Generating, distributing and updating them is the responsibility of
    the application. The application must keep a key package in the provider
    until its member has joined; removed sooner, the welcome cannot be
    processed.
  • Connecting a client to a member is the application’s. It supplies the
    credential and the signature key; de-mls hands back the handle that names that
    member. Mapping the handle to its own user is the application’s job, and its
    identifiers never enter the group.
  • Every commit takes the same route: stage, validate, then merge or discard.
    A commit is never applied on arrival, and one that is not chosen is discarded
    rather than left staged.

D. Joining, and staying in sync

MLS’s welcome carries the cryptographic state of the group. It does not carry
the protocol state de-mls runs on — who the stewards are, the group’s timing,
the peer scores, the round the group is in. That is the bootstrap, and a
joiner holding only a welcome is inside the group without being able to take
part in it.

  • The bootstrap travels with the welcome. The two are minted together and
    delivered together, so a joiner is usable from its first call, and join
    reports whether the bootstrap arrived.
  • A bootstrap describes the moment it was minted. The welcome it rides with
    may be delivered much later, so what it says can already be out of date when
    it is applied.
  • A joiner can ask again, and any member can answer. No single member is on
    the critical path of another member’s bootstrap.
  • What is sent is checked, not trusted. A joiner recomputes the bootstrap
    against the member set and epoch MLS gives it, so a stale or wrong one is
    detected rather than adopted. Whatever can be derived is derived; only what
    cannot be is sent.
  • A joiner is not a full participant on arrival. It does not steward and
    does not vote until it has been present for a full epoch, which is what makes
    a late or stale bootstrap survivable rather than a fork.

E. Failure, restart, and delivery

Four kinds of bad outcome

Kind What changed What the application does
Unusable input Nothing Nothing. de-mls reports that it saw something it could not use
Refused request Nothing Retry the same call once the blocking state clears
Internal failure Nothing the application asked for Surface it. The conversation stays usable and goes on making progress
Fatal The conversation ends Release it, and everything held for it

Fatal means the conversation can no longer be trusted to agree with the group,
not that a step failed. It ends once, as a Required event, and no further call
repairs it. Releasing it means dropping the handle along with what the
application keeps for it: its consensus scope, its stored snapshot.

Duplicates

Handing de-mls the same packet twice is safe. Most duplicates land as unusable input;
the rest are recognised by what they carry — a proposal already resolved, a vote already counted, a commit already applied, a welcome already consumed — and dropped.
A welcome is the rarest to see twice, since it usually arrives addressed alongside its commit rather than over the broadcast, but even a re-broadcast one is caught by its welcome hash and never applied a second time.

The exception is a plain application message. de-mls does not remember chat, so
a message delivered twice is reported twice. Recognising that is the
application’s, because only it knows what a duplicate means to a user.

Ordering

Arrival order does not change what de-mls computes. A round collects candidates
rather than sequencing them; an application message is decrypted and handed up
as it arrives, and what its position means is the application’s to decide.

Lateness is the cost that counts. Windows are measured in real time, so a packet
arriving after its round has closed is worth no more than one that never
arrived, however correctly it was ordered.

Time windows

Input agreement (principle 8) is bought with time, and the windows are the
price. Each has to be wider than real delivery delay for the members that round
depends on.

de-mls cannot size them: it sees neither who received what nor how long anything
took. They are configuration, chosen by whoever knows the network — and like the
rest of the group’s configuration, set when the group is created, carried to
joiners in the bootstrap, and identical for every member. Members running
different windows open and close rounds at different moments, and the group ends
up on two commits.

Local clocks are part of the price. A window is measured on each node’s own
clock, so drift shortens it for some members and widens it for others.

Restart

de-mls keeps no storage and writes nothing. The protocol state it carries is
offered as a snapshot: an opaque, versioned value the application reads out
and hands back.

  • A snapshot is a read; restoring is a construction. snapshot sits among
    the queries and restore among create and join. Nothing new crosses the
    boundary and no ownership changes.
  • Only what nothing else holds goes in it. State that already lives in
    application-supplied storage stays there, and whatever can be derived from MLS
    is derived on restore rather than carried.
  • A snapshot belongs to one point in the group’s history. Restoring checks
    it against the MLS state in the provider. If the two do not match, MLS is
    right: the conversation restores from the tree and asks for a sync.
  • Any snapshot is safe to restore. A stale one costs a sync, never
    correctness. That is what makes when to take one the application’s call, and
    an epoch change the natural point, since that is when derived state turns
    over.
  • A missing snapshot is not a lost conversation. Restoring without one
    leaves a member with MLS state and no protocol state — the position of a
    joiner without a bootstrap, and resolved the same way, by asking for a sync.
  • Work in flight does not survive, and neither does the phase. Open votes,
    half-collected rounds and the deadlines that governed them are bounded by a
    clock that moved while the process was gone. A restored conversation resumes
    idle and is driven forward again from where the group now is.
2 Likes