From 78f82f3ea080a3d2a4f1ac11ac8d0adabfb9b380 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 15:06:35 -0400 Subject: [PATCH] =?UTF-8?q?Regenerate=20the=20spec=20page=20for=20=C2=A76.?= =?UTF-8?q?15=20through=20=C2=A76.27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine spec gained thirteen sections — editing a sent message, forwarding, per-message timers, mentions, pinning, the six story sections, reporting, signed device wipes and screenshot notices. The published page is generated from it and pins the source hash, so it has to move with it. Generated by Scripts/build-doc-pages.sh; no hand edits. Supersedes the per-section regeneration MRs !16, !17, !19, !20, !22 and !23. --- Resources/Views/spec.leaf | 552 +++++++++++++++++++++++++++++++++++++- 1 file changed, 549 insertions(+), 3 deletions(-) diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf index 14869fa..477f821 100644 --- a/Resources/Views/spec.leaf +++ b/Resources/Views/spec.leaf @@ -3,7 +3,7 @@ + source-sha256: 94f4d086a225f72b -->

Flamenet @@ -364,8 +364,9 @@ required, dur absent), audio sent as 0; RECOMMENDED encoding AAC in an MPEG-4 container, mime: "audio/mp4", so every platform's native decoder opens it), and story.

-

Stories. A story is an image attachment -addressed to every buddy: the sender seals and uploads the blob +

Stories. A story is usually an image +attachment addressed to every buddy (a story with no attachment +at all is §6.21): the sender seals and uploads the blob once, then sends one kind: "story" 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.

+

For the same reason, a client that lets a reader tap a quote +to reach the message it answers MUST select that target by +mid alone. Matching on preview — or falling +back to it when the mid 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 +mid the receiver does not hold has no target: the correct +behaviour is to render the preview and offer no jump, not to guess.

Reactions. kind is "react", caption is the emoji, re names the target:

@@ -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.

+

6.15 Editing a +message that was already sent

+

A sender MAY replace the text of a message it sent earlier. +kind is "edit", re names the +target, and caption carries the new wording:

+
{ "fnc": 1, "kind": "edit", "caption": "meet at 9", "re": { "mid": "<target>", "author": 0, "preview": "" } }
+

The receiver decides authorship, and MUST NOT read it from +the envelope. re.author 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 +and already attributes to the sender of the edit. An +implementation that trusted re.author would let one +envelope rewrite what somebody else said, inside the reader's own +transcript. Senders SHOULD write 0 in that field, as this +specification's reference implementations do, precisely so that nothing +is tempted to believe it.

+

An edit MUST be visible as an edit. 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.

+

Further requirements:

+
    +
  • 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.
  • +
  • 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.
  • +
  • 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.
  • +
  • Edits are monotonic. 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.
  • +
  • A message with no mid cannot be edited, for the same +reason it cannot be reacted to: there is nothing to name it by.
  • +
+

gid applies as elsewhere: an edit of a group message +carries the group id and routes to the group thread.

+

Status. 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 mid for each line. That single gap also blocks replies +and reactions on Android (see §6.9), and closing it enables all +three.

+

6.16 Forwarding

+

A sender MAY pass a message on to another conversation. A forward +carries the content and nothing about where it came +from:

+
{ "fnc": 1, "kind": "text", "fwd": 1, "mid": "<a NEW id>", "caption": "meet at 6" }
+

fwd follows §6.10's flag shape — 1, with +true accepted on the way in.

+

What MUST NOT travel. A forwarded envelope MUST NOT +carry re, and MUST NOT carry the mid 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 mid is +unique within a conversation, so the same one appearing in two +conversations is a handle the relay can use to tie +those two conversations together — which is the link a forward is +supposed to break. A forward MUST mint a fresh mid.

+

Attachments are re-uploaded, not re-referenced. A +forwarded blob MUST be sealed again under a new key with a new id, +rather than the original att 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 +mid 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 exp: a +story hours from vanishing must not hand the new recipient a copy that +vanishes with it.

+

What fwd says, and what it does not. It +says the sender is passing on somebody else's words. It says nothing +about whose. 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.

+

gid applies as elsewhere.

+

Status. 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.

+

6.17 A timer on one message

+

A sender MAY put a timer on a single message rather than on the whole +conversation. It rides the content envelope in secs, +alongside the message it applies to:

