Commit Graph
98 Commits
Author SHA1 Message Date
Paul Hitt adf7858940 Post several pictures as one story sequence (§6.23)
Taken by hand rather than cherry-picked, for the same reason as the
archive: the original was cut from a tree where text stories looked
different from the version rebuilt on this branch, and applying it whole
would have added a second `isTextStory`/`storyExpiry` beside the
existing `textStoryExpires`.

What came across is the sequence and nothing else — the `StorySet` wire
shape, the field on a story, and the two rules that decide how a set
behaves when it does not all arrive.

🔴 One envelope per item, sharing one set id. An array of attachments in
a single envelope is the obvious encoding and degrades badly: a client
predating §6.23 requires a single `att`, fails the guard, and renders
the whole set as a message in the conversation. Separate envelopes
degrade to several stories in order, which every existing build already
draws correctly.

🔴 `setExpiry` takes the EARLIEST. Every item is required to carry the
same `exp`, but a sender can write different ones — and if the latest
won, the difference would be a way to keep part of a sequence alive
after the rest had gone. A text story has no attachment and contributes
nothing, so one in a set cannot drag the shared expiry to zero.

⚠️ `groupedStories` renders what arrived. `n` is the sender's claim and
any item can fail to arrive, so a set is never held back or shown as
incomplete — a missing item is indistinguishable from one never sent.

777 tests green across app, engine and client, up from 648.
2026-09-03 19:16:48 -04:00
Paul Hitt bee63e0ef7 Keep your own stories in a private archive
Reinstates ios#54, taken by hand rather than cherry-picked. The original
commit was cut from a tree where story mute, text stories and story
replies all looked different from the versions rebuilt on this branch
today, so applying it whole would have left two implementations of each
under different names — no compile error, and exactly the kind of
divergence that rots quietly.

What came across is the archive and nothing else: the `archive()` rule,
the store, the sweep, and the setting.

🔴 The rule refuses anything not the author's own, and it does so in the
engine as well as at the caller. An archive holding somebody else's
expired story breaks the promise the app made to them, and "it is
private to you" does not repair that.

⚠️ The sweep runs at the one moment it can. Expired stories leave on
write, so if they are not caught before `saveStories` they are gone —
which is correct for everybody else's and wrong for your own.

`archiveEnabled` is injected rather than read from a context, because
this layer has no Android dependency and an unset value has to read as
OFF. This is a feature that keeps something the interface said would
disappear, so it stays off until somebody asks for it.

Vault story serialisation is now shared between the live set and the
archive rather than written twice.
2026-09-03 19:13:56 -04:00
Paul HittandClaude Opus 4.8 9ca2c352e7 Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:11:21 -04:00
Paul HittandClaude Opus 4.8 a727475447 Recognise a pinned message (§6.19)
Codec only — the app does not pin yet — but without this branch an
inbound pin falls through parse to the attachment path and comes back
renderable, so every pin the iPhone app sets would draw an empty bubble
in the conversation.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every pin while looking like it worked.

🔴 The envelope carries an id and never a copy of the message. A pin
carrying the text would keep a message readable after its own timer had
removed it, so pinning would quietly defeat disappearing messages.

Refs paulhitt/flamenet-messenger-ios#50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:10:33 -04:00
Paul HittandClaude Opus 4.8 968a88789a Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:09:49 -04:00
Paul HittandClaude Opus 4.8 fc8d40ecf6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. 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.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:09:10 -04:00
Paul HittandClaude Opus 4.8 edd53bc771 Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:08:44 -04:00
Paul HittandClaude Opus 4.8 3dd44ebc9a The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them.
Content.groupText has taken a messageId since §6.9 shipped and parse
reads it back — the transcript was where it stopped. So nothing here
could name an earlier message: no replies to resolve, no reactions to
fold, no edits to apply. All three were "codec done" and none worked.

A reply from the iPhone app arrived as a bare message with the quote
silently removed, which is worse than the missing id: the reader had no
way to know anything had been dropped.

🔴 Also fixes a real bug in sendGroup. It built the envelope twice —
once to send, once for the outbox — so an id minted inside either would
differ between them, and a member who was offline would eventually
receive a DIFFERENT message from everybody else, which nothing could
reconcile with a reply or a reaction. One id, minted by the caller, used
by both.

Both fields written only when present: a null on every message would
grow the transcript for nothing and make "no id" indistinguishable from
"an id of nothing".

Refs paulhitt/flamenet-messenger-ios#58, #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:08:44 -04:00
Paul HittandClaude Opus 4.8 3acaf429e5 Recognise a forwarded message (§6.16)
An inbound forward is shown as one rather than as the sender's own
words. Both flag shapes accepted, like ctl: org.json's optBoolean does
not coerce a number, so an engine reading one shape would silently miss
every forward the other two send.

⚠️ The subtle one: Content.text returns raw bytes with no envelope at
all when there is no id, reply or link to carry. A forward that was not
counted in that condition would lose its flag and arrive looking like
the sender's own composition — so  is part of the test for
whether an envelope is needed, not just a field inside it.

Codec only. Originating a forward needs the transcript work in #10.

