Commit Graph
498 Commits
Author SHA1 Message Date
Paul Hitt d8d48d8651 "Checking what this server uses…" was also the failure state
🔴 **The relay probe had no deadline and no way to say it failed.**
`discovered` is null both while the probe is in flight and after it has
come back with nothing, so `identityDescription` shows "Checking what
this server uses…" in both cases. A relay that never answers leaves the
sign-up form on that sentence permanently — no error, no retry, nothing
to act on.

Found by the instrumented suite on a clean emulator: nine of ten tests
failed with `nav-contacts did not appear within 120000ms`, and the screen
dump showed the form still checking.

⚠️ **The reason it never answered is worth writing down.** The app was
pointed at `http://10.0.2.2:18810`, and Android has blocked cleartext
HTTP by default since API 28 — there is no `usesCleartextTraffic` and no
network security config here. The request never left the device.

🔴 That refusal is CORRECT and is not what changed. `SiteConfig` already
says why: a bare hostname becoming `http://` would hand key material to
anyone on the path "and it would work, which is the worst outcome." What
was wrong is that it failed silently, so somebody self-hosting who omits
the scheme gets a dead screen and no idea why.

Now: bounded at fifteen seconds — far longer than a healthy relay needs —
and a failed probe is a distinct state that says
"Couldn't reach that server. Check the address — and note it has to be
https, not http."

Same defect as android#19 one screen earlier, which I fixed this morning
without noticing the probe had it too.

Also adds `scripts/instrumented.sh`, which closes two ways an on-device
run silently points at production: the suites DEFAULT to live hosts when
no relay is named, and a saved emulator snapshot restores whatever the
AVD was last doing — observed today as a snapshot signed in as
`fnreviewbeta` against relay.flamenet.io, under a run told to use a local
relay. The script has no default, refuses both live hosts by name, cold
boots with `-no-snapshot-load`, and exits 3 on a skip because a skip is
not a pass.
2026-09-05 15:41:49 -04:00
Paul Hitt d2d5a0b496 Merge branch 'feat/report-a-message' into 'main'
Report a message, not just a story

See merge request paulhitt/flamenet-messenger-android!74
2026-09-05 18:35:11 +00:00
Paul Hitt 02edf3bc45 Merge branch 'ship/android-0-10' into 'main'
Android 0.10 — published

