Regenerate the spec page for §6.15 through §6.27 #92

Merged
paulhitt merged 1 commits from docs/regenerate-spec-pages-6-15-to-6-27 into main 2026-09-02 19:06:48 +00:00
+549 -3
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/SPEC.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: ba0062ff88813c30 -->
source-sha256: 94f4d086a225f72b -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-messenger--end-to-end-encryption-spec">Flamenet
@@ -364,8 +364,9 @@ required, <code>dur</code> absent), <strong><code>audio</code></strong>
sent as <code>0</code>; RECOMMENDED encoding AAC in an MPEG-4 container,
<code>mime: "audio/mp4"</code>, so every platform's native decoder opens
it), and <strong><code>story</code></strong>.</p>
<p><strong>Stories.</strong> A story is an <code>image</code> attachment
addressed to <em>every</em> buddy: the sender seals and uploads the blob
<p><strong>Stories.</strong> A story is usually an <code>image</code>
attachment addressed to <em>every</em> buddy (a story with no attachment
at all is §6.21): the sender seals and uploads the blob
<strong>once</strong>, then sends one <code>kind: "story"</code>
envelope per buddy through that buddy's normal ratchet session. No new
server surface exists — the server sees only ordinary sealed envelopes
@@ -542,6 +543,14 @@ attributable to the quoted author — it is the replier's assertion about
what was said, and a replier can lie about it. Clients SHOULD render it
as a quote attributed to the replier's view, not as the original
author's words.</p>
<p>For the same reason, a client that lets a reader <strong>tap a quote
to reach the message it answers</strong> MUST select that target by
<code>mid</code> alone. Matching on <code>preview</code> — or falling
back to it when the <code>mid</code> is unknown — would let a crafted
reply send the reader to a message of the sender's choosing, and would
do it invisibly, because the quote itself would look correct. A
<code>mid</code> the receiver does not hold has no target: the correct
behaviour is to render the preview and offer no jump, not to guess.</p>
<p><strong>Reactions.</strong> <code>kind</code> is
<code>"react"</code>, <code>caption</code> is the emoji, <code>re</code>
names the target:</p>
@@ -754,6 +763,543 @@ connection and rings from its own foreground service, so an Android
client MAY set the flag for interop but gains nothing from it. The
asymmetry is Apple's process model, not a design choice, and it belongs
on the privacy page for the same reason the push dependency does.</p>
<h3 id="615-editing-a-message-that-was-already-sent">6.15 Editing a
message that was already sent</h3>
<p>A sender MAY replace the text of a message it sent earlier.
<code>kind</code> is <code>"edit"</code>, <code>re</code> names the
target, and <code>caption</code> carries the new wording:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;edit&quot;, &quot;caption&quot;: &quot;meet at 9&quot;, &quot;re&quot;: { &quot;mid&quot;: &quot;&lt;target&gt;&quot;, &quot;author&quot;: 0, &quot;preview&quot;: &quot;&quot; } }</code></pre>
<p><strong>The receiver decides authorship, and MUST NOT read it from
the envelope.</strong> <code>re.author</code> is a claim: any member of
a group can put any user id there, and a 1:1 peer can put yours. A
receiving client MUST apply an edit only to a message it already holds
<em>and</em> already attributes to the sender of the edit. An
implementation that trusted <code>re.author</code> would let one
envelope rewrite what somebody else said, inside the reader's own
transcript. Senders SHOULD write <code>0</code> in that field, as this
specification's reference implementations do, precisely so that nothing
is tempted to believe it.</p>
<p><strong>An edit MUST be visible as an edit.</strong> A receiving
client MUST show that the message was changed after it was sent. This is
not a courtesy: an edit the reader cannot detect puts new words into a
bubble they have already read and trusted, and removes any trace that
the original was different — which is a forgery primitive, and it is the
reason this section exists at all rather than the feature being a local
convenience.</p>
<p>Further requirements:</p>
<ul>
<li>A receiving client MUST NOT render an edit as a message. It is
folded onto the target and, if the target is unknown, dropped — the same
rule as a reaction (§6.9), and for the same reason: an unrecognised edit
drawn as text appears as a duplicate of the message it was meant to
replace.</li>
<li>An edit MUST NOT raise a notification. Being buzzed a second time
because somebody fixed a typo is the behaviour people turn a messenger
off over.</li>
<li>An edit MUST NOT empty a message. Retracting a message is a
different operation with different honesty requirements, and reusing an
edit for it leaves a blank bubble marked "edited", which explains
nothing.</li>
<li>Edits are <strong>monotonic</strong>. Two edits can arrive out of
order, because a relay orders by when it received an envelope rather
than by when it was written. A receiving client MUST NOT let an edit
written earlier replace one written later; the relay's timestamp for the
envelope is the ordering key.</li>
<li>A message with no <code>mid</code> cannot be edited, for the same
reason it cannot be reacted to: there is nothing to name it by.</li>
</ul>
<p><code>gid</code> applies as elsewhere: an edit of a group message
carries the group id and routes to the group thread.</p>
<p><strong>Status.</strong> In progress. The iOS client implements this
section end to end. The Kotlin engine implements the codec — an inbound
edit is recognised and is not drawn as a duplicate bubble — and the
Android app cannot yet apply one, because its transcript does not record
a <code>mid</code> for each line. That single gap also blocks replies
and reactions on Android (see §6.9), and closing it enables all
three.</p>
<h3 id="616-forwarding">6.16 Forwarding</h3>
<p>A sender MAY pass a message on to another conversation. A forward
carries the content and <strong>nothing about where it came
from</strong>:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;text&quot;, &quot;fwd&quot;: 1, &quot;mid&quot;: &quot;&lt;a NEW id&gt;&quot;, &quot;caption&quot;: &quot;meet at 6&quot; }</code></pre>
<p><code>fwd</code> follows §6.10's flag shape — <code>1</code>, with
<code>true</code> accepted on the way in.</p>
<p><strong>What MUST NOT travel.</strong> A forwarded envelope MUST NOT
carry <code>re</code>, and MUST NOT carry the <code>mid</code> of the
message it was copied from. The first is the reply chain, whose preview
is somebody else's words from a conversation the new recipient was never
part of. The second matters more than it looks: a <code>mid</code> is
unique within a conversation, so the same one appearing in two
conversations is a handle the <strong>relay</strong> can use to tie
those two conversations together — which is the link a forward is
supposed to break. A forward MUST mint a fresh <code>mid</code>.</p>
<p><strong>Attachments are re-uploaded, not re-referenced.</strong> A
forwarded blob MUST be sealed again under a new key with a new id,
rather than the original <code>att</code> being repeated. Repeating it
would work and would be far cheaper, and it would let the relay watch
one blob being fetched from two conversations — the same correlation the
<code>mid</code> rule closes, through a different door. ⚠️ The
consequence is that forwarding an attachment can fail where forwarding
text cannot: the blob may have expired from the relay. That is a failure
to report, not one to paper over by sending the caption alone. A
forwarded attachment MUST NOT inherit the original's <code>exp</code>: a
story hours from vanishing must not hand the new recipient a copy that
vanishes with it.</p>
<p><strong>What <code>fwd</code> says, and what it does not.</strong> It
says the sender is passing on somebody else's words. It says nothing
about <em>whose</em>. A receiving client SHOULD show that a message was
forwarded, and MUST NOT present it as the sender's own composition; it
has nothing to attribute it to and MUST NOT invent one. Attribution is
something a person types if they want it, and this specification
deliberately provides no field for it — a field would become a default,
and a forward that carries its author by default is not a forward.</p>
<p><code>gid</code> applies as elsewhere.</p>
<p><strong>Status.</strong> In progress. The iOS client implements this
section. The Kotlin engine implements the codec, so a forward from
another client is recognised and shown as one; the Android app cannot
originate a forward yet.</p>
<h3 id="617-a-timer-on-one-message">6.17 A timer on one message</h3>
<p>A sender MAY put a timer on a single message rather than on the whole
conversation. It rides the content envelope in <code>secs</code>,
alongside the message it applies to:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;text&quot;, &quot;mid&quot;: &quot;&lt;id&gt;&quot;, &quot;secs&quot;: 60, &quot;caption&quot;: &quot;meet at 6&quot; }</code></pre>
<p><strong>The shorter timer always wins.</strong> Where a message
carries <code>secs</code> and its conversation has a timer from §6.11,
the message lives for the <em>lesser</em> of the two. A per-message
timer may only take life away, never add it. This is not a tidiness
rule: without it a sender could defeat the recipient's conversation
timer by putting a long <code>secs</code> on every message, turning a
setting the recipient chose on their own device into something the other
party overrules. A receiving client MUST implement it this way.</p>
<p><strong>A duration, never a deadline.</strong> <code>secs</code> is
how long, not when. An absolute expiry would be the sender's clock, and
a sender who chose it could backdate a message so it vanished from the
recipient's phone on arrival, or forward-date one so it never expired.
The receiver counts from the time IT recorded the message — the rule
§6.11 already follows, for the same reason.</p>
<p>Further requirements:</p>
<ul>
<li>Zero, absent, or negative all mean <strong>no timer</strong>, and
MUST NOT be read as "expire immediately". A message whose sender chose
nothing must not vanish on arrival.</li>
<li>A sender MUST expire its own copy too. A timer that only cleared the
recipient's phone would leave the message on the device belonging to the
person who set it, which is the opposite of the promise.</li>
<li>Pruning MUST run per message, not per conversation. A conversation
with no timer of its own can still hold messages that carry one, and an
implementation that skips such a thread keeps every timed message in it
for ever — while appearing to work on the sending device.</li>
<li>⚠️ <code>secs</code> on a <code>kind: "ttl"</code> envelope means
something else entirely: it sets the whole conversation (§6.11). A
receiver MUST distinguish them by <code>kind</code>.</li>
</ul>
<p><strong>What the relay learns: nothing.</strong> The content envelope
is inside the ratchet, exactly where §6.11's announcement already rides,
so a timed message is indistinguishable to the relay from any other. ⚠️
One honest limit, shared with §6.11 and not introduced here: an
attachment's blob sits on the relay under the relay's own retention,
which may outlast a short message timer. The key is gone from both
devices, so the blob is unopenable — but it has not been deleted at the
moment the message was.</p>
<p><strong>Status.</strong> Implemented in the iOS client and in the
Kotlin engine and Android app, including the combining rule and
per-message pruning on both.</p>
<h3 id="618-mentions">6.18 Mentions</h3>
<p>A group message MAY name the members it is addressed to, in
<code>ment</code>:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;text&quot;, &quot;gid&quot;: &quot;&lt;group&gt;&quot;, &quot;ment&quot;: [12, 34], &quot;caption&quot;: &quot;@Ada @Adam thoughts?&quot; }</code></pre>
<p><strong>A mention decides whether the receiver is notified, and
nothing else.</strong> It confers no permission, changes no state, and
is not evidence of anything. This matters because <code>ment</code> is a
claim by the sender like every other field in the envelope: the worst a
dishonest one can achieve is a notification the reader is able to turn
off.</p>
<p><strong>Which is the rule that makes mentions safe to have.</strong>
A mention that always broke through a mute would hand every member of a
group a way to interrupt somebody whenever they liked — which is how
mentions become the thing people leave a group over. A mention that
never broke through would make muting a busy group mean missing the one
message that was actually for you. So the responsibility splits:</p>
<ul>
<li>the <strong>sender</strong> decides that a message is addressed to
somebody;</li>
<li>the <strong>receiver</strong> decides whether being addressed is
worth a notification.</li>
</ul>
<p>A client MUST make the second half a setting the receiving person
controls, and MUST NOT let anything in an envelope override it.
Defaulting that setting to "a mention breaks a mute" is reasonable and
is what the reference implementation does; defaulting it to "always,
with no way off" is not.</p>
<p>Further requirements:</p>
<ul>
<li>A receiving client MUST resolve names against the group's roster and
MUST NOT treat <code>ment</code> as naming anyone who is not a
member.</li>
<li><code>ment</code> MUST be bounded and deduplicated on receipt. A
sender can name ten thousand ids in one envelope, and the cost of that
lands on every recipient's device rather than theirs. The reference
implementation caps at 256, far above any real roster.</li>
<li>A client MUST NOT notify for a mention of somebody else, and SHOULD
NOT let a person mention themselves — the only thing that achieves is
making your own phone buzz.</li>
<li>Reading the conversation beats a mention. A banner about a message
already on screen is no less absurd for naming you.</li>
<li>Omitted entirely when there are no mentions, which is nearly every
message.</li>
</ul>
<p>⚠️ <strong>Muting is per conversation, not per author.</strong>
Stated here because getting it wrong makes this section unimplementable:
if a mute is keyed on whoever sent an arriving message, a group cannot
be muted at all — silencing it means muting every member one at a time,
and anyone who joins later is not covered. There is then no mute for a
mention to break.</p>
<p><strong>What the relay learns: nothing.</strong> <code>ment</code> is
inside the ratchet like the rest of the content envelope. A relay cannot
tell an addressed message from any other, and in particular cannot tell
who in a group is being singled out.</p>
<p><strong>Status.</strong> In progress. The iOS client implements this
section. The Kotlin engine implements the codec; the Android app does
not compose or act on mentions yet.</p>
<h3 id="619-pinning-a-message">6.19 Pinning a message</h3>
<p>A conversation MAY have one pinned message. <code>kind</code> is
<code>"pin"</code> and <code>re</code> names it:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;pin&quot;, &quot;re&quot;: { &quot;mid&quot;: &quot;&lt;target&gt;&quot;, &quot;author&quot;: 0, &quot;preview&quot;: &quot;&quot; } }</code></pre>
<p>An <strong>empty</strong> <code>mid</code> takes the pin down.
Removal is a message rather than an absence, for the same reason a
reaction's removal is (§6.9): the other device has already drawn it.</p>
<p><strong>A pin is an id, and a receiving client MUST NOT store a copy
of the message.</strong> This is the requirement the section exists for.
A pin that carried the text would keep a message readable after its own
timer had removed it (§6.11, §6.17) — pinning would become a way to
defeat disappearing messages, silently, from either side of a
conversation. The pin is resolved through the thread each time it is
drawn; when the message is gone the pin shows nothing and the banner
goes with it.</p>
<p><strong>Pins are monotonic.</strong> A relay orders envelopes by when
it received them, not by when they were written, so two people pinning
at once can arrive in either order. A receiving client MUST NOT let a
pin written earlier replace one written later, and MUST apply the same
rule to an unpin — otherwise a late-arriving unpin from before somebody
pinned something new silently clears it. Unlike a message, a pin is
shared state both people are looking at, so an inconsistency here does
not resolve itself.</p>
<p>Further requirements:</p>
<ul>
<li>A pin MUST NOT render as a message, and MUST NOT raise a
notification. Somebody pinning an address you both already have is not
news.</li>
<li>A pin naming a <code>mid</code> the receiver does not hold shows
nothing, rather than an empty banner claiming something is pinned.</li>
<li><code>re.author</code> is not read, exactly as in §6.15. Senders
SHOULD write <code>0</code>.</li>
<li><code>gid</code> applies as elsewhere: a pin in a group carries the
group id and pins there.</li>
</ul>
<p>⚠️ <strong>One pin, not a list.</strong> A set of pins needs an
ordering, a cap, and a rule for what happens when two people add the
tenth at once — three more pieces of shared state to keep consistent
across devices that cannot talk to each other directly. One pin has
exactly one rule, above.</p>
<p><strong>Status.</strong> In progress. The iOS client implements this
for one-to-one conversations. A group pin is carried correctly on the
wire but cannot resolve yet, because a group transcript does not record
a <code>mid</code> for each message; the moment it does, the pin becomes
a lookup like the other.</p>
<h3 id="620-story-replies">6.20 Story replies</h3>
<p>A story has no comment thread and no viewer list, and MUST NOT grow
one. A reply to a story is an ordinary direct message to its author,
carrying a reference to the story it answers:</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;text&quot;,&quot;caption&quot;:&quot;where is this?&quot;,&quot;sre&quot;:{&quot;sid&quot;:&quot;&lt;story id&gt;&quot;}}</code></pre>
<p><code>sre.sid</code> is the story's id as the replier received it.
The envelope is a normal text message in every other respect: it rides
the pairwise ratchet to the author, lands in the existing conversation,
and the relay sees nothing it does not already see for any message.</p>
<p>🔴 <strong><code>sre</code> carries no copy of the story — no
caption, no blob, no thumbnail.</strong> This diverges from
<code>re</code> in §6.9, and the reason the two differ is worth stating.
A quoted message may be one the recipient never had: they joined late or
cleared their history, so §6.9 sends a <code>preview</code> and accepts
that it is the replier's assertion. A story's author, by definition, had
the story — the only way they no longer hold it is that it EXPIRED.
Sending a copy back would therefore do exactly one thing: resurrect
content the author's own timer had already removed, at a moment they did
not choose. That is the trap §6.19 avoids for pins, and it is the same
trap here.</p>
<p>A receiver whose copy of <code>sid</code> has expired or was never
held MUST render the reply as an ordinary message, and MAY note that it
answers a story that is no longer available. It MUST NOT render a
placeholder that implies the content could be recovered.</p>
<p>⚠️ <strong>Replying tells the author you watched, and that is a
disclosure the replier makes.</strong> Stories deliberately have no
viewer list: the author cannot otherwise learn who opened one. A reply
necessarily reveals it — there is no way to answer somebody without
their knowing. The disclosure is therefore acceptable, but it MUST be a
decision and not a side effect: a client MUST tell the user, before the
first story reply that account sends, that replying reveals they
watched. A client MUST NOT send any automatic acknowledgement — a read
receipt, a delivery marker, a typing indicator — in response to a story
being opened.</p>
<p>⚠️ <code>sid</code> is a local identifier, not a global one. It is
meaningful to the pair of devices that exchanged the story and MUST NOT
be treated as a handle the relay or any third party can resolve.</p>
<h3 id="621-text-and-link-stories">6.21 Text and link stories</h3>
<p>A story MAY carry no attachment. The caption is then the whole
story:</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;story&quot;,&quot;caption&quot;:&quot;back in the city on Thursday&quot;,&quot;exp&quot;:1788000000,
&quot;bg&quot;:3}</code></pre>
<p><code>att</code> is absent, <code>caption</code> is REQUIRED and MUST
NOT be empty, and <code>exp</code> rides at the top level exactly as it
does for a story with a blob. <code>bg</code> is an OPTIONAL integer
naming one of the client's own background styles; it is a rendering hint
with no meaning on the wire, and a receiver that does not recognise the
value MUST fall back to its default rather than refuse the story.</p>
<p>A <strong>link story</strong> is a text story whose caption contains
a URL and which carries a §6.12 <code>lnk</code> preview. Everything
§6.12 says applies unchanged and is the reason it is not a separate
kind: <strong>the sender fetches the preview</strong>, and it rides
inside the ciphertext. A receiver MUST NOT fetch the URL to draw the
card — doing so would tell the site the address of every viewer, once
per viewer, which for a story is the whole audience rather than one
correspondent.</p>
<p>🔴 <strong>A client that predates this section renders a text story
into the conversation.</strong> Its decoder requires <code>att</code>
for <code>kind: "story"</code>, fails that guard, and falls through to
the path that shows <code>caption</code> as an ordinary message. The
result is a story appearing as a chat bubble that never expires — the
two things §6.6 says a story must not do. This is a real cost and it is
stated here rather than discovered: senders SHOULD NOT post text stories
until the recipients' builds are known to understand them, and a client
MAY choose to send a text story as a rendered image to unknown builds.
The alternative — a new <code>kind</code> — would degrade no better,
since §6.10 makes an unknown kind invisible rather than legible, and a
story silently vanishing is not obviously the kinder failure.</p>
<p>⚠️ A text story carries no blob, so there is nothing for the server's
retention purge (§9) to collect. Its entire lifetime is the
<code>exp</code> that honest clients enforce, which makes the advisory
nature of story expiry stated in §6.6 the <em>only</em> mechanism here
rather than a backstop to one.</p>
<h3 id="622-story-reactions">6.22 Story reactions</h3>
<p>A one-tap acknowledgement of a story. It is a sealed direct message
to the author, exactly as a story reply is (§6.20), and carries the same
reference:</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;react&quot;,&quot;caption&quot;:&quot;\ud83d\udc4d&quot;,&quot;sre&quot;:{&quot;sid&quot;:&quot;&lt;story id&gt;&quot;}}</code></pre>
<p><code>kind</code> is <code>react</code> and <code>caption</code> is
the emoji, matching §6.9's reaction envelope; <code>sre</code> names the
story, matching §6.20. An empty <code>caption</code> withdraws a
reaction already sent — removal must be a message rather than an
absence, because the author's device has already rendered the first one
and there is no other way to reach it.</p>
<p>🔴 <strong><code>re</code> is absent and MUST be.</strong> §6.9's
reaction names a message by <code>mid</code> and folds onto it; a story
is not in the conversation and has no <code>mid</code> there, so a story
reaction that carried <code>re</code> would fold onto whatever message
happened to share the id — or onto nothing. A receiver MUST route on
<code>sre</code> and MUST NOT render a story reaction into the thread as
a message.</p>
<p>⚠️ <strong>The disclosure is the same as §6.20's, and MUST be made
separately.</strong> A reaction reveals that you watched, exactly as a
reply does. What differs is the deliberation behind it: a reply is
typed, and a reaction is one tap on a story that is already filling the
screen. A client that has disclosed the reply case MUST NOT treat that
as covering this one — the first reaction an account sends MUST be
preceded by its own statement that the author will learn they were
watched.</p>
<p>⚠️ A client MUST NOT offer a reaction control that sends on the same
gesture that advances or dismisses the story. An acknowledgement
delivered by a mis-tap is a disclosure the person did not make.</p>
<h3 id="623-multi-item-stories">6.23 Multi-item stories</h3>
<p>Several pictures posted as one sequence. Each item is an ordinary
story envelope (§6.6) with one extra field naming the set it belongs
to:</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;story&quot;,&quot;att&quot;:{…},&quot;exp&quot;:1788000000,
&quot;set&quot;:{&quot;id&quot;:&quot;&lt;set id&gt;&quot;,&quot;i&quot;:0,&quot;n&quot;:3}}</code></pre>
<p><code>set.id</code> is an opaque identifier the sender mints,
<code>i</code> is the item's position from zero, and <code>n</code> is
how many items the set has. A client SHOULD group items sharing a
<code>set.id</code> from the same author and present them in
<code>i</code> order.</p>
<p>🔴 <strong>One envelope per item, not one envelope carrying
many.</strong> An array of attachments would be the obvious encoding and
it degrades badly: a client that predates this section requires a single
<code>att</code>, so it would fail the guard and render the whole set as
an ordinary message in the conversation (§6.21 documents that failure).
Separate envelopes degrade to exactly what they are — several stories
from one person, in order, which is what the sender meant and what every
existing build already knows how to draw.</p>
<p>⚠️ <strong>Expiry is per SET, not per item, and every item MUST carry
the same <code>exp</code>.</strong> This is a decision rather than a
detail. Per-item expiry makes a sequence decay: after some hours the set
holds items 0 and 2, which is both confusing to look at and a disclosure
— the gaps reveal the order the author added things and roughly when. A
receiver that finds items of one set carrying different <code>exp</code>
values MUST treat the EARLIEST as the expiry of the whole set, so a
sender cannot use the difference to keep part of a sequence alive after
the rest has gone.</p>
<p>⚠️ <code>n</code> is the sender's claim and MAY be wrong — items are
separate envelopes and any of them can fail to arrive. A client MUST
render what it has rather than waiting for <code>n</code> items, and
MUST NOT show a set as incomplete. A missing item is indistinguishable
from one never sent.</p>
<p>⚠️ The cost is the sender's: <code>n</code> items is <code>n</code>
blobs uploaded and <code>n</code> envelopes per recipient, so a set of
ten to twenty contacts is two hundred sends. Clients SHOULD bound the
number of items they will post in one set.</p>
<h3 id="624-stories-posted-to-a-group">6.24 Stories posted to a
group</h3>
<p>A story MAY be addressed to a group rather than to contacts. It is an
ordinary story envelope (§6.6) carrying the group it belongs to, fanned
out pairwise to the members exactly as a group message is (§6.7):</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;story&quot;,&quot;att&quot;:{…},&quot;exp&quot;:1788000000,&quot;gid&quot;:&quot;&lt;group id&gt;&quot;}</code></pre>
<p>Receivers MUST render it in the story surface, not in the group's
message thread — a story is not a message, and <code>gid</code> here
names the audience rather than the destination thread.</p>
<p>🔴 <strong>The group's roster is the audience, and the sender's story
audience does NOT apply.</strong> These are two different lists with two
different rules, and this is the whole hazard of the feature. §6.6's
audience is a statement about who may see this person's stories; a group
roster is a statement about who is in a conversation. They will
disagree, and when they do the roster wins, because a story addressed to
a group that silently dropped some members would be a group message that
some of the group cannot see — a worse failure than the one being
avoided.</p>
<p>⚠️ <strong>A client MUST say so before the first group story an
account posts</strong>, and MUST name the consequence concretely: that
it goes to everyone in the group, including members this account has
excluded from its stories, and including members who are not contacts at
all. A sender who believes their story audience still applies is being
misled by the interface, not by the protocol.</p>
<p>⚠️ <strong><code>exp</code> still governs, and is not extended by
group membership.</strong> A group story expires on the same terms as
any other; a client MUST NOT keep it because it also happens to be
addressed to a conversation that keeps history.</p>
<p>⚠️ A group story MUST NOT be forwarded into the contacts story
surface of members who are not in that group, and MUST NOT be treated as
evidence that its author is a contact. Group membership and the contact
list are separate facts, and a story is not an introduction.</p>
<h3 id="625-reporting-content">6.25 Reporting content</h3>
<p>Blocking needs no protocol: it is a local decision plus the relay's
existing block list, and nothing about the reported content leaves the
reporter's device. Reporting is different, and the difference is the
whole of this section.</p>
<p>🔴 <strong>A report is the one place where a user deliberately breaks
their own confidentiality.</strong> The relay stores ciphertext it
cannot read, so it cannot examine a reported message or story on its own
account, and no amount of server-side work changes that. A report that
carried only an accusation — "this account sent me something abusive" —
gives an operator nothing to act on but the assertion, which makes
moderation either credulous or useless. So a report MUST carry the
reporter's own decrypted copy of the content being reported, and that
copy is readable by the operator.</p>
<p>⚠️ <strong>Therefore consent, stated in those terms, before the
report is sent.</strong> A client MUST tell the reporter, in the moment
and not in a policy document, that sending the report gives the operator
a readable copy of the content and of who sent it. "Report" on its own
is a button whose consequence is invisible and irreversible. A client
MUST NOT report anything the user has not explicitly confirmed, MUST NOT
batch a report with any other action, and MUST NOT include surrounding
conversation the user did not choose to include.</p>
<p>The submission is an ordinary authenticated request, not a content
envelope — it is addressed to the operator rather than to a person:</p>
<pre><code>POST /moderation/report
{ &quot;about&quot;: 12, &quot;kind&quot;: &quot;story&quot;, &quot;reason&quot;: &quot;abuse&quot;,
&quot;content&quot;: &quot;&lt;the reporter&#("#")39;s decrypted copy&gt;&quot;, &quot;note&quot;: &quot;&lt;optional, the reporter&#("#")39;s words&gt;&quot; }</code></pre>
<p><code>about</code> is the account being reported. <code>kind</code>
distinguishes a story from a message so an operator knows what surface
it came from. <code>reason</code> is a short enumerated value.
<code>content</code> is what the reporter saw, and MAY be empty when the
reporter declines to include it — a report with no content is weaker,
and a client SHOULD say so rather than sending it silently.</p>
<p>⚠️ <strong>A report MUST NOT be presented as, or accompanied by, a
claim that the operator can see the conversation.</strong> They cannot.
They see exactly what this one request carried, because the reporter
sent it, and a report from one side is one side's account.</p>
<p>⚠️ Reporting MUST NOT notify the reported account, and the relay MUST
NOT expose reports to anyone but the operator. A report that told the
subject who filed it would make reporting dangerous for exactly the
people most likely to need it.</p>
<p>⚠️ A client SHOULD offer blocking alongside reporting and SHOULD make
clear they are separate: blocking takes effect immediately and is the
reporter's own decision; a report is a request to somebody else, and
nothing may happen.</p>
<h3 id="626-wiping-a-lost-device">6.26 Wiping a lost device</h3>
<p>Revoking a device stops it receiving new mail. Everything already on
it stays readable, so a phone that is lost rather than retired is still
a copy of the conversation. A device MAY be instructed to erase what it
holds, and the instruction MUST be signed by the account key (§20), not
merely served by the relay:</p>
<pre class="json"><code>{&quot;wipe&quot;:1,&quot;device&quot;:&quot;&lt;device id&gt;&quot;,&quot;at&quot;:1788000000,
&quot;sig&quot;:&quot;&lt;b64 Ed25519 over the canonical bytes below&gt;&quot;}</code></pre>
<p>The signed bytes are
<code>"fnwipe:v1:" || device_id || ":" || decimal(at)</code>, exactly,
with no whitespace. The verifying device already holds what it needs:
its own account address is <code>fnid@relay</code>, and the fnid IS the
account public key (§20.3), saved at sign-in.</p>
<p>🔴 <strong>The relay must not be able to cause a wipe, and this is
the whole point of the section.</strong> The obvious implementation is
to erase when the relay says the device is gone — an HTTP status on the
next poll. That hands an untrusted server the power to destroy every
user's history at will, irreversibly. It cannot read anything, and it
must not be able to delete anything either. A client MUST NOT erase
local data on the strength of any relay response that carries no
signature: a device told by an unsigned mechanism that it has been
removed SHOULD sign out and stop polling, and MUST leave stored messages
alone.</p>
<p>⚠️ <strong>Signing requires the passphrase, and that is
deliberate.</strong> The account key is derived, not stored (§20.3), so
issuing a wipe means re-entering the passphrase. A device able to wipe
the account's other devices without one would turn any unlocked phone
into a weapon against its owner's other phones.</p>
<p>⚠️ <strong>Best effort, and clients MUST describe it that
way.</strong> A device that is off, offline, or wiped-and-reset never
sees the instruction. <code>at</code> bounds nothing on its own — a
device that reappears in a year still honours a year-old instruction,
which is correct, because the instruction has not stopped being true.
What a client MUST NOT say is that the data is gone: it is gone from
devices that have since connected, and unknown elsewhere.</p>
<p>⚠️ A wipe instruction MUST be scoped to one <code>device</code>, and
a device MUST ignore one naming any device but itself. "Wipe everything"
is a different and much more dangerous operation; a client wanting it
issues one instruction per device so each is separately auditable.</p>
<p>⚠️ An account with no account key — one that only ever signed in
against a single relay and never derived an fnid — cannot use this. Such
a client MUST offer revocation without claiming a wipe, rather than
falling back to an unsigned mechanism.</p>
<h3 id="627-screenshot-notices">6.27 Screenshot notices</h3>
<p>A disappearing-message timer (§6.11) promises the message goes away.
A screenshot breaks that promise silently, and the sender never learns.
It cannot be prevented on either platform. It can be reported, and a
client MAY tell the sender when it notices one:</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;shot&quot;,&quot;ctl&quot;:1,&quot;at&quot;:1788000000}</code></pre>
<p><code>ctl</code> is set (§6.10), so a build that does not know
<code>shot</code> stays quiet rather than rendering it. Receivers MUST
render it as a notice in the conversation, never as a message, and MUST
NOT allow it to reset or extend any timer.</p>
<p>🔴 <strong>This is a courtesy from the taker's own client, and
nothing else.</strong> The device that took the screenshot is the device
that reports it. A modified build, a second phone photographing the
screen, or the platform's own screen recorder all produce no notice at
all. That makes one direction of the inference completely invalid: a
notice arriving means one was probably taken; <strong>no notice arriving
means nothing whatsoever</strong>. A client MUST NOT present the absence
of notices as evidence that a conversation was not captured, and MUST
NOT offer any summary — "no screenshots taken", a count of zero, a green
tick — that would be read that way.</p>
<p>⚠️ <strong>The platforms differ and the interface MUST NOT imply
otherwise.</strong> iOS reports a screenshot to the foreground app
reliably. Android has no equivalent before API 34, and its callback
there covers screenshots but not screen recording. So a client MUST
describe this as "you are told when the other person's app can tell",
never as "you are told when somebody screenshots". Claiming a symmetry
that does not exist is worse than the gap, because somebody would rely
on it.</p>
<p>⚠️ Only in conversations with a timer set. Sending it everywhere
would make it a general surveillance feature rather than the narrow one
it is: the promise a screenshot breaks is the one §6.11 made, and where
no promise was made there is nothing to report against.</p>
<p>⚠️ A client SHOULD send at most one notice per conversation per short
window. A screenshot often produces several platform events, and a
person who took one screenshot should not appear to have taken four.</p>
<h2 id="7-safety-numbers-verification">7. Safety numbers
(verification)</h2>
<p>For a local identity <code>IK_sig_local_pub</code> and remote