**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.
🔴 **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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
`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>
`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>
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>
🔴 `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>
The same platform difference as the vault index, in shipped code, with a
worse consequence.
🔴 `saveTrust` wrote `"fnid": null` explicitly when a contact's global
address was not yet known. Android's bundled `org.json` reads a JSON null
back as the four-character string `"null"` — a non-null value that is not
an address. What that caused on a phone:
- `Trust.pin` compares a newly offered fnid against the pinned one and
treats a difference as a different person. `"null" != "fnid:…"`, so the
FIRST TIME a real address arrived the contact was marked **CHANGED** —
the "security keys changed" warning, which is supposed to mean an
attack, firing on an ordinary first sighting. The vault's own comment
about minting a fresh device warns about exactly this shape of false
alarm.
- `rememberIdentity` returns early once `fnid != null`, so the real
address was never resolved and the record stayed wrong permanently.
- `pinnedFnid` fed `"null"` into key-log verification, which recomputes a
leaf against the address a contact was accepted under — every check
against the wrong subject.
Two halves, because records written the old way are already on phones:
the writer now omits the key entirely (an absent key reads as `""` on both
platforms, which is what the reader always expected), and the reader
rejects the literal `"null"` so existing records repair themselves.
⚠️ The test that matters is the one feeding the reader the literal string.
On the JVM a real JSON null reads back as `""` whether the key is omitted
or not, so the plain round-trip passes with the bug intact — which is why
this survived every suite. Confirmed by restoring the old reader and
watching it go red.
Found by the sweep flagged in android!54. `JSONObject.NULL` is written in
exactly two places in this app, and both were wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **Android's bundled `org.json` coerces a JSON `null` to the
four-character string `"null"`. The Maven `org.json` the tests run against
returns `""`.** Every nullable field in the vault index therefore passed
on a laptop and broke on a device:
- `parentID` threw `Invalid UUID string: null`, so a vault holding any
top-level file failed to load AT ALL. Adding the first file worked,
because it was still in memory; reopening the app lost everything, with
an error nobody could act on.
- `wrappedContentKey` was worse, because it did NOT throw. `"null"` is
valid base64, so the node came back with a plausible three-byte content
key and simply refused to decrypt, for ever, with nothing to explain it.
- `bookmark` produced a reference pointing at `"null"`, permanently
missing.
Absence is now decided by `JSONObject.isNull` and never by comparing a
decoded string to a sentinel. The literal `"null"` is rejected as well: it
is not a valid value for any of these fields, and tolerating it costs
nothing next to reintroducing this.
⚠️ **The two regression tests write the literal string `"null"` into the
index on purpose.** That is exactly what the decoder is handed on a
device, so it is what makes a JVM run reproduce the device's behaviour.
Feeding a real JSON `null` — which is what the vault actually writes —
passes with the broken code and proves nothing. Both were confirmed by
restoring the old decoding and watching them go red.
Found by running the app (android!52). No JVM test could have caught it,
which is an argument for the device run rather than against the tests.
⚠️ `optString` on a nullable field is wrong everywhere in this app, not
only here, and it fails only on the platform it ships to. Worth a sweep.
Verified on the emulator afterwards: the vault survives a cold start, and
on disk the blob begins `ENCL\x01`, both wrapped keys carry version 0x11,
the name 0x01, the filename is not in the clear, and the plaintext is
absent from the whole app container.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both directions: pick a document from the vault inside a conversation, and
save a received one into the vault at the classification it arrived with.
🔴 **This is the answer to android#15, and the codebase had already given
it.** `Attachment.FILE` has carried this since before the lattice existed:
*the label is a MARKING, not a permission — the content key is in the same
envelope, so whoever can read the message can read the file.* That settles
the ★-property question. The key travels with the file by design, because
that is what makes sending work at all; there is no trusted computing base
to enforce against, since the person holds every key on a device they
control. A client-side refusal would be a suggestion, and **a suggestion
dressed as a control is worse than none** — it is the one people plan
around.
So neither client implements the ★-property, `LabelTest` keeps asserting
that absence, and PARITY.md now says plainly that Files is not mandatory
access control. What it IS: cryptographic read separation, plus a
classification that travels. The picker says so before the tap, in those
words.
Three things found on the way:
🔴 **A second copy of the vocabulary.** `classificationName` in
ChatScreens listed the same four names as `FileLevels`, agreeing by
coincidence with nothing making them agree. Rename a level for the Files
screen and the chat bubble keeps showing the old word for the same number,
in the same conversation, on the same phone. Collapsed to one.
🔴 **Attach and Camera did the same thing.** Both launched the gallery, and
there was no way to send a document at all. Attach now opens a small panel
— photo, or from Files — which keeps the kit's composer row at its four
controls.
🔴 **iOS's picker lists the root only**, so a file inside a folder cannot
be sent from a conversation, and nothing says so — it is simply absent,
which reads as "it isn't there". `LocalVault.allFiles()` walks the whole
tree. Filed as ios#68.
⚠️ The picker is the ONE place this vault hides a row it cannot read.
Everywhere else it insists on showing them, because hiding one leaves
somebody unable to see anything is there; a picker exists only for
choosing, and a row that can never be chosen is furniture.
⚠️ A received file is always saved as an encrypted copy, never a link —
there is no file on this phone to point at — and always at the label it
arrived with. Re-labelling somebody else's document on receipt would
launder its classification, and the number is the only thing that survived
the trip.
9 new tests. Each guard proved by breaking the code: writing the label for
every attachment kind fails the image test; dropping compartments fails
the two that name them; removing the fallback caption fails the
older-build test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen, the Storage Access Framework resolver behind
`LocalVault.ReferenceResolver`, and the passphrase-protected identity the
vault opens under.
🔴 **Android asks for a Files passphrase, as iOS does, and that was a
decision rather than a port.** The Android wallet sits behind the
Keystore-sealed vault alone, so an unlocked phone opens it. Files does
not: the identity record is encrypted under a key stretched from a
passphrase this app deliberately keeps no copy of, with the Keystore seal
around it as the second layer. Dropping the inner one for convenience
would be a real reduction and not a refactor — a vault that opens because
the phone is open is a folder with a lock drawn on it.
🔴 **`OpenDocument`, never `GetContent`.** `GetContent` hands back a URI
whose read grant dies with the process, so a linked file works for the
rest of the session and is broken on the next launch — and whoever added
it watched it work. `filesReference` REFUSES when the grant cannot be
persisted and offers to copy the file in instead, rather than filing a row
that will be dead tomorrow.
🔴 **Locking drops the identity; it does not hide a screen.** The open
vault is a field, deliberately not part of `FilesState` — a state object
gets logged, compared and copied into recompositions, none of which should
ever have a private key in it.
The classification ordinals are pinned by a change-detector test, on
purpose: the NUMBER travels with a file and the word is only how it is
drawn, so renumbering them would file a Restricted document as Internal on
the other phone with nothing erroring and neither screen looking wrong.
⚠️ **Not yet run on a device.** The machine was carrying an iOS simulator
at 99% CPU on 8 cores, and booting an emulator on top of that produces a
flaky run rather than evidence. Two things need a real device before this
is done: the SAF picker with its persistable grant, and whether a linked
file still reads after a reboot. Neither can be faked on the JVM.
⚠️ Files is its own destination, not a section of the wallet. They share an
unlock, which is true and is an implementation detail; iOS grouped them
once and what it produced was somebody pressing "wallet" and arriving at a
file list.
Full build and all JVM suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The index, folders, both ways of holding a file, and the delete
semantics — everything iOS's `LocalVault` does, minus the screen.
🔴 One deliberate divergence, and it is a fix rather than a port.
`descendants(of:)` on iOS bounds a cyclic index with `guardRail < 10_000`.
That terminates and then returns ten thousand copies of the same two ids
— and the screen calling it is the one offering "delete this folder and
everything in it", so a corrupt index turns that into an offer to delete
10,000 things. Bounding by what has already been visited terminates AND
returns the right answer, with no magic number. Filed as ios#66.
The test that found it writes a real cycle into a real index. It matters
because the index is a file on disk: the input is not only ever our own
writes.
⚠️ The index file is device-local and is NOT an interchange format. The
node FIELDS match iOS's exactly so a courier transfer stays a copy rather
than a translation; the JSON around them does not. iOS encodes dates as
Swift's reference-date doubles, which is an accident of `JSONEncoder`
rather than a decision, and pinning Kotlin to it would promote an accident
to a format. Anybody building vault migration should move nodes through a
declared wire shape, not by copying this file.
🔴 `ReferenceResolver` is required with no default. A no-op default would
make every reference row read as "missing" in an app that simply forgot to
wire it up — a vault that looks like it has lost half its contents, with
nothing anywhere saying why.
Two tests worth naming:
- **Nothing on disk contains the plaintext.** A round trip passes just as
happily if `add` wrote the file out unencrypted and read it straight
back, so the bytes are searched for across the whole container, and the
index is checked for the filename.
- **Deleting a reference row does not reach the person's storage at all.**
Asserting only that their file survived would be a test that cannot
fail: the delete path holds no call to remove it, and the protection is
structural — the resolver has no way to delete anything. Asserting zero
resolver calls is the property that constrains future code, and it was
verified by routing the delete path through the resolver and watching it
go red.
29 tests. Each guard proved by breaking the code: the label key salted
with the level alone, a node key wrapped to its parent's label, and a bulk
delete that ignores unselected survivors each fail exactly the test that
names them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Names, key wrapping, chunked content, the KDF and the identity record —
everything needed to open a Files vault written by any other FlameNet
client.
🔴 Held to `vectors/files-v1.json`, produced by the JAVASCRIPT engine in
the browser. Not to the Swift engine. Checking Kotlin against Swift would
establish only that the two agree, and two implementations agreeing on
the wrong answer is how somebody's files stop opening when they change
phones. The file is copied byte for byte from the iOS repository rather
than regenerated, because a vector file regenerated by the implementation
under test proves nothing.
Two vectors were recomputed from scratch in Python before any Kotlin ran
against them — `struct.pack('>hQ')` for the label encoding and
`hmac.new(...).digest()` for the name digests. The `café` case matches
under NFC and NOT under NFD, which is what pins the normalisation form
rather than assuming it: a platform normalising differently does not
error, it silently fails to find a file filed on another device.
Each guard was proven by breaking the code and watching the right test go
red: normalisation removed → the composed/decomposed digest test; the
wrap salt halves swapped → the browser's label-key grant; the end-flag or
the chunk index dropped from the authenticated data → the browser's file
stops decrypting at all.
⚠️ The truncation and reordering tests each go on to show the surviving
bytes are still authentic under their ORIGINAL index and end-flag. Without
that second half "it threw" is all they would establish, and any bug at
all makes a mangled blob throw.
Two notes on the platform:
- PBKDF2 is hand-written on `Mac`, as `Bip39.pbkdf2HmacSha512` already is.
The JCA path takes a `CharArray` and lets the provider decide the
encoding; the reference implementations take bytes. A passphrase outside
ASCII would otherwise stretch differently under a different provider and
the record simply would not open, with nothing to say why.
- The public half of an identity is recovered by agreeing against the
X25519 base point, because the JCA will not derive one from an XDH
private key. That it returns 32 bytes proves nothing, so it is pinned to
the vector file's own `identityPublicKey`.
43 tests. `FileContent` rather than `Content`: the messenger's `Content` is
a message payload and the two are not interchangeable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>