Refs paulhitt/flamenet-messenger-ios#28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:07:13 -04:00
Paul HittandClaude Opus 4.8 4b295c2d73 Recognise an edit envelope so it is not drawn as a duplicate (§6.15)
Without this branch an edit falls through parse to the last case, which
returns the caption as text — so an iPhone correcting a typo appears on
Android as a SECOND bubble beside the original, with nothing to say
either replaced the other. That is the bug this fixes today, before any
of the applying half exists.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every correction silently, which is worse than the duplicate
because nobody would notice.

⚠️ Codec only. Session.Line records no mid, so nothing in an Android
transcript can be named by an edit and the app cannot apply one. That is
the same gap reactions have been sitting behind (PARITY.md "codec done,
no UI") and one transcript change unlocks replies, reactions and edits
together — filed separately.

Refs paulhitt/flamenet-messenger-ios#26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:05:38 -04:00
Paul HittandClaude Opus 4.8 9a2ff570e9 Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:05:17 -04:00
Paul HittandClaude Opus 4.8 2179615263 Stories you can answer, and an @ that finds people
The story features existed in the codec and nowhere a person could reach
them. + went straight to the file chooser, so a story of words was
unreachable even though the engine could carry one; there was no way to
react, reply, mute, or see what anybody had said back.

+ is a menu now. Reactions are a fixed row of five — a story is glanced
at and scrolled past, and anything that takes two taps to say "nice"
does not get used. Your own story shows the tally instead, because you
cannot react to yourself and a row of dead buttons is worse than none.

Mentions get an `@` picker in the group composer, matching iOS down to
the rule that matters: the id is recorded when somebody is PICKED, never
re-derived from the text at send time. Deriving it would mean typing a
name reached that person whether or not they were chosen, and renaming a
contact would silently unmention them. Only the ones still named in the
body are sent, so picking somebody and then deleting their name doesn't
ring them about a message that no longer mentions them.

A group member who isn't in your contacts has no name to show, so they
appear under their id. Un-mentionable would be worse than ugly — it
would mean a group where some people cannot be reached.

Mentions on Android are carried and rendered but override nothing: there
is no per-conversation notification mute here to override yet.

640 unit tests green across app, engine and client. PARITY.md updated —
mentions and the story features move to ✅, and Files and Wallet get rows
saying plainly that Android has none of either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:11:21 -04:00
Paul HittandClaude Opus 4.8 fdaf06415e Draw a story that is only words — and fix a bubble that never compiled
🔴 The file bubble committed earlier today DID NOT COMPILE. It reached for
Icons.Filled.InsertDriveFile in a file that imports no Material icons at
all, and I reported it as building.

The reason it went unnoticed matters more than the mistake: ANDROID_HOME
was unset, so `:app:compileDebugKotlin` failed on "SDK location not found"
before Kotlin ran — and the check for errors was a grep for lines starting
`e:`, which a build that never reached the compiler does not produce. A
green-looking check that never compiled anything. Every Android app-side
claim I made today rested on it.

It now uses the kit's own icon set, like the rest of the file, and the app
compiles with the SDK on the path.

Alongside: Vault.Story.ref is nullable, so a story can be words. Every
path here assumed a picture, which is the same shape that made a text
story impossible on iOS rather than merely unbuilt.

⚠️ `loadStories` used `getJSONObject("ref")`, which THROWS. A single text
story would have taken down the load of every story — a feed that is
simply empty, with nothing to say why. It reads the optional accessor now,
and tells "no ref at all" (a text story) apart from "a ref that would not
parse" (a corrupt row), because those deserve different treatment.

Expiry moved onto the story, falling back to the attachment for everything
already stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:18:48 -04:00
Paul HittandClaude Opus 4.8 bfe9fff430 Understand stories and mentions from iOS
Engine parity for the five features iOS regained: text stories, mentions,
story reactions, story replies. This build could already receive them —
legibly, because each degrades to its caption — but understood none of
them as what they are.

🔴 Every envelope in the test is the shape **iOS** assembles, not this
engine's own output. Two implementations that each only test against
themselves agree right up to the day somebody sends a real message, and
the symptom is a mention that never rings or a story that arrives as
"[unsupported attachment]", with nothing in either log to say why.

Two things the tests caught that reasoning would not have:

⚠️ Kotlin prints a Double in scientific notation, so this engine writes
`"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
both sides read either — but a story's expiry decides whether it shows at
all, and "arrived already expired" is indistinguishable from "never
arrived". Now pinned on both sides. Not a new risk: picture stories have
carried `exp` this way all along, which is the reason it works.

⚠️ An earlier version of one test built a JSONObject from an assembled
envelope and re-serialised it — which reordered the keys and moved `fnc`
off the front. It failed, correctly, and is a neat demonstration of why
both engines hand-assemble these strings instead of encoding a map.

UI comes next; this is the layer that decides whether the two apps agree
about what a message means.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:15:11 -04:00
Paul HittandClaude Opus 4.8 688a519e0c Understand a file somebody sent, rather than showing a line of text
iOS can now send a document from Files. This build treated `file` as a kind
from a newer peer: legible, because a file envelope always carries a
caption naming the document, but only as text.

The engine now knows the kind, parses the additive `fn`/`cls`/`cmp` keys,
and can assemble them — so sending works in both directions, not one.

🔴 The interop test parses an envelope produced by the iOS build rather
than by this engine. Two implementations of one format that each only test
against themselves agree right up to the day somebody sends a real file,
and the symptom is "[unsupported attachment]" with no clue why. It also
pins the two rules that keep older peers working: a file envelope is never
captionless, and a photo envelope still carries none of the new keys.

⚠️ The bubble shows the classification as a MARKING. It says how the
sender had it filed; it does not mean this device was cleared for it, and
whoever can read the message already has the key.

⚠️ No "Save to Files" on this side yet — this build has no Files of its
own, so the button would be one that cannot work. Showing what arrived is
the honest half of parity while the other half is built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:21:41 -04:00
Paul Hitt aebf41ed95 Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.

Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.

The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.

And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.

versionCode 10 so it installs over the broken v9.

Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
2026-09-02 19:03:30 -04:00
Paul HittandClaude Opus 4.8 4c3f30e180 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>
2026-09-02 17:57:00 -04:00
Paul HittandClaude Opus 4.8 b43be1b780 Post a story to a group
Android half of ios#55, spec §6.24. An ordinary story envelope carrying
`gid`, fanned out to the members and recorded in the story set.

🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who
is excluded by this account's story audience and who is not a contact at
all, counted separately. The roster decides either way — those are two
different lists making two different statements and they will disagree —
so a disclosure that cannot name the consequence is not one.

🔴 The group label is adopted only from an actual member. The roster
check that guards group messages runs after the story branch, so without
this a sender could attach the id of a group they are not in and have
their story shown as having come from it. Dropped to null rather than
refusing the story: the story is legitimately theirs, and only the claim
about where it was posted is unverified.

The story branch already sat before group routing, which is what keeps a
group story out of the conversation — filed there it would be a story
that is a message, and one that never expires out of the thread. That
ordering is now written down rather than incidental.

⚠️ `gid` is persisted, or a reload forgets which story went where.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:53:48 -04:00
Paul HittandClaude Opus 4.8 8b719032c2 React to a story with one tap
Android half of ios#52, spec §6.22.

🔴 Decoded before §6.9's reaction path, which requires `about`. A story
reaction carries `sre` instead — a story is not in the conversation and
has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave
it unhandled and drawn as an empty line in the thread. Tested both ways,
including that an ordinary thread reaction still decodes as before.

⚠️ Its own disclosure and its own stored key. A reaction reveals that you
watched exactly as a reply does; what differs is the deliberation. A
reply is typed. A reaction is one tap on a story already filling the
screen, so an account told about replies has been warned about the
considered case and not the careless one.

⚠️ The row does not share a gesture with anything that navigates: an
acknowledgement delivered by a mis-tap is a disclosure the person did not
make.

Branched from feat/story-replies, which carries the `sre` plumbing this
reuses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:52:32 -04:00
Paul HittandClaude Opus 4.8 52a793ce5c Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:50:17 -04:00
Paul HittandClaude Opus 4.8 d525ef3d69 Post several pictures as one story sequence
Android half of ios#53, spec §6.23. Each item is an ordinary story
envelope carrying a `set` naming the sequence, its position and its size.

🔴 One envelope per item rather than an array of attachments. The array
fails the single-`att` guard on any build predating §6.23 and renders the
whole set as a message in the conversation; separate envelopes degrade to
several stories in order, which every existing build already draws.

🔴 `setExpiry` takes the earliest stamp. Items are required to carry the
same one, but a sender can write different ones, and if the latest won
the difference would keep part of a sequence alive after the rest had
gone. A zero is skipped rather than read as "expires at the epoch",
which would kill the whole set.

⚠️ `groupedStories` renders what arrived: `n` is a claim, any item can
fail to arrive, and a missing one is indistinguishable from one never
sent. Stories with no set are keyed so two of them cannot collide.

⚠️ The set is persisted, or a relaunch scatters every sequence back into
loose stories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:48:16 -04:00
Paul HittandClaude Opus 4.8 242391119b Keep your own stories in a private archive
Android half of ios#54. Expired stories were gone from the author's
device too, so there was no way to look back at what you posted.

🔴 This keeps something the interface told everybody would disappear, so
the shape of the feature is the argument for it being acceptable: off
unless switched on, labelled as what it is, and only ever holding stories
this account posted. `Vault.archive` refuses anything else — the caller
filters too, and this is the guard that survives the caller being
rewritten.

🔴 The archive is deliberately NOT written through `saveStories`.
Everything in it is expired by definition, and that path drops expired
stories on write — so reusing it would empty the archive on the next
pass. Separate record, and a test named for exactly that.

Archiving happens in `stories()`, before `saveStories` drops the expired
ones: the only moment they can be caught, since expired bytes leave on
write. That runs on every load, so the fold is idempotent.

⚠️ The setting is injected into `Session` as a lambda. The engine layer
has no Android dependency and must not read preferences, and an absent
value has to read as OFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:47:44 -04:00
Paul HittandClaude Opus 4.8 819fab3161 Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:46:24 -04:00
Paul HittandClaude Opus 4.8 83d7803f9e Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:44:21 -04:00
Paul HittandClaude Opus 4.8 7c51bc2521 Post a story that is only words, or a link
Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.

🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.

`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.

⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.

A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:43:43 -04:00
Paul HittandClaude Opus 4.8 3614665732 Recognise a pinned message (§6.19)
Codec only — the app does not pin yet — but without this branch an
inbound pin falls through parse to the attachment path and comes back
renderable, so every pin the iPhone app sets would draw an empty bubble
in the conversation.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every pin while looking like it worked.

🔴 The envelope carries an id and never a copy of the message. A pin
carrying the text would keep a message readable after its own timer had
removed it, so pinning would quietly defeat disappearing messages.

Refs paulhitt/flamenet-messenger-ios#50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:05:41 -04:00
Paul HittandClaude Opus 4.8 42b7d1732f Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:03:09 -04:00
Paul HittandClaude Opus 4.8 ddb82b87b6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. 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.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:59:37 -04:00
Paul HittandClaude Opus 4.8 19f4247b71 Recognise mentions in a group message (§6.18)
Codec only — the app does not compose or act on them yet — but it must
already understand an envelope the iPhone app sends, and more
importantly must not be made to do work by one.

🔴 Bounded and deduplicated on receipt. The list comes off the wire, and
a hostile sender could name ten thousand ids in one envelope; the cost
of that lands on every recipient's device rather than theirs. Capped at
256, far above any real roster.

A malformed list does not break the message: it is an ordinary message
with an odd field, not a reason to drop what somebody wrote.

Refs paulhitt/flamenet-messenger-ios#49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:56:26 -04:00
Paul HittandClaude Opus 4.8 a43f8a6feb Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:52 -04:00
Paul HittandClaude Opus 4.8 3b7e69c0eb The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them.
Content.groupText has taken a messageId since §6.9 shipped and parse
reads it back — the transcript was where it stopped. So nothing here
could name an earlier message: no replies to resolve, no reactions to
fold, no edits to apply. All three were "codec done" and none worked.

A reply from the iPhone app arrived as a bare message with the quote
silently removed, which is worse than the missing id: the reader had no
way to know anything had been dropped.

🔴 Also fixes a real bug in sendGroup. It built the envelope twice —
once to send, once for the outbox — so an id minted inside either would
differ between them, and a member who was offline would eventually
receive a DIFFERENT message from everybody else, which nothing could
reconcile with a reply or a reaction. One id, minted by the caller, used
by both.

Both fields written only when present: a null on every message would
grow the transcript for nothing and make "no id" indistinguishable from
"an id of nothing".

Refs paulhitt/flamenet-messenger-ios#58, #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:34 -04:00
Paul HittandClaude Opus 4.8 01e6d6fe6d Recognise a forwarded message (§6.16)
An inbound forward is shown as one rather than as the sender's own
words. Both flag shapes accepted, like ctl: org.json's optBoolean does
not coerce a number, so an engine reading one shape would silently miss
every forward the other two send.

⚠️ The subtle one: Content.text returns raw bytes with no envelope at
all when there is no id, reply or link to carry. A forward that was not
counted in that condition would lose its flag and arrive looking like
the sender's own composition — so  is part of the test for
whether an envelope is needed, not just a field inside it.

Codec only. Originating a forward needs the transcript work in #10.

Refs paulhitt/flamenet-messenger-ios#28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:17 -04:00
Paul HittandClaude Opus 4.8 f95cb1e6aa Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:01 -04:00
Paul HittandClaude Opus 4.8 0c1af3e45f Mute one contact's stories, without blocking them
Android half of ios#33. Same shape as iOS: the muted set lives in the
vault beside the audience list, because who somebody has muted is a
statement about their social graph and shared preferences are readable
by anything that reaches the file.

The filter runs on the way out of `stories()`, after `saveStories` has
already written the live set back. A muted contact's stories are still
received and still stored — dropping them on receipt would be cheaper,
but unmuting would then show an empty strip until their next post, and
muting would quietly mean something other than what it says.

`Vault.visibleStories` never drops the signed-in user, even if their own
id is in the set. `setStoryMuted` refuses to put it there, but a restored
backup from a build without that guard could carry one, and hiding
somebody's own posts from them reads as the posts having failed to send.

Long press on a story bubble to hide or restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:55 -04:00
Paul HittandClaude Opus 4.8 914112b032 Recognise an edit envelope so it is not drawn as a duplicate (§6.15)
Without this branch an edit falls through parse to the last case, which
returns the caption as text — so an iPhone correcting a typo appears on
Android as a SECOND bubble beside the original, with nothing to say
either replaced the other. That is the bug this fixes today, before any
of the applying half exists.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every correction silently, which is worse than the duplicate
because nobody would notice.

⚠️ Codec only. Session.Line records no mid, so nothing in an Android
transcript can be named by an edit and the app cannot apply one. That is
the same gap reactions have been sitting behind (PARITY.md "codec done,
no UI") and one transcript change unlocks replies, reactions and edits
together — filed separately.

Refs paulhitt/flamenet-messenger-ios#26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:39 -04:00
Paul HittandClaude Opus 4.8 ee17d773b7 A session we ANSWER can now be v3, and receive failures say why
`E2ESession.respond` hard-coded `version = if (pq == null) 1 else 2`, so
a session this device answered could never be v3 — while `initiate`
twelve lines above correctly returns 3 whenever the post-quantum ratchet
is on. A conversation iOS started therefore ran as v3 on one side and v2
on the other, and every message this device sent into it was refused by
the peer as a downgrade (§13.4) and dropped on arrival. iOS to Android
worked; Android to iOS did not, for as long as the pairing existed.

Kotlin talking to Kotlin never noticed, because both ends drive the
ratchet from their own PQ state and ignore the label. Only a peer that
enforces the version sees it. `HybridSessionTest` asserted the wrong
value — `assertEquals(2, bobSide.version)`, two lines below requiring
the initiator to be 3 — so the suite passed while encoding the defect.
Corrected, with the reason recorded next to it.

The version now follows the initiator rather than assuming 3 whenever PQ
is present: a v2 peer sends a PQ block too, and answering that in v3
breaks the pairing in the other direction.

Receive-side failures are no longer swallowed. `catch (_: Exception) {}`
threw away the reason for every undecryptable envelope, which left this
half of every delivery investigation unreadable — the relay says
delivered, the app shows nothing, and the one place that knew why
discarded it. The envelope is still skipped; it now names the id, the
device and the exception. Sends report the protocol shape they emit, and
receives report which of the three chains opened them.

Also here:

  - proguard-rules.pro, which did not exist while build.gradle.kts
    referenced it. R8 accepts a missing entry in silence, so release
    builds ran on the platform defaults and answering a call aborted the
    process inside WebRTC's JNI_OnLoad — it resolves Java classes by
    name, and R8 had renamed them. The build now fails if the file is
    missing rather than shipping without it.
  - Server refusals read as sentences. `RelayException.Http` had no case
    in `describe()`, so choosing a taken handle showed "relay returned
    409:" and a JSON fragment, which says nothing about picking another
    name. Transport failures showed a Java networking string naming an
    address the person never typed.
  - Switching between signing in and creating an account clears the last
    error. It belonged to the other mode and stayed on screen, so taking
    the advice it gave looked like it had failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:12:28 -04:00
Paul Hitt eaf860e794 Advertise v3, now that there is evidence for the claim
PROTOCOL_VERSION read 2 with a note saying 3 would be wrong until ML-KEM was in
every ratchet step, "a separate piece of work". That work is done and proven
against another engine, so the number moves in the same change as the evidence
rather than ahead of it.

Until now the ratchet was inert in the field: iOS advertises 3 and gates on the
peer doing the same, so an iOS-to-Android session negotiated v2 and no message
ever carried a ratchet step, whatever either engine implemented.

🔴 A version is a claim other devices act on. Peers pin the highest proven
version and the pin is monotonic (§13.4), so a build that advertises 3 and
cannot honour it does not degrade — it strands every contact that believed it.

Three tests moved with it, and one is new. `a v2 registration publishes the
whole block` asserted 2 and now asserts the constant. `a capability signature
does not transfer between versions` edited a bundle to `proto = 3` to prove the
signature would not follow — a hardcoded neighbour that stopped testing anything
the moment the engine published 3, so it uses `PROTOCOL_VERSION + 1`. The v2
gating test needed a peer that has genuinely proven 2, which this engine no
longer produces: `copy(proto = 2)` is not enough, because the capability
signature commits to the version, so `publishedV2` re-signs for it.

The new one checks the published bundle claims 3 AND proves it. Bumping the
constant without the signature following would produce a bundle every peer
silently reads as v1, with nothing reporting an error anywhere.

Also: `the sealed blob reveals nothing` was the same coin flip as its iOS twin —
sealing ids 41 and 42 and searching the BASE64 for "41" and "42", about a 14%
chance of a meaningless failure. It fired during this run. The iOS one was fixed
this morning and this was missed because only one engine was looked at.

606 tests, 0 failures. Cross-engine: 56 passed, 0 failed.
2026-08-31 13:00:22 -04:00
Paul HittandClaude Opus 4.8 1e96af0122 A v2 peer was being sent a post-quantum ratchet step
Proving the v3 ratchet across engines turned up a bug in shipping it, and this
one would have broken every existing Android conversation.

`E2ESession.initiate` turned the v3 ratchet on whenever the peer offered a PQ
prekey. Every v2 device offers one — offering a prekey is a claim to §12, not to
§13. So a build with this change would attach `pqdh`/`pqct` to messages for
0.6, the released build, whose ratchet has no post-quantum anything.

That fails twice over, which is why it would have looked like a network
problem. Their root step derives without `ss` and under the `_v1` info string
while ours used `_v3`, so the keys diverge; and their canonical header omits the
two fields, so the AAD does not match either. The first message never opens and
never would.

Gated on a PROVEN claim now, the way the iOS engine already does it:
`chosen != null && bundle.proto >= 3`. `bundle.proto` on its own is the relay's
word for it, but `chosen` is non-null only when the capability signature over
that same proto verified (§12.7), so the pair is a claim the peer actually made.

The test asserts on the absence of a header field, which is the only way to
catch this — every message still decrypts in a self-test, because both sides of
one engine agree with each other about the mistake.

The interop driver now publishes a real v3 bundle: `pqspk` under
`FlamenetE2E_PQSPK_v2`, and `caps_sig` over `FlamenetE2E_Caps_v2 ‖ uint8(3)`.
Both were needed before the web engine would negotiate anything but v1 — which
is why the harness had been quietly proving v1 and nothing else. It can still
publish a v1 bundle on request, because the fleet still holds devices that speak
it and that coverage must not disappear the day v3 lands.

605 tests, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 12:55:44 -04:00
Paul HittandClaude Opus 4.8 98342dd608 Carry post-quantum through every ratchet step, not just the handshake
iOS mixed a fresh ML-KEM secret into every ratchet step; Android used it only in
X3DH. A conversation gets the property only if both ends do, so nobody had it —
which is what the comparison page now says, and this is what would let that row
change.

The gap is not academic. v2 already protects a whole session against
harvest-now-decrypt-later, because the root chain never recovers once an
adversary has missed the start. What v2 lacks is post-quantum HEALING: after a
compromise, recovery rests entirely on X25519 steps, which a quantum adversary
breaks. The session never heals against that attacker.

Implemented to §13 rather than by reading the Swift: PQs/PQr/PQpending on the
session, KDF_RK_v3 over `dh_out || ss` with its own info string, and the two
optional header fields.

🔴 Decapsulation happens BEFORE adopting the peer's new PQ key. They encapsulated
to the key we held going into the step, and reversing those two derives a
different root key with no error until an AEAD open fails somewhere later.

⚠️ The PQ block repeats on every message of a sending chain until the peer
answers. Sending it once would mean losing that one message strands the chain:
the receiver never gets the material to advance its root key and nothing later
in the chain can be opened. It is replaced by the next ratchet step, not cleared
when the peer replies — it belongs to the sending chain.

Seven tests. Six drive conversations; the seventh pins the canonical header as
bytes, because the header is the AAD and three engines must produce it
identically. A conversation test cannot catch a reordered field — both sides
would drift together. It also pins that half a PQ step falls back to the v2
shape rather than emitting a malformed header.

A peer with no PQ material still runs v2 and emits no PQ fields (§13.4), and an
archive written by an older build simply has no PQ state and stays on v2.

Closes #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 11:17:04 -04:00
Paul HittandClaude Opus 4.8 fefd87e482 Sealed group membership changes, and secrets that survive a reinstall
This had been sitting uncommitted and unbuilt. It builds.

`Session.updateSealedGroupMembers` is owner-only and refuses locally rather than
sending a request the relay would reject: we hold the owner's signing key only
if we created the group. The version must increase, because a roster signature
stays valid for ever — a stale one could otherwise be replayed to re-add
somebody who was just removed, with a signature that genuinely verifies.
Existing members keep their tags; only newcomers get fresh ones, since reusing
a tag would hand somebody the token another member had held.

The vault carries the group secrets through export and import. Without them a
restored device loses its sealed groups for good: the relay cannot say what you
are a member of — that is the point — so the tag is the only way to fetch a
group and the key the only way to read it. A backup with the messages but not
these restores a device that can see old conversations and can never rejoin the
groups they came from. Import fills gaps only, the same rule the names use.

Verified: :engine:test passes, :app:assembleDebug succeeds.

Closes #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 11:07:03 -04:00
Paul HittandClaude Opus 4.8 d56ae82c59 Sealed groups on Android: create, fetch, invitations
Mirrors the iOS side. Create mints a gid, group key, owner signing key and
one random tag per member, seals the roster and hands the relay a blob —
no member id leaves the device. Fetch asks by TAG, never "what groups am I
in", because a sealed relay cannot answer that.

Reachability moved here from the relay, which needed to know who the
members were in order to check it.

⚠️ An invitation is NOT subject to the roster check the group name goes
through, and cannot be: that check asks the relay who is in the group, and
for a group we have not joined yet we cannot fetch it to ask. What guards
it instead is that an invitation is accepted only for a group we hold no
secrets for — a stranger cannot overwrite a group we are really in, which
would make every later roster fail to open and silently empty the
conversation.

The base64 stays inside the engine: `Bytes` is internal there on purpose,
so `SealedRoster` grew `newGroupId`, `newOwnerKey`, `ownerPublic` and
`signRoster` rather than the app reaching past that boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:56:02 -04:00
Paul HittandClaude Opus 4.8 58fb20246e Sealed roster primitives (§8.9b)
The group key, the member tag, and the blob. Mirrors the Swift engine —
same JSON shape, same canonical signing string, same nonce||ct||tag box
layout, so a roster sealed on either platform opens on the other. The
Swift suite pins that with a vector produced by this code.

Tags are random rather than derived from the account id: anything derived
is a pseudonym the relay can link across groups, which is the social graph
again wearing a hash.

The encoding writes its keys in a fixed order — `JSONObject` promises none,
and a roster whose bytes depend on map iteration order is a roster whose
signature depends on it too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:05:22 -04:00
Paul HittandClaude Opus 4.8 41533baae6 Group names survive a reinstall, and reach our own other devices
Two gaps the group-name change left behind.

The encrypted history export carried the messages but not the names. That
was harmless while the relay answered with them — a reinstalled device got
them back for free. It holds none now, so this device's copy is the only
copy, and restoring a backup would have produced a chat list of groups all
called "Group": exactly the loss the export exists to prevent. Names ride
the payload, and only fill gaps on import — a backup is older than the
device by definition, so a rename made since must not be undone.

And fan-out excludes our own account, so a second phone we own was told by
nobody: the relay has no name to serve it and the members only send to each
other. The announcement now addresses our own account too. The fan-out loop
already skipped our own device, so what goes out reaches our OTHER devices
and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 21:23:52 -04:00
Paul HittandClaude Opus 4.8 4a6be0ba6b Group names live on the devices, not the relay
The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder. The real name is held in the
sealed vault and travels between members: on every group envelope, and as
an announcement riding the `delivery_key` control kind at creation and on
adding a member.

`delivery_key` rather than a new kind because a new one would arrive on
every phone running v5 as a bubble full of raw JSON.

An announced name is recorded as a CLAIM, not adopted. Conversations does
not know the rosters, and a name taken on the sender's word would let
anyone who can reach you write whatever they liked into your chat list
under a group heading you have no reason to doubt. Session checks it
against the relay's roster and only then stores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 20:24:03 -04:00
Paul HittandClaude Opus 4.8 79c2734f86 Issue, spend and revoke delivery capabilities on Android (§21.4)
The last client. A recipient mints a batch of random tokens per contact,
uploads only their hashes, and hands the tokens over inside the ratchet;
a sender spends one per message; revoking one contact is dropping one
batch, and nobody else notices.

The batch rides the existing `delivery_key` envelope rather than a new
control kind, because a receiver recognises control traffic by KIND
first and falls back to the `ctl` flag only for kinds it has never heard
of — and this engine reads that flag with `optBoolean`, which does not
coerce the number §6.10 calls for. A `delivery_tokens` kind would have
arrived on the phones already out there as a bubble full of raw JSON.

Replenishment is counted rather than requested, for the same reason: a
"running low" message would have been another new kind. The issuer can
count on its own, because every message it receives from a contact spent
one of their capabilities, and it mints afresh at half a batch.

A refused capability means the batch was revoked, so the rest is
discarded and the same envelopes are re-sent on the shared key — they
are addressed to the device, not to the authorisation. That is not a way
around revocation, and the code says so: revoking moves a sender back
onto §14.4's key, it does not silence them.

One bug the tests found. `consumeControl` read the peer's delivery key
first and bailed with `loadDelivery() ?: return`, so a device that had
not published a key of ITS own yet — every device before
`ensureDeliveryKey` runs — silently dropped everything in the envelope,
capabilities included. Capabilities have their own store and no such
dependency, so they are handled before that early return.

Verified against a locally-run relay carrying the §21 work as well as by
the unit suite: 126 capability hashes on the relay after the live run,
so the path is exercised rather than mocked past.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:25:22 -04:00
Paul HittandClaude Opus 4.8 356c89e1cd Test the mailbox state whose keys a stranger chooses
Two of these keys are not ours. An envelope id comes from the sender, so
an unbounded list of them is storage anybody on the federation can grow;
a relay name comes from a signed record, so a per-relay entry can be
created by anyone who can get one published. Both are bounded or scoped
deliberately, which is worth asserting rather than trusting to a
comment — and the bound is now proven at 2,500 ids.

Also pinned: each relay's cursor is its own (ids come from different
databases, and one shared cursor would let the first mailbox's progress
skip the second's mail), the served flag and the head pin are per relay,
an existing global pin is still inherited so a phone already holding one
keeps it, and dropping a mailbox takes its cursor with it — otherwise a
re-added mailbox resumes mid-inbox and skips everything before.

One of these tests asserted that two objects were not the same object,
which is true of any two objects. It now writes with one vault and reads
with a second over the same storage, which is what a restart is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:54:28 -04:00
Paul HittandClaude Opus 4.8 ca49eedade Test address parsing, mirroring the Swift suite case for case
An address is parsed from input nobody authenticated: typed, pasted,
scanned from a code somebody else printed, and — worst — read out of a
sealed envelope written by a stranger on another relay. So the refusals
matter more than the successes.

Written to mirror the Swift cases one for one, because two
implementations that disagree about which strings are addresses open two
threads for one person on one side and none on the other, and nothing
about either engine's own tests would show it.

It found one on the first run, in the other direction: Swift's decoder
splits on the first `@` with `maxSplits: 1` and then refuses `/` and `:`
but not `@` — so `fn1abc@a@b.example.com` parsed as a relay of
`a@b.example.com`, a "hostname" no host is called. Fixed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:51:12 -04:00
Paul HittandClaude Opus 4.8 088fd5f5a7 Key-log state is per relay, and two bugs the new tests found
Writing tests for the multi-mailbox and key-log work turned up three
things, two of them in code committed earlier today.

**The "this relay serves a log" flag was global.** So was the head pin.
Signing in to a second relay that keeps no log would have been read as
the FIRST one going quiet — a fork warning against an honest deployment,
and every contact on it unreachable. The pin was worse: a second relay's
head checked against the first relay's root cannot reconcile, so the
same outcome arrived by a different route. Both are now keyed by relay
host. Pin reads fall back to the old global entry, so a phone already
holding one keeps it rather than starting trust-on-first-use again.

**A dead home relay hid every other mailbox.** `poll` read the
signed-in relay first and let its failure propagate, which is precisely
the situation several mailboxes exist to survive, defeated by an
unguarded call. But swallowing it outright broke a property that was
already tested and matters: a failed poll must not be reported as an
empty inbox, because "no new messages" and "could not reach the server"
are different things the app says different words for. Both hold now —
every mailbox is read, and the failure is raised only if NOTHING could
be read. A home that is down while a backup answers is a partial success
whose mail is returned rather than thrown away with the error.

**A relay is named in the record and dialled by URL, and those are two
questions.** The hostname is inside the signature so it can carry no
scheme and no port; how this device reaches that host is separate and
now stored beside it. That is the production shape as well as what lets
these paths be tested at all.

New: 12 tests for the address and home record (including that a relay
serving a shortened or lengthened list is caught by arithmetic), 7 for
multi-mailbox reading, 10 for sending to an address, 10 for adding and
dropping a mailbox, 5 for the control flag. A fixture that let a mock
relay forget a record it had stored is fixed too — it made every publish
after it reuse serial 1, which a real relay refuses as a rollback.

Verified against a live relay: 243 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:29:53 -04:00
Paul HittandClaude Opus 4.8 db5aa33280 Write the control flag as the number the spec says
`Content.disappearing` emitted `"ctl":true` where §6.10 specifies
`"ctl":1`. That was not a cosmetic disagreement between engines.

iOS decodes the field as a number through a synthesised `Codable`, so a
boolean did not merely fail to set the flag — it threw out of the
initialiser and failed the WHOLE envelope's decode, dropping the message
onto the plain-text path. An Android user turning on disappearing
messages sent an iPhone a bubble containing raw JSON, and the timer was
never applied at that end.

Nothing caught it because each engine parsed what it had written itself.
The piece-level tests passed on both sides, the vectors do not cover
this envelope, and the two halves only meet on two real phones.

Both spellings are accepted on the way in. `org.json`'s `optBoolean`
does not coerce a number, so reading only that missed every `"ctl":1`
the other two engines send — and reading only the number would miss the
`true` this engine wrote until today, which is on phones now and cannot
be recalled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 16:23:03 -04:00
Paul HittandClaude Opus 4.8 72314491fa Mailboxes at more than one relay
Your address already survives a relay disappearing — it comes from your
passphrase, so signing in elsewhere produces the same one. Your mailbox
does not. Mail sitting at a relay that goes away is gone with it, and a
contact holding only that relay's name has nowhere to write.

The relay has fanned out to a signed home record for a while and no
client could publish one, so the feature existed on exactly one side of
the wire. This is the other side, and both halves of it, because
publishing alone would not have worked: a copy sitting in a mailbox
nothing reads is storage, not redundancy. So the poll now reads every
home, with a cursor per relay — envelope ids come from each relay's own
database, and one shared cursor would let the first home's progress
silently skip the second home's mail.

Copies are collapsed by the id the sender stamps once per message.
Without that the second copy reaches a ratchet that has already deleted
its message key, so the symptom would not be a duplicate — it would be a
dropped envelope and a message that looks lost.

Adding a mailbox is several requests because homing somewhere means
having an account there: sign in, register this device so there is
somewhere to deliver, claim the same address so a sender can resolve it,
then publish the record at EVERY home rather than only the new one. A
home that does not know it is one still accepts mail, but a sender
asking it for the record gets a stale list and skips the others — so the
redundancy would quietly depend on which relay the sender happened to
ask. The new relay accepting is what decides the whole operation:
a record every old home took but the new one refused describes a mailbox
that does not exist.

Removal publishes the shorter list first and forgets the credential only
if that worked. The reverse order leaves mail arriving at a mailbox this
device no longer reads, which from the sender's side is indistinguishable
from delivery. The relay being dropped is not asked to accept the new
record — it is the one party with a reason to refuse.

No one-time prekeys are published at a second home. They are only usable
if this device kept the private halves, and publishing keys it would not
offer back makes every session opened with one undecryptable, which is
worse than offering none.

The passphrase is asked for on the screen that needs it and cleared the
moment the attempt ends. The account key seed lives in memory for the
run and is never written down: it signs the mailbox record, which is the
one thing a relay must never be able to do on the account's behalf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 16:06:11 -04:00