+
{ "fnc": 1, "kind": "text", "mid": "<id>", "secs": 60, "caption": "meet at 6" }
+

The shorter timer always wins. Where a message +carries secs and its conversation has a timer from §6.11, +the message lives for the lesser 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 secs 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.

+

A duration, never a deadline. secs 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.

+

Further requirements:

+
    +
  • Zero, absent, or negative all mean no timer, and +MUST NOT be read as "expire immediately". A message whose sender chose +nothing must not vanish on arrival.
  • +
  • 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.
  • +
  • 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.
  • +
  • ⚠️ secs on a kind: "ttl" envelope means +something else entirely: it sets the whole conversation (§6.11). A +receiver MUST distinguish them by kind.
  • +
+

What the relay learns: nothing. 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.

+

Status. Implemented in the iOS client and in the +Kotlin engine and Android app, including the combining rule and +per-message pruning on both.

+

6.18 Mentions

+

A group message MAY name the members it is addressed to, in +ment:

+
{ "fnc": 1, "kind": "text", "gid": "<group>", "ment": [12, 34], "caption": "@Ada @Adam thoughts?" }
+

A mention decides whether the receiver is notified, and +nothing else. It confers no permission, changes no state, and +is not evidence of anything. This matters because ment 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.

+

Which is the rule that makes mentions safe to have. +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:

+
    +
  • the sender decides that a message is addressed to +somebody;
  • +
  • the receiver decides whether being addressed is +worth a notification.
  • +
+

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.

+

Further requirements:

+
    +
  • A receiving client MUST resolve names against the group's roster and +MUST NOT treat ment as naming anyone who is not a +member.
  • +
  • ment 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.
  • +
  • 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.
  • +
  • Reading the conversation beats a mention. A banner about a message +already on screen is no less absurd for naming you.
  • +
  • Omitted entirely when there are no mentions, which is nearly every +message.
  • +
+

⚠️ Muting is per conversation, not per author. +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.

+

What the relay learns: nothing. ment 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.

+

Status. 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.

+

6.19 Pinning a message

+

A conversation MAY have one pinned message. kind is +"pin" and re names it:

+
{ "fnc": 1, "kind": "pin", "re": { "mid": "<target>", "author": 0, "preview": "" } }
+

An empty mid 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.

+

A pin is an id, and a receiving client MUST NOT store a copy +of the message. 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.

+

Pins are monotonic. 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.

+

Further requirements:

+
    +
  • 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.
  • +
  • A pin naming a mid the receiver does not hold shows +nothing, rather than an empty banner claiming something is pinned.
  • +
  • re.author is not read, exactly as in §6.15. Senders +SHOULD write 0.
  • +
  • gid applies as elsewhere: a pin in a group carries the +group id and pins there.
  • +
+

⚠️ One pin, not a list. 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.

+

Status. 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 mid for each message; the moment it does, the pin becomes +a lookup like the other.

+

6.20 Story replies

+

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:

+
{"fnc":1,"kind":"text","caption":"where is this?","sre":{"sid":"<story id>"}}
+

sre.sid 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.

+

🔴 sre carries no copy of the story — no +caption, no blob, no thumbnail. This diverges from +re 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 preview 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.

+

A receiver whose copy of sid 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.

+

⚠️ Replying tells the author you watched, and that is a +disclosure the replier makes. 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.

+

⚠️ sid 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.

+ +

A story MAY carry no attachment. The caption is then the whole +story:

+
{"fnc":1,"kind":"story","caption":"back in the city on Thursday","exp":1788000000,
+ "bg":3}
+

att is absent, caption is REQUIRED and MUST +NOT be empty, and exp rides at the top level exactly as it +does for a story with a blob. bg 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.

+

A link story is a text story whose caption contains +a URL and which carries a §6.12 lnk preview. Everything +§6.12 says applies unchanged and is the reason it is not a separate +kind: the sender fetches the preview, 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.

+

🔴 A client that predates this section renders a text story +into the conversation. Its decoder requires att +for kind: "story", fails that guard, and falls through to +the path that shows caption 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 kind — 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.

+

⚠️ A text story carries no blob, so there is nothing for the server's +retention purge (§9) to collect. Its entire lifetime is the +exp that honest clients enforce, which makes the advisory +nature of story expiry stated in §6.6 the only mechanism here +rather than a backstop to one.

+

6.22 Story reactions

+

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:

