Count the sealed-to-unsealed fallback, and offer to refuse it

Android half of ios#16. `Sent.sealed` already reported each send's
outcome, but nothing accumulated, so nobody could answer "how often does
this happen to me".

🔴 The copy distinguishes sender from content, with tests asserting it.
An unsealed send is still end-to-end encrypted; what the relay learns is
who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong
decision in both directions.

⚠️ Aggregate only. Per-contact counts would be more useful and would also
build a record of who this account talks to and how often — the precise
thing sealing exists to keep off a server.

Refusing is offered and off by default: it turns a metadata leak into an
undelivered message, which for most people is the worse outcome. It has
its own exception type so the refusal can explain itself.

⚠️ Refusing spends nothing. `sealed` is false exactly when neither a
capability nor a key was found, so `takePeerToken` returned null and
there is no token to hand back — my first cut had a line handing one
back, which was unreachable.

⚠️ The policy and the counter are injected as lambdas, since `client` and
the engine have no Android dependency, and both are wired in one helper.
Two sign-in paths build a session, and only one of them applying the
policy is how a setting silently stops working on half the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:57:00 -04:00
co-authored by Claude Opus 4.8
parent 1316996ac5
commit 4c3f30e180
9 changed files with 284 additions and 0 deletions
@@ -164,6 +164,15 @@ class Conversations(
}
/** What happened to one send. Partial success is the normal case, not an error. */
/**
* Whether this device refuses to send when it cannot seal (ios#16).
*
* ⚠️ Injected rather than read from preferences here: this module has no
* Android dependency. An unset value must read as FALSE — degrade and say
* so, rather than silently refusing to send on a fresh install.
*/
var requireSealed: () -> Boolean = { false }
data class Sent(
val delivered: List<String>,
val failed: Map<String, String>,
@@ -685,6 +694,14 @@ class Conversations(
// cannot tell it from a photo (§14.4).
val sealed = peerToken != null || peerKey != null
// ios#16 — refuse rather than degrade, for the people who would rather
// a message did not go than go attributable.
//
// ⚠️ Nothing is spent by refusing here: `sealed` is false exactly when
// neither a capability nor a key was found, so `takePeerToken` above
// returned null and there is no token to hand back.
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
val preludes = listOfNotNull(
ourDeliveryKeyEnvelopeFor(toUserId),
ourKeyLogHeadEnvelopeFor(toUserId),
@@ -90,6 +90,20 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
/** A 2xx whose body was not what the route promised. */
class Malformed(message: String) : RelayException(message)
/**
* Sealing was required and this send could not be sealed (§14.5, ios#16).
*
* ⚠️ Its own type rather than a generic failure. The user turned this on
* deliberately and the message did not go for a reason they chose — a bare
* "couldn't send" reads as the app being broken, and the next thing
* somebody does is switch the setting off without knowing that is what
* they are doing.
*/
class SealingRequired : RelayException(
"Not sent. This message could not be sealed, so the server would have seen " +
"that you sent it — and you asked Flamenet to refuse rather than send " +
"anyway. It will seal again once they message you.")
}
/**