See merge request paulhitt/flamenet-messenger-android!73
2026-09-05 18:35:05 +00:00
Paul Hitt 9bba9d2555 Report a message, not just a story (android#20)
**The relay has always accepted `kind: "message"`. Nothing ever sent one.**
Reporting existed on stories only — so the surface where unsolicited
contact, spam and harassment actually arrive had no way to tell anybody.
The person being messaged by a stranger had Block, which protects them
and tells whoever runs the server nothing, so the account moves on to the
next person.

🔴 **Android had `reportStory` wired all the way to the relay and no
screen anywhere calling it.** The plumbing was complete and unreachable.
This is the first report UI on this platform, and the sheet is not
story-specific, so stories can use it too.

🔴 **The consent is the feature; the form is scaffolding.** Everything
else this app sends is sealed and a report is not: the relay cannot open
a message, so a report is actionable only if the reporter hands over what
they saw. §6.25 requires the client to say so, and it says so in these
words — what leaves the device, who can read it, and that it cannot be
taken back. Turning the copy off states what that costs rather than
quietly weakening the report.

⚠️ Block is offered beside it and never merged into it. A block takes
effect immediately and is the reporter's own decision; a report is a
request to somebody who may do nothing, and the sheet says which is
which.

⚠️ One message travels, never the thread around it — §6.25 forbids
including anything the reporter did not choose to.

⚠️ Report is absent, not disabled, on your own messages, and on the
stranger banner it sits below both buttons rather than beside them. Most
first messages are somebody you gave your address to; leading with Report
would frame every one of them as a threat.

⚠️ A relay may decline reports entirely (`reports_not_accepted`, 501).
That is a property of the server, not a mistake the reporter made, and
the message does not read like one.
2026-09-05 14:28:38 -04:00
Paul Hitt 1594ce84f6 Android 0.10 — published
sha256 39ccfbde…, signed CN=Flamenet Messenger with 791fc826… — the same
certificate as every build since 0.3, so 0.9 installs are offered this
rather than refused.

⚠️ APK into downloads/ BEFORE the manifest, and the served copy hashed
over HTTPS rather than trusted as copied. A manifest that lands first
announces a version the file is not, and every client checking in the gap
downloads the old one and rejects it against the new hash.

Second release through the notes gate, and it did its job unprompted:
`release-manifest.sh` read 0.10 back out of the APK and confirmed
`Resources/releases.json` names it before writing a byte.
2026-09-05 14:12:56 -04:00
Paul Hitt 1baa661b63 Merge branch 'fix/silent-signin-and-log-leaf' into 'main'
Sign-in that cannot finish now says so, and a missing log leaf heals

See merge request paulhitt/flamenet-messenger-android!72
2026-09-05 18:10:47 +00:00
Paul Hitt d8df4a8dbf Sign-in that cannot finish now says so, and a missing log leaf heals
**android#19 — sign-in sat for four minutes and said nothing.** The
failure path was always correct: anything `authenticate` throws is
reported. What it could not report was a call that never RETURNS —
observed 2026-09-05 with the requests demonstrably going out and the
screen unchanged. Nothing was thrown, so nothing was shown.

⚠️ Sixty seconds, not ten. Sign-in derives a key from the passphrase and
then talks to two services; an emulator or an older handset is
legitimately slow at the first part. This is a backstop for "never", not
a latency budget.

**android#6 — a failed key-log append was permanent.** `signIn` calls
`registerDevice`; `restore` did not. So a device registers exactly once,
and if the transparency-log append did not land — a slow write, a relay
mid-restart — it stays registered with no leaf, and every peer that
enforces the log refuses it forever with an error that reads as an
accusation of tampering. Relaunching did not help, which is the cruel
part: the one thing anybody would try was the one thing that could not
work. Restore now checks for a leaf and republishes if there is none.

⚠️ Checks first. Registering unconditionally would publish twenty fresh
prekeys every launch and churn the log for nothing — iOS's
`registerDeviceIfNeeded` tops up only when low for the same reason. Silent
on failure, because this repairs a state almost nobody is in.

**android#17 — a photo sent as a file keeps its GPS.** Same as ios#69 and
the same answer: the picker strips through ImagePrep and MediaScrub,
`Session.sendFile` passes the vault's bytes straight through, and the
send screen now says which path you are on rather than leaving the safer
one looking like a shortcut.
2026-09-05 14:08:27 -04:00
Paul Hitt ba47041d03 Merge branch 'ship/android-0-9' into 'main'
Android 0.9 — published

See merge request paulhitt/flamenet-messenger-android!71
2026-09-05 16:05:36 +00:00
Paul Hitt 581967289a Android 0.9 — published
🔴 **0.9, not 0.10.** The tree said 0.10 because "Put the app back to the
last state that worked" bumped past 9 without publishing anything, so 9
belonged to nothing and the live manifest still said 8. Going back to 9
keeps the published line contiguous; the only rule that matters here is
that `versionCode` never goes backwards from what is INSTALLED, and
nothing carrying 10 was ever served.

Published: sha256 b86219dc…, signed CN=Flamenet Messenger with
791fc826…, the same certificate as every build since 0.3 — an update
installs over 0.8 rather than being refused.

⚠️ The APK went into downloads/ BEFORE the manifest, and the served copy
was hashed over HTTPS rather than trusted as copied. A manifest that
lands first announces a version the file is not, and every client that
checks in the gap downloads 0.8 and rejects it against 0.9's hash.

This is the first release through the notes gate that landed one commit
earlier: `release-manifest.sh` refused to emit anything until
`Resources/releases.json` in the relay named Android 0.9.
2026-09-05 12:02:34 -04:00
Paul Hitt 155168a244 Merge branch 'ship/release-notes-gate' into 'main'
An APK no longer ships without a word about what changed in it

See merge request paulhitt/flamenet-messenger-android!70
2026-09-05 15:56:54 +00:00
Paul Hitt ad7c63ff4f An APK no longer ships without a word about what changed in it
Publishing was three steps done by hand, and only two of them left a trace.
The manifest and the APK could go out with nothing anywhere saying what was in
the build — which is how 0.8 shipped with notes and 0.9 shipped with none.

The gate goes on the manifest rather than on a checklist, because the manifest
is the step that cannot be skipped: it is what the in-app updater reads, so an
APK published without it offers nobody an update. A checklist item is skipped
on exactly the busy evening the notes matter most.

🔴 It matches on the `versionName` read out of the APK, never a number typed
here — the same reason this script already reads the version rather than
accepting one. The two cannot disagree.

The check itself lives in the relay repository (Scripts/covers-android.py),
because that is what serves the /releases page and defines the format. Parsing
the JSON here would leave a second copy of the schema to drift out of step
with the first, and a drifted gate is a gate that passes everything.

⚠️ FN_SKIP_RELEASE_NOTES=1 still publishes, and says so loudly on stderr — a
skipped gate that printed nothing would be a gate nobody remembered turning
off. A missing relay checkout is a refusal, not a pass, and names FN_RELAY_DIR.

Proven on a real APK in all four states: covered (manifest written, valid
JSON), uncovered (refused, and stdout stays EMPTY so no half-written manifest
lands), skipped, and no checkout. Every message goes to stderr, because this
script's stdout is the manifest itself.
2026-09-05 11:38:31 -04:00
Paul Hitt 76c548c727 Merge branch 'fix/files-uses-your-flamenet-passphrase' into 'main'
Files asks for your Flamenet passphrase, not a second secret

See merge request paulhitt/flamenet-messenger-android!69
2026-09-05 14:44:03 +00:00
Paul Hitt 94a28ea467 Merge branch 'fix/calls-say-when-the-key-changed' into 'main'
Say when a call was refused because the key changed (android#20)

See merge request paulhitt/flamenet-messenger-android!68
2026-09-05 14:43:07 +00:00
Paul HittandClaude Opus 5 571c18571f Files asks for your Flamenet passphrase, not a second secret
Reported: "on android, the files passcode should just reuse the account like
ios does."

🔴 **The difference was entirely in the words, and iOS has been right all
along.** iOS says "your passphrase" and takes whatever is typed, so a person
naturally uses their Flamenet one and it simply works. Android said the files
were "encrypted with a passphrase of their own", labelled the field "Choose a
passphrase", and later asked for "your Files passphrase" — three separate
places telling somebody to invent, and then remember, a second secret.

⚠️ **Forgetting it is a one-way door.** The label keys are derived from the
identity that passphrase protects and exist nowhere else, so a forgotten
Files passphrase makes every vaulted file permanently unreadable. Sending
people off to mint an extra secret for no reason put them one lapse of memory
from that, and the app already has a paragraph explaining the consequence.

⚠️ **Nothing in the derivation changed, deliberately.** It is still a
free-choice string protecting the identity record, so anyone who already set
a different one keeps opening their files with it exactly as before. Making
the account passphrase the ACTUAL key would be a migration with permanent
data loss on the far side of any mistake, and it is not what was asked for.

That also means the copy IS the feature here, so a copy guard is the only
guard there can be. Proven by reverting the wording: both tests go red naming
the file and the phrase.

1194 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 10:42:56 -04:00
Paul HittandClaude Opus 5 97961974dd Say when a call was refused because the key changed (android#20)
Reported from a live session: "the calls wouldn't connect because the keys
were changed. it wasn't obvious why the call failed." Confirmed by the
reporter — accepting the new key made everything work.

🔴 **It was not obvious because nothing said it.** The call's signalling
lambda was `runCatching { session?.sendCallSignal(...) }` with no
`onFailure`, so `IdentityChangedException` vanished. That exception is the
send path REFUSING to encrypt to an unreviewed identity — TOFU working
exactly as designed — and swallowing it meant the offer never left while the
screen sat in DIALLING until it timed out.

⚠️ **`describe()` had no case for it either**, so anywhere it WAS caught the
person got "Something went wrong reaching that server." The server was fine.
That sentence sent somebody hunting a network problem that did not exist,
which is the specific thing the neighbouring branches in that function exist
to prevent — they all say what to DO.

⚠️ **And the fix is on a screen the caller is not looking at.** Accepting a
changed key is a banner in the CHAT; somebody calling from the Calls tab
never sees it. So the message names the conversation, not just the problem.

This is a parity gap, not a new idea. iOS has named this on the call path
since it was built — "Their security code changed", with the review opened —
and its comment describes this exact bug: "sent someone hunting network
problems when the actual cause was their contact reinstalling".

`describeFailure` moves to file scope so the mapping can be tested without an
`Application`. It never touched instance state, so that is a move rather than
a rewrite — the alternative was a Robolectric run to assert a string.

Proven by removing the branch: all three tests go red reporting the old
sentence verbatim, "Something went wrong reaching that server."

1196 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 09:50:45 -04:00
Paul Hitt de968ac1aa Merge branch 'test/label-lattice-interop' into 'main'
Expose the security lattice to the interop driver (relay#49)

See merge request paulhitt/flamenet-messenger-android!67
2026-09-05 10:40:23 +00:00
Paul HittandClaude Opus 5 2764145063 Expose the security lattice to the interop driver (relay#49)
relay#49 requires that labels and security levels be kept as Messenger and
Files converge. Both engines have implemented §1's lattice independently
since the Files apps shipped, each pins its own vectors, and nothing has ever
compared the two.

🔴 **The ten-byte encoding is authenticated data for every construction that
uses a label**, so a platform disagreement does not warn. It produces a
decryption failure, or — worse — plaintext that quietly belongs to the wrong
sensitivity. Each side passing its own vectors is necessary and not
sufficient; two engines can pass their own and still disagree.

`label_encode` reports the mask as a STRING. Bit 63 makes it a negative Long,
and a JSON number would arrive on the other side as a double and lose the low
bits — turning the one compartment most likely to be mis-shifted into the one
this check cannot see.

The matching harness stage is flamenet-e2e!28. Proven by breaking it rather
than by watching it pass: dropping `compartments.containsAll(...)` from
`dominates` — the classic "compares the level and forgets the areas" bug —
turned exactly four checks red, all of them compartment cases, each reporting
iOS and Android disagreeing. That bug behaves correctly on every case built
from levels alone, which is why it needs two engines to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 06:39:17 -04:00
Paul Hitt 7d343c5222 Merge branch 'test/kotlin-content-interop' into 'main'
Kotlin content interop commands (e2e#4)

See merge request paulhitt/flamenet-messenger-android!66
2026-09-05 10:08:12 +00:00
Paul HittandClaude Opus 5 8f474ce0b9 Give the Kotlin engine content commands, so the layer can be checked (e2e#4)
The interop driver dispatched 19 commands — device, initiate, encrypt, seal,
log_leaf — and not one touched a content envelope. Swift has had
`content_encode`/`content_parse` for a while; Android has never been checked
against any other implementation at this layer.

🔴 **This is the half that most needed it.** SPEC.md §6.10 says so in as many
words, after the `ctl:true` bug: "no vector covers this envelope. It is the
standing argument for the interop harness." That bug was Android writing
`"ctl":true` where iOS decoded a number — and the cost was not a missed flag
but a failed decode that dropped the whole message onto the plaintext path,
while each engine's own tests passed on both sides.

⚠️ The two parsers fail DIFFERENTLY, which is why a round-trip on either
proves so little. This engine reads field by field through `org.json`, whose
bundled Android build coerces a JSON null to the string "null" where Maven's
returns "". iOS decodes all-or-nothing through `Codable`, so one bad field
downgrades the entire envelope to a raw-JSON bubble. The same additive change
degrades differently on each platform.

`content_parse` also reports `isControl`, so the harness can check §6.10's
rule across engines instead of each engine checking itself.

The matching harness stage is flamenet-e2e!27. Proven by breaking it: making
this engine read the SPEC's `ment` instead of the shipped `men` turned
"iPhone mentions parse on Android" red — the exact drift that has been
invisible since the key was chosen.

1190 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 06:07:09 -04:00
Paul Hitt 6262ac46f9 Merge branch 'fix/unsend-tells-the-truth' into 'main'
"Unsend" did not unsend (android#18)

See merge request paulhitt/flamenet-messenger-android!65
2026-09-05 02:18:59 +00:00
Paul HittandClaude Opus 5 8c2d7f1f65 "Unsend" did not unsend. Say what the button does (android#18)
The message menu labelled the delete `if (line.mine) "Unsend" else "Delete
for me"`, and both branches call `deleteMessage` → `Session.deleteMessage` →
`vault.deleteLine`. That is the whole implementation. No envelope leaves the
phone; the recipient's copy stays exactly where it is.

🔴 **Worse than an overclaiming page**, which is why this got a test and not
just an edit. A page that overclaims can be read sceptically. A button that
reports success cannot: the feedback IS the claim, and it arrives at the
moment the person is most relieved to believe it.

"Delete for me" was already the other half of that ternary, and it was always
the accurate half.

⚠️ Two tests, not one, because the first is worthless without the second: a
source scan that walks the wrong directory finds no files and passes every
assertion in it. `the scan actually reaches the chat screen` pins that the
walk reaches the file the bug was on. The walk climbs to the module root
rather than resolving a relative path, for the same reason.

Proved by breaking it, not by watching it pass:

  label reverted → FAILED, naming ChatScreens.kt line ~1564
  restored       → 2 tests, 0 failures

1190 tests pass across the suite.

⚠️ This is ios#57 on the other platform, fixed there in the same pass. A real
retract is a new `kind` on rails that already exist — §6.15's edit machinery
is implemented and tested on both clients. But even then it is a request, not
a guarantee: a client can ignore it and the message may already have been
read. Whatever the button eventually says must not promise more than "we
asked them to remove it."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:06:57 -04:00
Paul Hitt 99a33627a4 Merge branch 'test/group-pin-resolves' into 'main'
Prove a group pin resolves (relay#46)

See merge request paulhitt/flamenet-messenger-android!64
2026-09-05 00:42:43 +00:00
Paul HittandClaude Opus 5 56ec122405 Prove a group pin resolves, so a spec claim can be settled (relay#46)
The published spec says a group pin "is carried correctly on the wire but
cannot resolve yet, because a group transcript does not record" a message
id. `Session.sendGroup` mints a mid and passes it to `record(...)`, so the
stated reason is already false — but relay#46's rule is that a claim is
flipped by a run, not by reading the code, and nothing exercised the group
case specifically.

Five tests, on a group thread rather than a 1:1 one, because that is what
the spec singles out: the transcript carries an id, the pin resolves to the
message it names, and a group pin does not leak into a DM.

⚠️ One of them is about the design rather than the plumbing. The pin stores
an ID and never a copy, so the banner follows an edit instead of quoting
words that are no longer in the conversation — a stored copy would go stale
the moment the message changed and would outlive it being deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:41:40 -04:00
Paul Hitt 17dbb186a8 Merge branch 'docs/parity-pq-is-shipped' into 'main'
PARITY: post-quantum is shipped on both clients

See merge request paulhitt/flamenet-messenger-android!63
2026-09-05 00:30:05 +00:00
Paul HittandClaude Opus 5 28169a648f PARITY: post-quantum is shipped on both clients, not missing on either
Both rows said ⚪/⚠️ and the note under them said "neither client does
post-quantum anything today". That was wrong in the understating
direction — the same direction relay#45 warns about, and the same one that
had the comparison page scoring Signal unfairly in our favour.

- **Android** uses BouncyCastle's ML-KEM (FIPS 203), shipped in the engine.
  The old note's "no ML-KEM at any API level" was about the PLATFORM, which
  is true and is why the engine carries its own — not a reason it has none.
- **iOS** uses CryptoKit `MLKEM768`, unconditional since `project.yml` moved
  the deployment target to 26.0. The runtime gate that made those files
  inert is gone, and `PQKEM.swift` says so at the top.

⚠️ Corrected by a run rather than by reading the source, which is the rule
this file keeps to. `test/interop-swift-kotlin.mjs` establishes it end to
end in both directions: "Android picks v3 for an iPhone peer", "the first
Android envelope carries a PQ ratchet step", "the iPhone agrees the session
is v3", "a v3 session survives being archived and restored
mid-conversation".

⚠️ The honest limit is unchanged and kept: it counts only once BOTH ends
have it, so today that means two Android phones — or an Android phone and
an iPhone running a build that has not shipped.

Found while correcting relay#11, where I went in expecting to find our own
cells overstated and found the opposite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:29:01 -04:00
Paul Hitt e5217f79ce Merge branch 'fix/gradlew-test-on-a-clean-tree' into 'main'
Fix: ./gradlew test now passes on a clean tree (android#5)

See merge request paulhitt/flamenet-messenger-android!62
2026-09-04 23:18:12 +00:00
Paul HittandClaude Opus 5 c01d1ba32b Fix: ./gradlew test now passes on a clean tree (android#5)
Confirmed exactly as filed: 15 red `ScreenshotTest`s, all in the RELEASE
variant, all "Unable to resolve activity for Intent". The diagnosis in the
issue was right — `androidx.compose.ui:ui-test-manifest` is a
`debugImplementation`, and it is what declares the `ComponentActivity`
those tests launch, so the release variant's merged manifest has no such
activity.

⚠️ **The tempting fix is to add that dependency to release as well. Do
not.** It exists to inject a test-only activity into the manifest, so
putting it on `releaseImplementation` would ship that into the APK. The
tests belong to the variant that can run them.

⚠️ Excluded by name rather than by disabling `testReleaseUnitTest`
wholesale — everything else still runs in both variants, so a genuine
release-variant difference would still be caught.

⚠️ And the first green was not evidence: `clean` followed by `test`
reported BUILD SUCCESSFUL in 3 seconds, which is far too fast to have run
anything. Re-run with `--rerun-tasks` and counted: debug 129, release 114,
JVM modules 933, 0 failures. The 15-test gap between the variants is
exactly the excluded screenshot tests, which is the shape that proves the
exclusion did what it says rather than taking the suite with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:17:38 -04:00
Paul Hitt 8c9fd8f09c Merge branch 'test/inbound-edit-is-applied' into 'main'
Prove an inbound edit is applied (relay#46)

See merge request paulhitt/flamenet-messenger-android!61
2026-09-04 22:36:27 +00:00
Paul HittandClaude Opus 5 327da06ed3 Prove an inbound edit is applied, so a spec claim can be corrected (relay#46)
The published spec says the Android client *"cannot yet apply one, because
its transcript does not record a `mid` for each line"*, and that the same
gap blocks replies and reactions.

The transcript DOES record a mid, and `MessageReactionTest` already folds
a reaction onto a message by id — so the stated reason is false and the
consequence for §6.9 is false with it.

⚠️ But relay#46's rule is the point of this commit: **do not flip a claim
because the code looks present; flip it because a run proved it, and say
which run.** `applyEdit` was implemented and wired with nothing exercising
it — `InboundControlTest` covers the codec and says in its own header that
the app-side application needs a Vault and is out of scope there. This is
that run, written so the correction has something behind it.

Five tests, and the two that matter most are refusals: an edit from
somebody who did not write the message, and an edit of our own message
claiming another author. Without the author check a contact could rewrite
another person's words in a transcript, which reads to the reader as the
sender having said something they never said. Both proved by removing that
check and watching exactly those two go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:36:21 -04:00
Paul Hitt 93dd278434 Merge branch 'fix/purge-orphaned-attachments' into 'main'
Expired messages no longer leave their plaintext on disk (android#11)

See merge request paulhitt/flamenet-messenger-android!60
2026-09-04 22:29:55 +00:00
Paul HittandClaude Opus 5 46fb23dbe1 Expired messages no longer leave their plaintext on disk (android#11)
`attachmentBytes` wrote DECRYPTED bytes to `cacheDir/att-<id>` and nothing
ever removed them. So a disappearing message expired, the bubble went, and
the photo or voice note stayed on disk indefinitely — the exact failure the
feature exists to prevent. Spec §6.11: attachments MUST be purged with the
message that referenced them.

🔴 **A sweep, not a purge at each deletion point, and that changed what the
fix had to be.** FOUR paths drop a row — message expiry, story expiry,
delete-for-me and clear-thread — so threading "here is what I removed" out
of each would still miss anything a future path drops, and would never
reach the orphans already on disk. Asking instead "what is still
referenced?" is complete by construction and self-healing.

🔴 **Refs are found by SHAPE.** An attachment reference serialises with
`id`, `key` and `digest` together and nothing else in the vault has all
three, so `attachmentIdsInUse` walks every record and matches on that. A
hand-written list of stores is wrong the day somebody adds a store and
does not update it — and the sweep would then delete attachments still in
use, which is far worse than the leak.

⚠️ **That is not hypothetical: enumerating by hand gets it wrong today.**
The obvious sources are history and stories. The vault ALSO holds refs in
the story archive, in per-contact avatars and in this account's own
avatar. A sweep built on the obvious two would have deleted every avatar
on the device the first time it ran, and blanked the user's own story
archive — which is the one thing that archive exists to prevent.

Three more safety properties, each with a test that fails without it:
fails closed when the in-use set cannot be built (a partial answer is what
turns a cleanup into data loss); a grace period so a sweep racing a fetch
cannot delete bytes on their way to the screen; and a throttle inside the
function, since it reads every vault record and is called from the poll
loop.

⚠️ The cost is that a file survives to the next sweep rather than going at
the instant of expiry — named in the code rather than left implied.

17 tests. Every guard proved by breaking it. ⚠️ One did NOT hold at first:
removing the `/` -> `_` filename mangling broke nothing, because every id
in the suite was slash-free — the guard was untested by the tests meant to
be guarding it. Added the case; it now fails as it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:29:30 -04:00
Paul Hitt 3caaa2172d Merge branch 'docs/live-run-2026-09-04' into 'main'
PARITY: the file round trip is proven live

See merge request paulhitt/flamenet-messenger-android!59
2026-09-04 20:55:47 +00:00
Paul HittandClaude Opus 5 0e70c78e98 PARITY: the file round trip is proven live, not merely written
`LiveFileTransferTest` was committed unrun, with the caveat that an unrun
test proves nothing. It has now run against relay1 on the same image
production runs: a Restricted document left one vault, crossed a real
relay, and arrived in another's with its classification and both
compartments intact.

Ran alongside it, all green: 6 LiveConversationTest, 7 LiveKeyLogTest, 2
LiveSealedSenderTest, LiveSignUpTest, LiveRelayTest and the self-hosted
LiveShapesTest paths — 31 live tests in total.

⚠️ The managed-account paths stay SKIPPED. They need FN_LIVE_MANAGED, which
signs up against accounts.flamenet.io — production, and `LiveTarget`
refuses it by design. Those accounts are real and persist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:55:33 -04:00
Paul Hitt e3d0c0c121 Merge branch 'test/live-file-transfer' into 'main'
A live test for the file round trip, and four published passwords

See merge request paulhitt/flamenet-messenger-android!58
2026-09-04 20:50:18 +00:00
Paul Hitt c47533929e Merge branch 'fix/node-equality-conflated-updates' into 'main'
Fix: promoting a link to the vault appeared to do nothing

See merge request paulhitt/flamenet-messenger-android!56
2026-09-04 20:50:11 +00:00
Paul Hitt 730a40141d Merge branch 'feat/files-screen' into 'main'
Files on Android: set up, unlock, browse, add, folders, delete, promote

See merge request paulhitt/flamenet-messenger-android!52
2026-09-04 20:49:58 +00:00
Paul Hitt 3615f314b9 Merge branch 'feat/files-local-vault' into 'main'
The local Files vault in Kotlin

See merge request paulhitt/flamenet-messenger-android!51
2026-09-04 20:49:55 +00:00
Paul Hitt 54f9a1e0fa Merge branch 'feat/files-engine' into 'main'
The Files vault crypto in Kotlin, pinned to the browser's vectors

See merge request paulhitt/flamenet-messenger-android!50
2026-09-04 20:49:52 +00:00
Paul Hitt 9c107b7c8d Merge branch 'feat/files-label-lattice' into 'main'
The Files security lattice in Kotlin (labels, clearance)

See merge request paulhitt/flamenet-messenger-android!49
2026-09-04 20:49:49 +00:00
Paul Hitt 7bd8784d98 Merge branch 'feat/wallet-chain-client' into 'main'
Read a balance and a transaction history from a chain node (Android)

See merge request paulhitt/flamenet-messenger-android!47
2026-09-04 20:49:42 +00:00
Paul Hitt 958689b3eb Merge branch 'feat/wallet-store' into 'main'
Create, import and store a wallet (Android engine)

See merge request paulhitt/flamenet-messenger-android!46
2026-09-04 20:49:39 +00:00
Paul Hitt 9d2dca803b Merge branch 'feat/wallet-keys-and-address' into 'main'
Derive wallet addresses and sign with them (Android)

See merge request paulhitt/flamenet-messenger-android!45
2026-09-04 20:49:36 +00:00
Paul Hitt 8ea8341f03 Merge branch 'feat/wallet-engine-primitives' into 'main'
Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010

See merge request paulhitt/flamenet-messenger-android!44
2026-09-04 20:49:32 +00:00
Paul Hitt 8466fc1b57 Merge branch 'feat/apply-inbound-edits-and-pins' into 'main'
Apply inbound edits and pins instead of filing them as messages

See merge request paulhitt/flamenet-messenger-android!41
2026-09-04 20:49:22 +00:00
Paul Hitt 5afa147712 Merge branch 'feat/handle-a-wrong-clock' into 'main'
Handle a wrong clock (ios#45, Android half)

See merge request paulhitt/flamenet-messenger-android!38
2026-09-04 20:49:12 +00:00
Paul Hitt e6e7d1cc73 Merge branch 'feat/app-lock-backoff' into 'main'
Back off on repeated app-lock attempts (ios#41, Android half)

See merge request paulhitt/flamenet-messenger-android!37
2026-09-04 20:49:09 +00:00
Paul Hitt 10dcb53fa6 Merge branch 'fix/tests-must-not-publish-credentials' into 'main'
A test run must not publish a credential (ios#62, Android half)

See merge request paulhitt/flamenet-messenger-android!36
2026-09-04 20:49:04 +00:00
Paul HittandClaude Opus 5 ead31053a4 A live test for the file round trip, and four published passwords
`LiveFileTransferTest` covers the one seam nothing else does: a document
leaving one vault, crossing a real relay, and arriving in another's with
its label intact. `FileShareWireTest` proves the envelope's shape and
`LocalVaultTest` proves the vault at both ends; neither puts a byte on a
wire.

⚠️ **Written, compiles, UNRUN** — the same state `LiveSignUpFleetTest` is
in, and the same caveat applies: an unrun test proves nothing. It creates
accounts, so it needs a throwaway relay and an explicit decision to point
it at one.

🔴 **Writing it surfaced four published passwords and a guard that could
not see them.**

`TestSafetyTest` exists to stop a credential being written into a suite
that creates real accounts. It matched `val X = "…"` only. So:

- `LiveConversationTest` passed the literal straight as an ARGUMENT,
  twice — invisible to the check.
- `LiveSignUpTest`, `LiveKeyLogTest` and `LiveSealedSenderTest` wrote
  `password = "…"` on its own line inside a multi-line argument list, the
  most ordinary Kotlin formatting there is — also invisible.

I copied the same literal into the new suite before noticing, which is
how I found it.

**A guard that passes while the thing it guards against is present in the
tree is worse than no guard, because it is read as evidence.** All five
call sites now read `LiveTarget.passphrase()` from the environment with no
fallback, and announce a SKIP when it is unset.

The check itself took two wrong turns worth recording:

- Skipping every named argument, to avoid flagging
  `product = "messenger"` — which let the three `password = "…"` suites
  through. Trading one blind spot for another is not a fix; a named
  argument is now judged by its NAME.
- Widening the sign-up match to a few lines around the call — which
  flagged forty test tags, assertion messages and message bodies as
  credentials. That is how a check stops being read, so it is same-line
  only, and rule 1 covers the multi-line case properly.

"secret" was dropped from the credential names for the same reason: test
prose is full of it, and a marker string called `secret` is a fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:28:11 -04:00
Paul HittandClaude Opus 5 e6913ae60e PARITY: history at rest was already built — the third stale row
Marked ⚪ and implemented the whole time. Every read goes to disk:
`Session.history` calls `Vault.history`, which decrypts the record. There
is no in-memory copy to be fooled by.

Checked against iOS's `E2EHistoryStore` rather than assumed. Same cipher,
same posture, two honest differences recorded rather than papered over:

- 🟢 Android binds the RECORD NAME into the AAD, so a sealed blob cannot
  be moved between slots. iOS keeps one snapshot file and does not need
  that — but would, the day it splits.
- iOS's `.completeFileProtection` is a per-file assertion. Android's
  equivalent is Credential-Encrypted storage, a property of the volume
  rather than of this app's request.

Neither favours one platform overall, and both clients agree on the
behaviour that matters: a plain sign-out KEEPS history, and erasing the
device destroys it — on Android by wiping every record and deleting the
Keystore key, so what is left is unopenable as well as gone.

⚠️ Also recorded: `Vault.history` prunes expired messages on the way out,
so disappearing messages are enforced on READ rather than by a timer
nothing runs — and it rewrites the record only when something actually
expired, so a read is not a write.

Services (AIM/ICQ) retired rather than left as a gap: not a goal for this
app (owner, 2026-09-04).

That leaves only the two post-quantum rows, and the note above them
already says why they are not a gap in effect: iOS's PQ files are inert
because CryptoKit exposes ML-KEM on iOS 26 and the app targets 16, so
neither client does post-quantum anything today. Closing it properly is
one decision for both clients, not an Android task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:22:01 -04:00
Paul HittandClaude Opus 5 0a4709dd0e Fix: promoting a link to the vault appeared to do nothing
🔴 `Node.equals` compared only the id. It was written that way to dodge a
real trap — a data class compares `ByteArray` by identity, so the
generated equals calls two identical rows different — and dodging it broke
a whole feature.

Promoting a link keeps the node's id and changes its `storage`. So the new
row compared EQUAL to the one it replaced, the state object holding it
compared equal too, `StateFlow` conflated the update away, and Compose
never saw it. On screen the file vanished from Linked and did not appear
in the vault until the app was restarted. That reads as losing somebody's
file.

Every field is compared now, with `contentEquals` for the four byte
arrays — the identity trap handled rather than avoided.

⚠️ **This suite could not have caught it, and the new tests say why.**
Everything here reads the vault, and the vault was right the whole time:
the index on disk had both rows, and a fresh load rendered both. The
failure was in what the vault handed a state holder that conflates on
equality. So the tests assert that property directly — a promoted row must
not equal its old self, an unchanged row read twice must, and every
mutable field must be visible in equality.

Found by watching it on an emulator. Confirmed by restoring the old
equals and watching both go red, then re-verified on device: the promoted
row now appears immediately with no restart, and its size corrects from
the provider's reported 4 kB to the real 14 kB plaintext length — which is
itself proof the row genuinely updated.

Also verified on device while here: a persistable read grant survives a
reboot, a full cold boot and an app update (`persistable=0x1
persisted=0x1`), promoting drops the bookmark from the index, and the
person's own file is left untouched where they put it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:19:18 -04:00