+
{"fnc":1,"kind":"react","caption":"\ud83d\udc4d","sre":{"sid":"<story id>"}}
+

kind is react and caption is +the emoji, matching §6.9's reaction envelope; sre names the +story, matching §6.20. An empty caption 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.

+

🔴 re is absent and MUST be. §6.9's +reaction names a message by mid and folds onto it; a story +is not in the conversation and has no mid there, so a story +reaction that carried re would fold onto whatever message +happened to share the id — or onto nothing. A receiver MUST route on +sre and MUST NOT render a story reaction into the thread as +a message.

+

⚠️ The disclosure is the same as §6.20's, and MUST be made +separately. 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.

+

⚠️ 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.

+

6.23 Multi-item stories

+

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:

+
{"fnc":1,"kind":"story","att":{…},"exp":1788000000,
+ "set":{"id":"<set id>","i":0,"n":3}}
+

set.id is an opaque identifier the sender mints, +i is the item's position from zero, and n is +how many items the set has. A client SHOULD group items sharing a +set.id from the same author and present them in +i order.

+

🔴 One envelope per item, not one envelope carrying +many. An array of attachments would be the obvious encoding and +it degrades badly: a client that predates this section requires a single +att, 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.

+

⚠️ Expiry is per SET, not per item, and every item MUST carry +the same exp. 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 exp +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.

+

⚠️ n 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 n items, and +MUST NOT show a set as incomplete. A missing item is indistinguishable +from one never sent.

+

⚠️ The cost is the sender's: n items is n +blobs uploaded and n 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.

+

6.24 Stories posted to a +group

+

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):

+
{"fnc":1,"kind":"story","att":{…},"exp":1788000000,"gid":"<group id>"}
+

Receivers MUST render it in the story surface, not in the group's +message thread — a story is not a message, and gid here +names the audience rather than the destination thread.

+

🔴 The group's roster is the audience, and the sender's story +audience does NOT apply. 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.

+

⚠️ A client MUST say so before the first group story an +account posts, 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.

+

⚠️ exp still governs, and is not extended by +group membership. 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.

+

⚠️ 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.

+

6.25 Reporting content

+

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.

+

🔴 A report is the one place where a user deliberately breaks +their own confidentiality. 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.

+

⚠️ Therefore consent, stated in those terms, before the +report is sent. 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.

+

The submission is an ordinary authenticated request, not a content +envelope — it is addressed to the operator rather than to a person:

+
POST /moderation/report
+{ "about": 12, "kind": "story", "reason": "abuse",
+  "content": "<the reporter&#("#")39;s decrypted copy>", "note": "<optional, the reporter&#("#")39;s words>" }
+

about is the account being reported. kind +distinguishes a story from a message so an operator knows what surface +it came from. reason is a short enumerated value. +content 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.

+

⚠️ A report MUST NOT be presented as, or accompanied by, a +claim that the operator can see the conversation. 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.

+

⚠️ 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.

+

⚠️ 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.

+

6.26 Wiping a lost device

+

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:

+
{"wipe":1,"device":"<device id>","at":1788000000,
+ "sig":"<b64 Ed25519 over the canonical bytes below>"}
+

The signed bytes are +"fnwipe:v1:" || device_id || ":" || decimal(at), exactly, +with no whitespace. The verifying device already holds what it needs: +its own account address is fnid@relay, and the fnid IS the +account public key (§20.3), saved at sign-in.

+

🔴 The relay must not be able to cause a wipe, and this is +the whole point of the section. 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.

+

⚠️ Signing requires the passphrase, and that is +deliberate. 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.

+

⚠️ Best effort, and clients MUST describe it that +way. A device that is off, offline, or wiped-and-reset never +sees the instruction. at 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.

+

⚠️ A wipe instruction MUST be scoped to one device, 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.

+

⚠️ 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.

+

6.27 Screenshot notices

+

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:

+
{"fnc":1,"kind":"shot","ctl":1,"at":1788000000}
+

ctl is set (§6.10), so a build that does not know +shot 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.

+

🔴 This is a courtesy from the taker's own client, and +nothing else. 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; no notice arriving +means nothing whatsoever. 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.

+

⚠️ The platforms differ and the interface MUST NOT imply +otherwise. 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.

+

⚠️ 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.

+

⚠️ 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.

7. Safety numbers (verification)

For a local identity IK_sig_local_pub and remote -- 2.54.0