Spec: messenger signals, field diagnostics, and the moss capability adoption
Problem Statement
Mosh conversations feel inert and the field is undiagnosable.
When a message shows two ticks, the sender cannot tell whether the
counterpart ever opened the conversation. There is no "typing" hint, so a
live back-and-forth gives no sense of the other person being there. For a
privacy-first messenger this is the most-requested liveness surface, and
today it simply does not exist.
When something breaks for a real user, we are blind. The Rust core emits
error lines to process stderr, which a release Windows build has no
console for — every dropped frame, failed handshake, and stalled resend
is silently lost. The diagnostics panel shows a mesh event ring, but it
carries no library version, so after a moss bump a stale library on a
user machine is indistinguishable from a real regression. There is no
file a user can attach to a bug report.
The channel probe used for release verification measures the wrong thing
and gives false negatives: it waits for "any substrate peer" before
sending, but substrate peers are strangers — the probe reports failure
for channels whose actual rendezvous simply needs more time, so channel
delivery has never been measurable end to end.
Finally, attachments ride the same pubsub room wire as everything else.
On a direct session that wire is slower than it needs to be for bulk
transfers, and moss's stream API (direct-session fast path with relay
fallback) goes unused.
Solution
Four capabilities, one branch, each independently shippable as an ordered
commit:
- Signals. A [[Read receipt]] — MLS-authenticated proof the user
opened the conversation — rendered as the existing two delivery ticks
changing color, never a third tick. Off by default, symmetric: a user
who does not send receipts does not see others'. DM only. A
[[Typing indicator]] — live "the counterpart is typing" — DM and
groups, on by default, emit-on-input with a ~3s refresh and 5s expiry,
MLS-encrypted. Channels never carry either.
- Field diagnostics. A plain file log under the app's private data
directory (rotated, small), carrying the errors the core already
emits plus key session transitions (handshake land, resend attempts,
delivery settlement). The diagnostics panel gains the loaded moss
library version and per-peer RTT.
- Probe honesty. The channel probe drops the substrate-peer gate
and retries the send itself until the listener has the frame or the
timeout is spent — the send becomes the rendezvous probe, and the
verdict measures what users actually experience.
- Stream transport for attachments. The attachment blob channel
keeps its chunk protocol (requests, retry, dedup) but rides moss
streams: direct-session fast path, wrapped-relay fallback. User-
visible as faster attachment transfers on direct sessions; no
protocol change for counterpart devices.
User Stories
- As a DM participant, I want the two delivery ticks on my message to
change color when the counterpart opens the conversation, so that I
know a human actually saw it without asking "did you see it?".
- As a privacy-conscious DM participant, I want read receipts off by
default, so that my reading behavior is never reported without my
explicit consent.
- As a privacy-conscious DM participant, I want the read-receipt
setting to be symmetric — if I do not send receipts I do not see
others' — so that I cannot free-ride on a feature I refuse to give.
- As a DM participant who has enabled read receipts, I want my choice
to apply to every DM I have, so that I do not have to decide it per
conversation.
- As a DM participant, I want the receipt to be MLS-authenticated, so
that no mesh bystander can forge "read" and lie to me about my
counterpart.
- As a DM participant, I want an old counterpart client that does not
know receipts to simply never send one, so that mixed-version
conversations degrade silently instead of breaking.
- As a DM participant typing a message, I want the counterpart to see
"typing" within a few seconds, so that the conversation feels live.
- As a DM participant, I want the typing hint to disappear within five
seconds of the counterpart stopping, so that it never lies about
continued activity.
- As a DM participant, I want the typing hint to stop the moment my
counterpart sends or clears their draft, so that "typing" never
contradicts what arrives (or does not).
- As a group member, I want the same typing hint in private groups,
so that groups feel as live as DMs.
- As a group member, I want the typing hint to identify which member
is typing, so that in a multi-party room the hint is informative
rather than ambiguous.
- As a participant in a public channel, I never want typing or
receipts in channels, so that strangers cannot probe my activity.
- As a user, I want typing to work without any setting, so that the
feature needs no onboarding to be useful.
- As a user, I want typing frames MLS-encrypted, so that a mesh
bystander cannot fake "someone is typing" in my conversation.
- As a user with a broken conversation, I want the app to write a
log file in its private data directory, so that a support
conversation can end with "attach this file".
- As a user, I want the log rotated so it never grows unbounded, so
that diagnostics do not cost disk.
- As a user, I want the log to record dropped frames, failed
handshakes, resend attempts and delivery settlements, so that
"it didn't send" has a post-mortem.
- As a user, I want the diagnostics panel to show the loaded moss
library version, so that I can tell whether my install actually has
the new core.
- As a user reporting a bug, I want the log to include the moss
library version, so that the maintainer knows what was running.
- As a maintainer verifying a release, I want the channel probe to
retry its send until the listener has the frame or the timeout is
spent, so that a channel verdict reflects delivery and not a
substrate-peer race.
- As a maintainer, I want the probe timeline to show the retry
attempts, so that rendezvous latency for channels is finally
measurable.
- As a maintainer, I want probe verdicts comparable across the dm,
group and channel kinds, so that a release gate reads uniformly.
- As a sender of an attachment on a direct session, I want chunks to
ride the stream fast path, so that a large file arrives noticeably
sooner.
- As a receiver of an attachment, I want the existing chunk protocol
(requests, retries, dedup) preserved behind the new carrier, so that
a mid-transfer network wobble still recovers.
- As a user on a relayed (no direct session) path, I want
attachments to still transfer via the wrapped relay fallback, so
that streams are an acceleration, not a requirement.
- As a user with an older counterpart client, I want my stream sends
to fall back gracefully to the room wire, so that mixed versions
keep transferring.
- As a developer, I want read-receipt state derived from the same
snapshot polling as everything else, so that the UI has one
data-flow pattern and no new push infrastructure.
- As a developer, I want typing state in the session/group snapshot,
so that the poll cycle the UI already runs renders it.
- As a developer, I want app-level event codes 8–11 synthesized into
the existing event ring by the runtime that owns the fact, so that
the diagnostics panel shows "message_read"/"typing" like any mesh
event.
- As a developer, I want the 52 error sites to move onto the file
log through one call, so that no call site decides rotation or
path policy.
- As a developer, I want log calls structured (level, kind, context
id), so that the file is greppable and machine-diffable.
- As a developer, I want the library version loaded through the same
dynamic-symbol table as every other moss call, so that a library
missing the symbol degrades instead of crashing.
- As a developer, I want the probe to stay a pure observer of the
production runtimes, so that verification code never forks runtime
behavior.
- As a tester, I want a state-machine test pair that proves the
receipt frame settles Sent→Delivered→Read exactly like the
existing ack tests, so that the new state extension is proven at
the same bar.
- As a tester, I want a state-machine test that proves a forged
(non-MLS) read receipt is dropped, so that the color change cannot
be faked.
- As a tester, I want widget tests that render the tick color change
and the typing hint from a scripted snapshot, so that the UI
contract is pinned without a live network.
- As a tester, I want the file log covered by tests that assert
rotation and content at the existing storage-test seam, so that
the sink itself is not trusted on faith.
- As a tester, I want a probe-e2e channel run on the stand to
exercise retry-on-no-peers end to end, so that the fix is proven
on the network shape that exposed it.
- As a tester, I want attachment transfer tests to run the chunk
protocol over the new carrier with the real library dlopened, so
that the wire change is proven against the real moss transport.
- As a tester, I want mixed-version tolerance (old client drops
unknown envelope variants) covered by decode-drop tests, so that
upgrades never break running conversations.
Implementation Decisions
- Ordered commits on one branch, one PR at the end: probe fix first
(it gates how everything else is measured), then diagnostics (it
makes the rest field-diagnosable), then signals, then transport.
- Signals are mosh's own protocol, not moss events. The mesh
runtime never dispatches event codes 8–11; it pins the numbering for
hosts. The messenger layer (mosh) carries its own frames and
synthesizes its own events. The runtime that owns the fact (a DM
runtime marking a message read) files the event into the existing
event ring — the same seam the diagnostics panel already polls.
- Envelope variants are named after the pinned glossary terms
(ReadReceipt, TypingIndicator) so code vocabulary and the domain
glossary cannot drift. "Delivered" stays taken by the runtime-level
delivery ack; the read state is a distinct concept, never a third
tick — the two existing ticks change color.
- MLS encryption is mandatory for both signals. The existing
delivery ack's rationale applies verbatim: a plaintext signal could
be forged by any mesh member; only the MLS peer can produce a
ciphertext the session accepts. The ack-decrypt pattern (drop on
failed decrypt, keep state unchanged) is the template.
- Mixed-version tolerance rides serde's unknown-variant drop, the
same pattern every prior envelope addition used: an old client that
fails to decode simply never sends receipts and never shows typing;
nothing breaks.
- Read-receipt semantics: auto-trigger when the conversation is
open and a not-yet-read message is rendered; default off; symmetric
exchange; DM only in this slice. Groups ("read by N") and channels
are explicitly later/never.
- Typing semantics: emit-on-input, ~3s refresh while input
continues, 5s expiry, stop on send/clear; DM + groups; on by
default; group hint identifies the member. State reaches Dart
through the existing snapshot polling — no new push channel.
- Log sink shape: one plain file under the app-private data
directory (the ADR-0010 injected path), simple size rotation
(a few MB per file, a few files). No tracing crate, no new
dependencies. Call sites pass (level, kind, context id, message)
to one sink call; the sink owns path and rotation policy. The
existing stderr lines move onto it; the stderr behavior may remain
for debug builds. A later swap to a structured subscriber stays
possible without touching call sites.
- Library version and peer RTT load through the same dynamic-symbol
table every other moss call uses. A missing symbol degrades to
"unknown" rather than failing the node.
- Probe fix: remove the substrate-peer gate for the channel kind;
retry the send on the moss "no peers" refusal until timeout. The
probe stays a pure observer — no runtime behavior changes for
production. The listener-presence protocol variant stays parked
unless the retry alone proves unable to measure rendezvous.
- Transport: the attachment blob channel keeps its chunk protocol
(request/retry/dedup) and changes only its carrier: moss streams,
which give a direct-session fast path and a wrapped-relay fallback
inside the library. Fallback to the room wire covers peers whose
library predates streams. Directed sends for message data are
explicitly out of this slice (measure on attachments first).
- No telemetry. Network statistics require opting the node into
moss's telemetry gossip; that stays off by design, consistent with
the Axiom opt-out.
- Snapshot contracts extend, not fork: read and typing state ride
the existing session and group snapshot structs (optional fields,
skip-when-none), so codegen drift stays additive and old snapshots
stay valid.
Testing Decisions
- Good tests prove external behavior at the highest existing seams
— the conversation bridge functions and the snapshot contracts; the
event ring via the diagnostics surface; the probe via its timeline;
the log via its written files. Implementation details (envelope
serialization shapes, sink internals) are not asserted except where
they are the contract (mixed-version decode-drop).
- Modules tested: the DM runtime state machine (receipts, typing,
log transitions), the group runtime (typing), the log sink
(rotation, content), the probe (retry loop), the attachment runtime
over the stream carrier, the Dart conversation UI (tick color,
typing hint, composer emit), the diagnostics panel (version, RTT,
events).
- Prior art to follow:
- In-crate state-machine tests that run two runtime instances
against each other over the real dlopened library — the existing
handshake/ack/duplicate-frame tests set the bar; the receipt
tests extend the same harness (settle Sent→Delivered→Read; forged
receipt dropped; old-client decode-drop).
- Widget tests mounting screens via the shared pump helpers with a
scriptable gateway — the existing delivered-tick and composer
tests extend to the tick color and typing hint; the composer
emit/stop logic is scripted through the same gateway.
- Storage-seam tests for the log sink: write, rotate, re-open, and
assert content — no mock filesystem.
- probe-e2e on the stand for the channel retry: verdicts and the
timeline must show retries, listener receipt, and a comparable
shape across dm/group/channel.
- The existing "no peers counts as failure" regression test in the
channel runtime is the anchor the probe fix must not weaken —
the runtime keeps refusing; only the probe retries.
- Quality bar: every behavior change lands with its test in the
same commit; the full local suites (core, flutter, analyze, fmt,
clippy, format) plus CI stay green; the probe run on the stand is
the release-gate proof for the transport and probe changes.
Out of Scope
- App-level presence ("online" indicator) — parked until the masq
rollout stabilizes sessions; connection-level presence already
exists in the mesh events.
- Group read receipts ("read by N") and any receipts/typing in
channels.
- Directed sends for message data (SendToPeer as the DM carrier)
— measure the stream technique on attachments first.
- Network statistics (GetNetworkStats) — telemetry is off by
design.
- Any moss submodule modification — the submodule stays a vendored
pin; upstream work goes through the partner.
- New settings UI beyond the single read-receipt toggle.
- Localization beyond the existing ARB pattern (strings ride the
existing gen-l10n flow).
Further Notes
- The glossary terms [[Read receipt]], [[Delivery]] and
[[Typing indicator]] are pinned in CONTEXT.md; the envelope variant
names deliberately match them.
- The event codes 8–11 are already named in the event-ring mapping
(message_delivered / message_read / typing / presence); this slice
makes the runtime actually file the first three of them.
- The file-log decision was driven by a confirmed gap: in a release
Windows build there is no console, so stderr is lost; debug prints
only appear under a debug console. The app-private data directory is
already injected once per process and is the natural anchor.
- The probe fix unblocks a parked upstream report: once the partner's
masq rollout reaches the relays, one fresh probe round decides
whether to send the NAT-pair re-establishment report with both
timelines; the UDP write-deadline escalation rides the same letter.
- Branch name reserved:
moss-capabilities. Ordered commits: probe,
diagnostics, signals, transport. Docs (changelog, ADR updates) ride
each commit rather than a final batch.
Spec: messenger signals, field diagnostics, and the moss capability adoption
Problem Statement
Mosh conversations feel inert and the field is undiagnosable.
When a message shows two ticks, the sender cannot tell whether the
counterpart ever opened the conversation. There is no "typing" hint, so a
live back-and-forth gives no sense of the other person being there. For a
privacy-first messenger this is the most-requested liveness surface, and
today it simply does not exist.
When something breaks for a real user, we are blind. The Rust core emits
error lines to process stderr, which a release Windows build has no
console for — every dropped frame, failed handshake, and stalled resend
is silently lost. The diagnostics panel shows a mesh event ring, but it
carries no library version, so after a moss bump a stale library on a
user machine is indistinguishable from a real regression. There is no
file a user can attach to a bug report.
The channel probe used for release verification measures the wrong thing
and gives false negatives: it waits for "any substrate peer" before
sending, but substrate peers are strangers — the probe reports failure
for channels whose actual rendezvous simply needs more time, so channel
delivery has never been measurable end to end.
Finally, attachments ride the same pubsub room wire as everything else.
On a direct session that wire is slower than it needs to be for bulk
transfers, and moss's stream API (direct-session fast path with relay
fallback) goes unused.
Solution
Four capabilities, one branch, each independently shippable as an ordered
commit:
opened the conversation — rendered as the existing two delivery ticks
changing color, never a third tick. Off by default, symmetric: a user
who does not send receipts does not see others'. DM only. A
[[Typing indicator]] — live "the counterpart is typing" — DM and
groups, on by default, emit-on-input with a ~3s refresh and 5s expiry,
MLS-encrypted. Channels never carry either.
directory (rotated, small), carrying the errors the core already
emits plus key session transitions (handshake land, resend attempts,
delivery settlement). The diagnostics panel gains the loaded moss
library version and per-peer RTT.
and retries the send itself until the listener has the frame or the
timeout is spent — the send becomes the rendezvous probe, and the
verdict measures what users actually experience.
keeps its chunk protocol (requests, retry, dedup) but rides moss
streams: direct-session fast path, wrapped-relay fallback. User-
visible as faster attachment transfers on direct sessions; no
protocol change for counterpart devices.
User Stories
change color when the counterpart opens the conversation, so that I
know a human actually saw it without asking "did you see it?".
default, so that my reading behavior is never reported without my
explicit consent.
setting to be symmetric — if I do not send receipts I do not see
others' — so that I cannot free-ride on a feature I refuse to give.
to apply to every DM I have, so that I do not have to decide it per
conversation.
that no mesh bystander can forge "read" and lie to me about my
counterpart.
know receipts to simply never send one, so that mixed-version
conversations degrade silently instead of breaking.
"typing" within a few seconds, so that the conversation feels live.
seconds of the counterpart stopping, so that it never lies about
continued activity.
counterpart sends or clears their draft, so that "typing" never
contradicts what arrives (or does not).
so that groups feel as live as DMs.
is typing, so that in a multi-party room the hint is informative
rather than ambiguous.
receipts in channels, so that strangers cannot probe my activity.
feature needs no onboarding to be useful.
bystander cannot fake "someone is typing" in my conversation.
log file in its private data directory, so that a support
conversation can end with "attach this file".
that diagnostics do not cost disk.
handshakes, resend attempts and delivery settlements, so that
"it didn't send" has a post-mortem.
library version, so that I can tell whether my install actually has
the new core.
library version, so that the maintainer knows what was running.
retry its send until the listener has the frame or the timeout is
spent, so that a channel verdict reflects delivery and not a
substrate-peer race.
attempts, so that rendezvous latency for channels is finally
measurable.
group and channel kinds, so that a release gate reads uniformly.
ride the stream fast path, so that a large file arrives noticeably
sooner.
(requests, retries, dedup) preserved behind the new carrier, so that
a mid-transfer network wobble still recovers.
attachments to still transfer via the wrapped relay fallback, so
that streams are an acceleration, not a requirement.
to fall back gracefully to the room wire, so that mixed versions
keep transferring.
snapshot polling as everything else, so that the UI has one
data-flow pattern and no new push infrastructure.
so that the poll cycle the UI already runs renders it.
the existing event ring by the runtime that owns the fact, so that
the diagnostics panel shows "message_read"/"typing" like any mesh
event.
log through one call, so that no call site decides rotation or
path policy.
id), so that the file is greppable and machine-diffable.
dynamic-symbol table as every other moss call, so that a library
missing the symbol degrades instead of crashing.
production runtimes, so that verification code never forks runtime
behavior.
receipt frame settles Sent→Delivered→Read exactly like the
existing ack tests, so that the new state extension is proven at
the same bar.
(non-MLS) read receipt is dropped, so that the color change cannot
be faked.
and the typing hint from a scripted snapshot, so that the UI
contract is pinned without a live network.
rotation and content at the existing storage-test seam, so that
the sink itself is not trusted on faith.
exercise retry-on-no-peers end to end, so that the fix is proven
on the network shape that exposed it.
protocol over the new carrier with the real library dlopened, so
that the wire change is proven against the real moss transport.
unknown envelope variants) covered by decode-drop tests, so that
upgrades never break running conversations.
Implementation Decisions
(it gates how everything else is measured), then diagnostics (it
makes the rest field-diagnosable), then signals, then transport.
runtime never dispatches event codes 8–11; it pins the numbering for
hosts. The messenger layer (mosh) carries its own frames and
synthesizes its own events. The runtime that owns the fact (a DM
runtime marking a message read) files the event into the existing
event ring — the same seam the diagnostics panel already polls.
(
ReadReceipt,TypingIndicator) so code vocabulary and the domainglossary cannot drift. "Delivered" stays taken by the runtime-level
delivery ack; the read state is a distinct concept, never a third
tick — the two existing ticks change color.
delivery ack's rationale applies verbatim: a plaintext signal could
be forged by any mesh member; only the MLS peer can produce a
ciphertext the session accepts. The ack-decrypt pattern (drop on
failed decrypt, keep state unchanged) is the template.
same pattern every prior envelope addition used: an old client that
fails to decode simply never sends receipts and never shows typing;
nothing breaks.
open and a not-yet-read message is rendered; default off; symmetric
exchange; DM only in this slice. Groups ("read by N") and channels
are explicitly later/never.
continues, 5s expiry, stop on send/clear; DM + groups; on by
default; group hint identifies the member. State reaches Dart
through the existing snapshot polling — no new push channel.
directory (the ADR-0010 injected path), simple size rotation
(a few MB per file, a few files). No tracing crate, no new
dependencies. Call sites pass (level, kind, context id, message)
to one sink call; the sink owns path and rotation policy. The
existing stderr lines move onto it; the stderr behavior may remain
for debug builds. A later swap to a structured subscriber stays
possible without touching call sites.
table every other moss call uses. A missing symbol degrades to
"unknown" rather than failing the node.
retry the send on the moss "no peers" refusal until timeout. The
probe stays a pure observer — no runtime behavior changes for
production. The listener-presence protocol variant stays parked
unless the retry alone proves unable to measure rendezvous.
(request/retry/dedup) and changes only its carrier: moss streams,
which give a direct-session fast path and a wrapped-relay fallback
inside the library. Fallback to the room wire covers peers whose
library predates streams. Directed sends for message data are
explicitly out of this slice (measure on attachments first).
moss's telemetry gossip; that stays off by design, consistent with
the Axiom opt-out.
the existing session and group snapshot structs (optional fields,
skip-when-none), so codegen drift stays additive and old snapshots
stay valid.
Testing Decisions
— the conversation bridge functions and the snapshot contracts; the
event ring via the diagnostics surface; the probe via its timeline;
the log via its written files. Implementation details (envelope
serialization shapes, sink internals) are not asserted except where
they are the contract (mixed-version decode-drop).
log transitions), the group runtime (typing), the log sink
(rotation, content), the probe (retry loop), the attachment runtime
over the stream carrier, the Dart conversation UI (tick color,
typing hint, composer emit), the diagnostics panel (version, RTT,
events).
against each other over the real dlopened library — the existing
handshake/ack/duplicate-frame tests set the bar; the receipt
tests extend the same harness (settle Sent→Delivered→Read; forged
receipt dropped; old-client decode-drop).
scriptable gateway — the existing delivered-tick and composer
tests extend to the tick color and typing hint; the composer
emit/stop logic is scripted through the same gateway.
assert content — no mock filesystem.
timeline must show retries, listener receipt, and a comparable
shape across dm/group/channel.
channel runtime is the anchor the probe fix must not weaken —
the runtime keeps refusing; only the probe retries.
same commit; the full local suites (core, flutter, analyze, fmt,
clippy, format) plus CI stay green; the probe run on the stand is
the release-gate proof for the transport and probe changes.
Out of Scope
rollout stabilizes sessions; connection-level presence already
exists in the mesh events.
channels.
— measure the stream technique on attachments first.
design.
pin; upstream work goes through the partner.
existing gen-l10n flow).
Further Notes
[[Typing indicator]] are pinned in CONTEXT.md; the envelope variant
names deliberately match them.
(message_delivered / message_read / typing / presence); this slice
makes the runtime actually file the first three of them.
Windows build there is no console, so stderr is lost; debug prints
only appear under a debug console. The app-private data directory is
already injected once per process and is the natural anchor.
masq rollout reaches the relays, one fresh probe round decides
whether to send the NAT-pair re-establishment report with both
timelines; the UDP write-deadline escalation rides the same letter.
moss-capabilities. Ordered commits: probe,diagnostics, signals, transport. Docs (changelog, ADR updates) ride
each commit rather than a final batch.