App Store Connect refused the 1284x2778 set on 2026-10-20 and took
1320x2868 in the 6.9 inch slot. The header now names the iPad device
type for 2048x2732 too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Xcode reformats both catalogs on every build. Beyond the formatting:
"Send" gains a second translator comment, "Search contacts" and
"Sam Rivera" are marked stale, and InfoPlist gains one extracted key.
No translation changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test launches default to http://127.0.0.1:9 so no suite reaches
production by accident. The shot test set -FNMSGServer only for a
throwaway sign-up, so signing in to a named account hit the dead
loopback and failed with "Could not connect to the server".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three @Published properties carried internal(set) on an internal
property, which changes nothing. forgetOnThisPhone defaulted its store
to .shared, evaluated outside the main actor; it now takes an optional
and resolves .shared inside the body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App Review rejected 0.32.0 under 3.1.5: crypto wallets are allowed only
from developers enrolled as an organization, and this account is an
individual one. WalletAvailability.hidden removes the Wallet tab, the
Home header button and the Privacy setting. The store shot test no
longer captures a wallet screen. Android and F-Droid keep the wallet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 2026-10-16 run gave the first real diagnostic in six attempts.
ci-test.log reached 889,595 bytes and ended at
PruneExplicitPrecompiledModules, which is the end of a clean build, and
then xcodebuild sat for 150 minutes without printing "Testing started".
The watchdog killed it and the gate printed that tail. So on pauls-imac
the build succeeds and the test phase never begins; the same commit
builds and runs 1289 XCTest plus 578 Swift Testing tests in about 7
minutes on an M3.
One xcodebuild call cannot tell a slow compile from a test phase that
never starts. build-for-testing and test-without-building each run under
their own cap, and the log says which one stopped.
Running test-without-building against a warm build exposed a second race
in BridgeFailoverTests, of the same kind as the first: onWorked and
onAllFailed append to a plain `var` from the transport's queue while the
test reads it, which races the callback and shares a variable across two
threads. Three assertions read a locked box on a 5-second deadline now.
Verified: build-for-testing reports ** TEST BUILD SUCCEEDED **,
test-without-building runs all 1289, and BridgeFailoverTests passed 11 of
11 three times in a row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
unit-tests and ui-tests have failed on all five CI runs since the move to
Gitea Actions, every one of them with "context deadline exceeded" and no
test summary. Both causes are here.
The hang. One test fails, and on a failure xcodebuild's default
-collect-test-diagnostics on-failure runs `simctl diagnose`: a sysdiagnose
plus 8 minutes of simulator logs, 600 seconds of timeout, before it
reports anything. Reproduced on an M3 by watching the pid: the suite
finished 1289 tests in 286 seconds, then xcodebuild sat on `simctl
diagnose` for over 10 minutes with the log frozen at the last line. On the
runner that is what burned the 180-minute deadline. Set to `never`.
The silence. The in-job cap was `command -v gtimeout || command -v
timeout`, and the runner that printed "no timeout command, xcodebuild runs
uncapped" is the one that then hit the job deadline, where a cancelled
step runs no gate and prints no tail. The cap is a shell watchdog now,
holding xcodebuild's own pid because the log is written by a redirect
rather than a pipe, and `tail -f` keeps the progress lines in the job log.
The failing test. BridgeFailoverTests.testTheStatusNamesTheLineThatCarried
Traffic read Obfs4Proxy.shared.state once, immediately after reading the
HTTP body. onWorked fires on the transport's queue when the tunnel ends,
not when the body arrives, so the read raced it and saw waiting(lines: 2).
The client socket is closed first and the state is read on a 5-second
deadline. The same race was in the noneReachable assertion.
Verified on this machine, Xcode 27.0 (27A266a): BridgeFailoverTests 11 of
11, then the whole suite, 1289 XCTest and 578 Swift Testing tests, zero
failures, ** TEST SUCCEEDED **.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two runs were cancelled at their timeout with no error, and raising the
number was treating a hang as slowness. The build was finishing both
times.
The evidence is in the artifact. ci-test.log from the 90-minute run was
889,539 bytes and from the 180-minute run 889,544, five bytes apart. A
slow build produces more output, not the same amount. Both end at
`PruneExplicitPrecompiledModules`, which is the last line of a
successful build, immediately before xcodebuild hands off to the
simulator.
So the suite compiles and then xcodebuild waits for `iPhone 17` to come
up. The job shut that device down before building and left xcodebuild to
boot it, and nothing reported when it did not.
`xcrun simctl bootstatus "iPhone 17" -b` boots the device and blocks
until it is usable. It exits 0 on success and non-zero when the device
does not exist, checked both ways on an M-series Mac: 0 after a
shutdown, 148 for a name that is not there. On failure the job now
prints the available device list and stops, so a runner whose simulator
will not boot says so in seconds.
A cap also goes INSIDE each job, 150 minutes for the unit suite and 100
for the UI suite, under job timeouts of 180 and 120. A hang now reaches
the gate below and prints the tail of the log instead of being cancelled
with nothing. `coreutils` is not guaranteed on a workstation runner, so
the cap resolves gtimeout, then timeout, and otherwise runs uncapped
with a note rather than failing.
Both jobs get both changes. Every `run:` block still passes `bash -n`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit job can spend 90 minutes before it prints anything, and when it
times out the only way to try again was to push another commit. Two of
today's three runs were exactly that.
workflow_dispatch costs nothing and makes a re-run a button.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 was not enough. The run on 50ce3c86 spent all 90 minutes inside
xcodebuild and printed no `Executed` line at all, so the cold compile
alone outlasts the limit on pauls-imac. The artifact it uploaded held
889 KB of build output and no test result.
The same commit builds and runs the whole suite in about 37 minutes on
an M-series Mac:
Executed 1289 tests, with 0 failures
** TEST SUCCEEDED **
So the suite is green and the limit was measuring an Intel compile. 180
leaves room for a cold build; the workspace on that runner now holds a
partial ci-build, so a warm run should come in far under it.
If 180 is not enough either, the answer is the runner rather than the
number: move these two jobs to an Apple Silicon runner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first run of this workflow died at exactly 30 minutes with "context
deadline exceeded", fifteen seconds past the vector gate and still inside
xcodebuild. 30 was chosen when the suite was 741 tests; the 0.32 merge makes
it 1289, and a cold `ci-build` compiles the app, its tests and the obfs4
package before any of them run.
pauls-imac has capacity 1, so ui-tests waits for unit-tests and then builds
from its own derived data; 60 covers that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it.
Every gate is kept: secret sweep, unit tests with the vector pin check and
the 700-test floor, UI tests with the 10-ran floor, vectors-head (advisory),
parity scrub.
Xcode jobs run on the pauls-imac runner; the sweep, vectors-head and the
scrub run on mac1/mac2. Builds and tests only, no signing.
The Obfs4 submodule and the sibling clones now come from
git.extant2000.com/extant2000, as do the README links.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Scripts/check-spanish-catalog.py` reported 1466 keys in code against 1391 in
the catalog. The 79 without Spanish were all from the release branch: greeting
cards, events, games, the Today tab, the small-web directory, story
take-downs, and the two scanner refusal lines added by this merge. It now
reports 0 missing.
Terms follow docs/i18n/glossary-es.md and, where Android already shipped the
same label, Android's wording: Today is "Hoy", Card "Tarjeta", Event "Evento",
Game "Juego", story "historia", take down "retirar", directory "directorio",
bridge line "línea del puente". Every entry is `needs_review`, like the rest
of the es column: nothing ships until the owner or a Localization Lab
volunteer has read it.
The old parental-controls footer is deleted. The merge replaced it with the
longer text that also covers Today and the directory, so its key was dead.
"Attach photo" and "Search contacts" are still reported unused, as on main:
both reach the catalog through a `String` parameter the compiler does not
record, and "Search contacts" has three live call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32 conflict hunks across 21 files. The two sides are the 0.32 feature branch
(cards, events, games, Today, the small-web directory, hello links, story
take-downs, stickers) and main (the Spanish catalog, attachment padding, the
stage 2-B request gate, bridge offers). Neither side is the answer anywhere
the conflict is about what the app does.
What the resolutions keep, and why:
Info.plist and project.yml: BOTH URL types. One claims `flamenet` for contact
links, the other `flamenetmessenger` for hello links. Taking either side alone
would have stopped the other kind of link opening the app at all.
CardScanner: git had already merged the controller to the union, with both the
injected `accepts` predicate and the new `onRefused` callback. The call sites
now match it. `accepts` is the only gate, because hard-coding `ScannedCode`
there would have refused every code the Devices screen scans. The address
sheet's default is `ScannedCode.card`, never `ContactCard.decode`, so an older
card's `?u=` username is still never trusted (DESIGN 32).
E2EInbox: `seedAccountStateFromDeviceIfNeeded()` moved to the factory rather
than into the release branch's new `connect()`. It reads this device's own
store and never the relay, and the offline launch path passes `connect: false`,
which would otherwise show every existing conversation as a request until the
relay answered. `deliverRemote` keeps both gates: main's `remoteMailShows` and
the release branch's control-traffic cases.
E2EEnvelopeRoute: bridgeOffer, rsvp and unpost all three, in the enum, in
`all`, and in the shape test.
MainTabView and PillTabBar: the extracted `PillTabItem` and `tabItems()` are
kept, with the `identifier` field put back and every title through
`String(localized:)`. `title` is a plain String, so the release branch's bare
literals would have left every tab English in every language.
ParentalControlsView: the release branch's fuller footer, written as ONE
literal. `Text` localizes a literal and not a `+` chain, so the concatenated
form would have taken that footer out of the catalog.
Eleven more places take the union with the localized string kept: the
attachment and sticker previews in E2EInbox, GroupsSection, BuddyListView,
ChatView, ChatViewModel and StoriesFeedView.
Also here: 35 spec references renumbered. `a3b05c3` in flamenet-e2e moved
anonymous reads and contact links to §8.13 and §8.14 to make room for the push
token and the Today file at §8.11 and §8.12, so every reference in this tree to
"8.11, anonymous reads" or "8.12, contact links" now pointed at the wrong
section. The Today and push references were already right and are untouched.
.e2e-ref is pinned to 4b4042f72880758ee2dea7713f7a2cb63fa12f73, the unified
spec, which is also what Android pins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WalletUITests failed 2 of 8 in CI (!240, pipeline 2933642198): after
"Send" opened its sheet, the app's main thread was busy for 30 s and every
UI query timed out. A sample showed WalletView.body running without end.
Cause: Obfs4Keychain.migrate ran on every read of the bridge settings and
always removed its old UserDefaults key, even when it was not there. That
posts UserDefaults.didChangeNotification. WalletView reads
AroundTheBridge.active in body and holds an @AppStorage value, so each
change re-rendered it, which read the settings, which posted again.
Fix: migrate only when the old key exists. A unit test counts
UserDefaults changes across 20 rounds of reads: 100 before, 0 after.
WalletUITests: 8 of 8 pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DRAFT, needs native review.
Rebased onto bridge/f2f-apps (4b92274a). NetworkSettingsView.swift and
Obfs4Settings.swift were taken from the bridge branch whole and wrapped
again, so every bridge behaviour stays as that branch wrote it.
- String(localized:) on the bridge list, failover, offer, sharing and
scanning text, the Obfs4 line problems and Obfs4Proxy check results, and
the four AroundTheBridge notices (link previews, calls, wallet, live
streams). Sentences joined with + are one literal; English is unchanged.
- 24 new catalog keys with Spanish, "%lld bridges saved, not used yet" with
plural forms. check-spanish-catalog.py: 1,390 keys in code, 0 missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpanishScreensUITests signs up two accounts against a relay on this machine
(it refuses any other), then relaunches the app with -AppleLanguages
(es-MX) and attaches 16 screenshots: sign-in, home, add contact, chat,
contact profile with the safety number, Settings, Privacy and the contact
links screen. Off unless TEST_RUNNER_FNMSG_ES_SHOTS=1.
The run found one cut-off label: "Aparecer desconectado" showed as
"Aparecer..." on the Account status buttons. It is "Invisible" now and fits.
DRAFT, needs native review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DRAFT, needs native review. Nothing in the es column ships until the owner
or a Localization Lab volunteer has read it.
- project.yml: developmentLanguage en, SWIFT_EMIT_LOC_STRINGS and
LOCALIZATION_PREFERS_STRING_CATALOGS on; xcodegen adds es to knownRegions
from the catalogs.
- Resources/Localizable.xcstrings: 1,366 keys from the compiler, 1,356 with
Spanish (the rest are punctuation and placeholders, marked not
translatable), 23 with plural variations. Every es value is
"needs_review".
- Resources/InfoPlist.xcstrings: the four permission prompts in Spanish.
- String(localized:) on 729 changed lines across 115 files, so status, error and
warning text held in a String reaches the catalog. Sentences built with +
are one literal now. English output is unchanged.
- Integer identifiers (user ids, HTTP and CallKit codes, signals) are
interpolated as String, because String(localized:) groups digits: a
CallKit code 9999 read "9,999".
- SpanishCatalogTests: every key has Spanish, identical placeholders, the
draft marker is present; short labels over 1.6x are listed, not failed.
- Scripts/check-spanish-catalog.py: every key the compiler extracts is in
the catalog (xcodebuild does not add new keys by itself).
- Source-reading tests updated to the String(localized:) form.
- docs/i18n/glossary-es.md: the terms, matched to the /es/ site pages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interop driver compiles E2EAttachment, LinkPreview and RelayDiscovery
alone, with no networking code. The bridge gate for link previews and the
bridged default for relay discovery move to app-only extensions, and the
offer size limit lives in E2EAttachment. The vector test reads all three
encodings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC §24.6: a client with bridges turned on must not connect directly. Four
features connect to a host other than the Flamenet edge a bridge forwards
to, so while a line is saved each is off and says why in plain words:
- Link previews: the fetch returns nothing and makes no connection; the
setting is greyed with a note.
- Calls: refused at start, at answer, and where the peer connection is built.
- The wallet: the chain client refuses before connecting; the screen says so.
- Live streams in stories: the player is replaced by a note naming the host.
The Files package's own sessions now go through the bridges
(FilesTransport, set at launch). Nothing in Sources or the Files client uses
URLSession.shared any more; a test reads the sources to keep it that way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A bridge screen on the sign-in and sign-up screens: paste or scan a line,
see the live status, remove it. Offers and sharing appear once signed in.
- Everything before sign-in goes through the bridges when one is set: relay
discovery, the sign-up proof of work, the account service and the REST
probe. Clients made before a bridge was set follow it (BridgedSession).
- Up to 8 lines, tried one at a time, the one that last worked first. A line
fails on no handshake within 15 s, a refused connection, or nothing coming
back through it; the unanswered request is sent again through the next
line. Every line failing never falls back to a direct connection, and a
local proxy that cannot start blocks traffic instead of sending it direct.
- The dialler takes IPv6 addresses and names, every address of a name.
- Offers: received offers route to their own branch and are kept for review
(proven senders only, never from this account or a federated address);
the Network screen shows them with accept and dismiss, and sends this
phone's line to one contact after saying what they learn.
- §24.9 gaps: certs decode with or without padding; offers with a gid or a
line over 1024 bytes are dropped and never built; an envelope whose known
key has the wrong type is consumed or shown as its caption or the
placeholder, never as raw JSON; the bytes test compares against both
writers in the vectors.
- Held to bridge-offer-v1.json; .e2e-ref moves to flamenet-e2e c75b861.
- Citations of §6.19 for the bridge offer now say §24.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e2e!66 merged; protocol-v1.json gains `safetyNumberPQ` and nothing else
changed. ProtocolVectorTests "testSafetyNumberCoversThePostQuantumIdentities":
red against the old copy (no vector), green with the new one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC 8.12: https://<relay>/add#... opened Safari. The entitlements now
claim applinks:relay.flamenet.io and applinks:relay1.flamenet.io; the
relay serves the matching apple-app-site-association (team M5923Y84P5,
/add*). A universal link arrives as a web-browsing activity and goes to the
same handler as the flamenet: scheme (AppEnvironment.open).
Signing needs the Associated Domains capability on the
net.flamenet.messenger App ID before an archive will sign.
AContactLinkOpensTheAppTests: 3/3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC §7 (flamenet-e2e 50fcc99, the owner's decision): the number covers
both post-quantum identities when a device holds both. A device pins a
contact's ik_pq only when it reads their bundle, which only the side that
opened the session does; the side that answered showed the Ed25519 number
while the other showed the covered one, and the digits never matched.
pinPeerPQIdentityIfNeeded reads the contact's bundles once, for devices
whose IK_sig is already pinned, and checks and pins ik_pq (§20.5). The
profile runs it when the safety number is shown and reads the number
again. It spends one prekey per contact device, once.
The test stub relay now carries ik_pq and spk_pq_sig in bundles, as the
relay does. FirstContactTests "testBothScreensShowTheSameCoveredNumber":
red (no pin, no coverage), then green; full suite 1074 XCTest and 408
swift-testing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Against a local relay, a one-time code used a second time was refused as
it should be, but the words read "Could not send. nora1009 (not verified
yet) needs to message you or send you a new link.": the refusal took the
conversation title, which carries the unverified mark. It now takes the
name without it (nameForRefusal), at all three call sites.
FirstContactTests "testTheRefusalNamesThePersonPlainly": red on the call
sites, then 15/15 green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the iPhone-to-Android pair against a local relay: a first message
sent through an Android account's one-time link reached that account's
Requests, but the iPhone opened nothing and Home said "No buddies yet".
The deep link opened a conversation only with somebody on the relay's
contact list, and the person behind a link is not on it. Android opens the
conversation either way.
BuddyListView.deepLinkTarget opens the buddy, or the person by the name
the inbox holds ("luke (not verified yet)" until their certificate).
AFirstMessageByLinkOpensItsConversationTests: 2 red, then 3/3 green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found linking an Android emulator to this iPhone's account by the numeric
fallback (17.3) against a local relay. The existing device sends its
snapshot right behind the offer; the new device, still waiting for the
person to compare the number, drops it as traffic from a device it has not
linked. Confirming then linked a device with none of the contacts,
acceptances or flags. Both apps had it.
confirmPendingNumericOffer now asks the offering device for its state
(sync_state with want), which both apps answer with a snapshot.
adoptLinkOffer says whether anything was linked. History sent behind a
numeric offer is still dropped; only the QR path backfills it.
LinkedDevicesTests "testTheNumericFallbackStillCarriesTheState": red
without the change (the contact list and acceptance did not cross), green
with it; 15/15.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests/Vectors already held the 0.32 vectors (padding, sync-merge,
handle-cert, links, poll-tally), byte-identical to flamenet-e2e 5a5b7e7,
the commit Android pins. The pin still named 3564030, which has six vector
files, and CI diffed only those six, so the five new copies were checked
against nothing. The pin now matches Android's and both loops (the pinned
gate and vectors-head) diff all eleven.
5a5b7e7 is on flamenet-e2e's privacy/sweep-2026-10, not yet pushed: the
clone in CI fails until it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-time code was a QR and a share button, and a reusable link was a
share button with six characters of its capability on it. Neither showed
the link itself, so it could not be read, checked or copied by hand. Each
now shows its https form, selectable, under the code. Android shows the
code and shares the same text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Section 8.12: a one-time link involves no introduction, so nothing vouches
for its `h` until the recipient's first reply carries their certificate.
The sheet said so, but the conversation it opened showed the bare name.
It now shows "bob (not verified yet)", the words Android's chat title uses,
until `adoptHandleCertificate` writes the certified name over it.
A link made on another relay was refused as "already used or turned off".
It now says "That link is for another server (<relay>).", as Android does.
TheListedSettingReachesTheRelayTests pins the Listed setting's wire: GET
and PUT/POST of `flamenet/v1/messenger/me` with `discoverable`, the field
RoutesAccount.updateMe reads, and nil from a relay that does not say.
Tests first: FirstContactTests two red, then green; 14/14, 36/36, 4/4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release branch had never been through a full test run. On Xcode 27:
- E2EInbox.transmit called `bundles(of:features:)` while a local named
`bundles` was in scope, which does not compile. Now `self.bundles`.
- FirstContactTests awaited inside XCTUnwrap's autoclosure and read a
main-actor property from a nonisolated test.
- Seven source-reading tests still looked for 0.31 shapes: adoptStrangers
(now nameStrangers, DC2), transmit's old one-line signature, two
functions that lost `private` for multi-device, and the Snapshot fields
that exist only for an export (16.2.1) or are carried as their projection.
- LinkedDevicesTests added a contact without accepting it, which no
production path does (ContactAdd accepts), so acceptance had nothing to
carry across.
- ABlockedSenderStaysOutTests (from main) now checks the block survives a
relaunch through the inbox, since the block is the `blocked/` register,
and purges a history file a previous run sealed under a wiped key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run through the avoid-ai-writing audit. Blocking now replaces the shared
delivery key and the app pushes the new one to every other contact
(section 14.4, DC6), so the Privacy footers that said a gap was left and
that contacts lose sealed delivery until messaged again were no longer
true, and neither was the "contacts can reach you sealed again once you
next message them" result. Three negation tails rewritten as plain
statements (web search, reusable links, the backup banner), and a curly
quote in code made straight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Web search screen (Account), shown only when the relay's descriptor says
`features.webSearch` (relay branch privacy/web-search, unmerged and
undeployed). POST /api/flamenet/v1/search with the member's credential,
the query in the body and never the URL, nothing logged; results open in
the browser, up to three pages; "search isn't working right now" (the
relay's `unavailable`) is told apart from "nothing found", and too many
searches is said as such. The copy says what the relay can and cannot see.
Tests: WebSearchTests (4). Written while builds were paused; not yet run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stages C2, C3, D2 and B3 (SPEC 14.5, 8.11, 8.12). Every part waits for
the relay's descriptor to say it can (`introductions`, `anonymousReads`,
`contactLinks`, `unlisted`); the relay code is on privacy/wave0-relay,
unmerged and undeployed, and does not advertise these switches yet, so on
every relay today the app behaves exactly as 0.31 did.
C2, handle certificates: a first message's delivery-key prelude carries
the sender's own certificate (`hc`, from GET /e2e/handle-cert); the
recipient keeps it only if it verifies against the pinned relay key, for
this relay's audience, about the sender the ratchet proved, before exp.
Requests and the call screen use the certified name, and with the switch
on the relay is no longer asked who user N is.
C3, sealed first contact: with no token, key or intro for somebody, the
app asks POST /e2e/intro once (by the reusable link the conversation
started from, or a listed handle after the proof of work), verifies the
answer is about that account, and sends sealed under the intro using the
bundles it carried. A refused key or token tries one fresh intro. With the
switch on nothing goes out named: no way to seal means the send is
refused with "Could not send. <name> needs to message you or send you a
new link." A batch of delivery tokens is now split into disjoint slices,
one per recipient device, so two devices never spend the same token.
D2, anonymous reads: directory, bundles and presence for other people go
to POST /e2e/anon/* with the target in the body, through a separate
ephemeral HTTP client (no credential, cookies, cache or shared pool, a
generic User-Agent), metered by the presence token the person issued;
presence in random batches of at most eight with up to 20 s before each;
own devices from GET /e2e/devices/me.
B3, Unlisted and contact links: "Finding you" (Privacy) has the Listed
switch (worded as the spec requires: nobody can find or add you by your
username, which is not secret), one-time QR codes (a delivery token, works
on today's relays) and up to five reusable links, each shareable and
revocable; sign-up offers the choice (Listed by default) and applies it
once the relay can. Contact links open from the flamenet: scheme (now
registered), the address scanners, and send one sealed first message to
the person's Requests, the name shown as unchecked until they reply.
Also here: MessengerAPI.webSearch, used by the next commit.
Tests: FirstContactTests (14) on the stub relay, which now answers the
wave 0b routes. Written while builds were paused; not yet run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage A2, linking (section 17.3). A device signed in with the password is
on the account and nothing more. A person links it from a device that
already is: the new device shows flamenet:link?d=&k=&s= as a QR code (ten
minutes, one use, never shared as text); the existing device checks the
relay lists that device on this account under exactly that key and that the
key log holds no revoke or expire for it, asks, links it locally, and sends
it a link_offer (secret, linked set, account delivery key) through an
ordinary session, then a snapshot of every register, then history if the
person wants it. The new device adopts only an offer whose secret matches
the code it is showing, from a sender in the set under the key its session
proved, and links each member only if the relay lists it under the key the
offer names. Numeric fallback: both screens show the two devices' safety
number and the new device holds the offer until the person confirms there
too (LinkOfferWatcher, from any tab). The linked set follows the relay:
a device retired in the key log, no longer listed, or listed under another
key is unlinked and the person is told which.
Stage A3, sync (sections 17.2, 17.6). Every register write goes to the
linked devices, batched (one op as `sync`, several as `sync_state`), sealed
to this account under the account delivery key. Incoming ops merge by the
vector-checked rule and are applied: acceptance, blocks, flags and read
marks, and the mirrored families (contacts, muted authors, group names and
keys, pins, story audience, contacts' delivery keys, issued token batches,
presence tokens issued and held, the account delivery key, the linked set,
each link checked against the relay first). State older than the registers
is written as registers once, so a snapshot carries all of it. A device
back after a week asks for snapshots; after 90 days it keeps only links.
Everything a person sends to someone (text, photo, file, sticker, reaction,
edit, pin, timer) and every group message once is copied to the linked
devices as `sent` and drawn there as outgoing, never notified; calls,
stories, avatars, polls and anything off the record are not (DA5). Only a
linked own device, under the key it was linked with, may send any of this;
a link_offer is the one kind taken from an unlinked one.
Stage A4, history (section 17.4): newest first, at most 10,000 messages,
16 KiB chunks, attachments by reference, off-the-record threads left out;
merged by message id in time order, a partial backfill tolerated.
UI: Devices has Link a new device (scanner), Show this device's link code,
Link (numeric) and Unlink per device, and the notices; the scanner takes a
filter so it can read link codes. Pending content envelopes from
content-v1.json are written to the byte (the sync_state chunk by content).
Tests: LinkedDevicesTests (15, two real devices of one account plus a
contact). Builds were paused by the owner while CI runs, so these were
written but not yet run; they run in the next build window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Section 8.9d and 14.5 (decision DC2). What the relay kept as pairs of
account ids now lives on the device as sync registers (section 17.6), so
linked devices can merge them later (stage A3):
- Acceptance (`acc/`): seeded once from the contact list and from every
thread this device wrote in, then once from the relay's buddy list;
writing to somebody, adding them, or accepting a request accepts them;
removing a contact takes it back.
- Requests: a first message from somebody not accepted waits in a Requests
folder (RequestsView: accept, block, delete) and never notifies. The app
no longer adds strangers to the contact list on its own; `adoptStrangers`
is now `nameStrangers`, which only puts a name to them, and the test that
pinned the old adoption is replaced.
- Blocks (`blocked/`): seeded once from the relay; a block is the relay
call (still enforced there until 2-E), the local register, the token
revoke and a delivery-key rotation, and anything from a blocked person
that still arrives is dropped on the device. The blocked list shows both.
- Star and archive (`conv/`): local only, flags written only when true, a
register deleted when both are off; the Messages list shows starred
first, keeps archived chats in an Archived folder, and /star and
/archive toggle locally instead of posting to the relay.
- Read marks (`read/`): coalesced to one op per thread per 30 seconds.
- Adding a contact goes through ContactAdd everywhere: by lookup onto the
sealed list when the relay no longer keeps one, never around it.
- A rotated delivery key is pushed at once, prelude only, to every contact
that held the old one and is not blocked (section 14.4, DC6).
- A contact's device with no session yet is handed the delivery-key
prelude with the presence token they already hold.
- Legacy groups this account owns are moved to sealed groups on refresh.
- Backups carry `acc`, `blocked`, `convFlags`, `dk`,
`deliveryTokensIssued` and `read` (section 16.2.1 pending); a restore is
a union and never unblocks anybody; `dk` is adopted only by a device
with no key of its own.
- A banner asks for a backup when nothing was exported for 30 days.
Multi-device envelopes get their own route (`.multiDevice`), handled by a
stub until stage A2.
Tests: PairsStageTwoBTests (13, real inboxes on the stub relay and the
pending backup vector), the route order and a branch test for every
multi-device kind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pieces sections 17.6, 17.3, 8.12 and 14.5 have to agree on with every
other engine, before anything uses them:
- SyncRegisters.swift: the register merge (later at wins, a block wins a
tie, then the greater device id as text by UTF-16 unit, read marks only
move forward, ops a day ahead dropped, malformed ops ignored whole, unknown
keys kept as registers), tombstones forgotten after 90 days, snapshot
registers merged one by one, and chunking at 16 KiB. JSONValue keeps a
register this build does not understand exactly as it came.
- LinkCodes.swift: the device-link QR (flamenet:link?d=&k=&s=), both forms
of a contact link (https://<relay>/add#... and flamenet:<fnid>@<relay>?...),
base64url, and the intro proof of work (smallest nonce, zero BITS).
- HandleCertificate.swift: fnhandle-v1 verification against the pinned
relay key, for this relay, for the account it is about, before exp, with
`|` and non-integer ids refused before any signature check.
MultiDeviceVectorTests checks sync-merge-v1 in every order of every case's
ops and snapshot registers, each order also delivered twice; handle-cert-v1
valid and invalid; links-v1 encode, decode and refuse for both kinds. The
three files are copied from flamenet-e2e 5a5b7e7 (privacy/sweep-2026-10),
with content-v1 and backup-payload-v1 refreshed from the same commit (only
their new pending sections changed); PROVENANCE.md says why they are not
in the CI diff list yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two bugs part 1 found.
Receiving, one key from one envelope was classified against everything
pinned for the sender. A contact with two pinned devices whose phone opened
a session read as "a device was removed" and was re-pinned down to the
phone, and the laptop's next first message then read as a new key beside a
missing one: a substitution, held behind "accept new keys". With nothing
pinned, the second device to write was held the same way.
`recordInboundTrust` now checks the one key: pinned is this person and
nothing is re-pinned; unpinned asks the directory (no prekey spent) and
classifies the whole listed set as a send does; unlisted, or a directory
that cannot be read, is refused. Android's Trust.recordInbound is the same
rule.
A call handoff from an own device was checked by device id alone, a name
the relay hands out. It is now checked against the identity key the
session that opened the envelope proved (the half of the X3DH associated
data that is not ours), via `isFromLinkedOwnDevice`, which the sync and
sent-copy kinds will use too.
Tests: ATwoDeviceContactIsNotAKeyChangeTests (4) and
AHandoffIsCheckedByKeyTests (3), with two real devices of one account in
one process: KeychainE2EStore and E2EHistoryStore take a `slot` (nil, and
unchanged, for every real install). The stub relay now hands back the
sending device on an unsealed row, as the real one does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SPEC 23.7 says a refusal is surfaced and never swallowed. The group
fan-out caught every error per member, so a group text over the top step
read as a send that reached nobody, and sendGroupText recorded it as
delivered. Now the fan-out checks the plaintext once against the largest
header an envelope can carry before any member is sent anything, so a
message is refused whole or sent whole, and a Padding failure from any
member is rethrown rather than caught.
The padded envelope and seal inner JSON are written by hand in a fixed
key order. A new test holds both to what JSONEncoder writes, key for key,
in every header shape, so a field added to the envelope later cannot be
dropped from every send without a red test.
Tests on My Mac (Designed for iPad): EverySendIsPaddedTests 6,
PaddingPropertyTests 8, ProtocolVectorTests 16, OwnDeviceTraffic 7,
TheDeliveryKeyIsRepublished 4, E2EInboxConversationTests 42, all green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Padding stage 1.2 in the inbox (SPEC 23.7, 23.9, 8.5).
Both wrap points pad now: the local `wrap` (unsealed envelopes with
`pad` beside `ct`, sealed ones through SealedSender.sealedPayload) and
the federated seal. That covers text, control, group fan-out, the
delivery-key prelude, call signalling, own-device copies and federated
sends, each envelope on its own. A payload that is not exactly a ladder
step throws and is never sent; nothing falls back to unpadded.
A message too long for the top step is refused before anything is
spent: for a body over 16 KiB each device's envelope is built once on
an unsaved copy of its session and measured in the sealed form, before
any token is taken, batch minted or ratchet step saved. The person sees
"Too long to send as a message. Send it as a file." Both composers show
how much is left in the last thousand characters, counted as the wire
counts them, and keep the send button disabled past the limit (20,000).
Attachments are sealed through sealAttachment, padded only when the
relay's descriptor says features.padAttachments; absent, false or an
unreadable descriptor means off. The descriptor's switches are cached
for an hour, and a failed read for a minute.
A poll the relay cut short at its byte cap ("more": true) is followed
at once from the new cursor, up to 16 pages, on the home relay and on
every extra mailbox.
Tests on My Mac (Designed for iPad): EverySendIsPaddedTests 5 (every
payload a ladder length, sealed and unsealed; the refusal spends no
token, batch or session step; the composer limit is sendable; the
attachment switch both ways, opened at the other end; a capped poll
finished in one call). Six sabotages, each red, each restored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two of the live bugs in the compete designs, findings 1 and 3.
Finding 1. transmit(_, to: myUserID) sent to every device on the
account, so a device signed in by anybody holding the password received
story copies, group keys and names, and the offer to take over a call.
The store now keeps a linked-device set (device id and the IK_sig it was
linked under), in the Keychain and wiped with the account. It starts
empty, and nothing in this release adds to it: linking is stage A2. Every
send to this account goes only to devices in it, matched on id AND key,
so a relay listing a linked id under another key is not believed. The
one exception is by design: a call-end saying answered-elsewhere or
declined-elsewhere still goes to every device, because the caller rang
them all. Receiving, a call handoff from an own device that is not linked
is dropped (SPEC 6.30).
Consequence until A2 ships: call handoff, story copies and group names or
keys no longer reach a second device of the same account. That is what
SPEC 17.3 asks for at A1.
Finding 3. When the relay's descriptor says features.perDeviceDigests,
the device publishes its delivery-key digest once more, once per relay
(a flag per relay host, set only when the relay accepts it), and sends
device_id with its roster tags. Without the republish, a device whose
digest was overwritten under the old one-per-account rule would never
publish again: the app remembered that it had.
Tests on My Mac (Designed for iPad), no simulator runtime on this Mac:
OwnDeviceTrafficGoesOnlyToLinkedDevicesTests 7, TheDeliveryKeyIs-
RepublishedOncePerRelayTests 4, E2EInboxConversationTests 42 (the story
test now links its second device first). Nine sabotages, each red, each
restored and checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a debug build the two walks take about a minute each, and the test
host's main thread not answering for that long ended the host mid-walk,
which xcodebuild reports as "Restarting after unexpected exit, crash, or
test timeout" rather than as a slow test. They now run detached and the
walk is thinner past 256 bytes (every 401st length instead of every
211th); every length near a step change is still checked.
On My Mac (Designed for iPad): 6 passed, 78 s and 56 s for the walks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sources/E2E/Padding.swift holds section 23's arithmetic: the message
ladder {16384, 65536}, the targets derived from it (never written out),
the plan, the pad drawn uniformly from [A-Za-z0-9] by rejection sampling,
and the length check on the bytes after the fact. A payload that misses
its step throws `wrongLength` and is never sent; one that does not fit
the top step throws `tooLong`, "Too long to send as a message. Send it as
a file."
SealedSender.sealedPayload pads the inner JSON and writes the outer blob
by template in the canonical form. JSONEncoder escapes every `/` in the
base64, about one in 32, so the length would miss the step; the vector's
escaped-slash case shows exactly that. The envelope and the inner JSON
are written in one fixed key order, because JSONEncoder's order changes
from run to run (measured) and the vector is byte-exact.
Attachments: `open` honours `clen` now, digest first, range-checked
before anything is decrypted; a `clen` that is not an integer keeps the
message and refuses only the attachment. `seal(pad:)` adds the random
tail to the attachment ladder, for use only when the relay says so.
Sealed rosters are padded to 8192 bytes or the next multiple.
Tests: padding-v1.json copied from flamenet-e2e privacy/sweep-2026-10
(7036115) and every case asserted in ProtocolVectorTests, the sealed
payload reproduced to the byte; PaddingPropertyTests walks 0 to 70,000
bytes in four header shapes, sealed with and without a return address
and unsealed, plus the composer limit, the pad's distribution and roster
sizes. Run on macOS against the app's own sources: 22 passed. Eleven
sabotages, each red, each restored and checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.31.0 is in App Review. Everything new from here goes into 0.32.0,
the privacy batch: padded payloads (SPEC section 23) and own-device
traffic held to linked devices (section 17.3). Build 32 keeps the
number lined up with the release, as every earlier one is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every group header on the iPhone read "xpa6698, Member #7", and 7 was the
iPhone's own account. The group screen names each member through the
buddy list and then the roster. Nobody is their own buddy and the relay's
roster carries no names, so the one member present in every group fell
through to the placeholder.
Found on the iPhone-to-Android pair against a local relay.
E2EGroup.displayName(of:me:known:) answers "You" for this account, then a
buddy's name, then the placeholder, and every name on the group screen
goes through it: the header, the roster, and the mention picker.
AGroupNamesYouAsYouTests: 3 red before, 5 green after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relay refuses a blocked sender only on an identified send. A sealed
send is authorised by the recipient's delivery key and does not say who
sent it, so a blocked contact who already holds that key keeps getting
through. Revoking delivery tokens does not close it: a refused token
falls back to the delivery key by design. The recipient learns the sender
when it unseals, so the recipient has to refuse.
Found on the iPhone-to-Android pair against a local relay: after this app
blocked the emulator, the emulator's next message went sealed, the relay
accepted it with the block row present, and this app showed it. Checked
twice, the second time minutes later with the block still in place.
The blocked list is kept on the device (KeychainE2EStore.blockedSenders),
written on every block and unblock and whenever the relay's list is
fetched, and deliver() drops anything from a blocked sender, calls
included. ABlockedSenderStaysOutTests: 2 red before, 4 green after;
BlockingTakesPresenceWithItTests and KeychainStoreWipeTests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.e2e-ref moves to d07ed14 (docs/spec-0-32), the commit the copies in
Tests/Vectors were taken from. The unit-tests gate and vectors-head now
also compare content-kinds-v1, today-v1 and hello-link-v1, which the
0.32 loader suites read.
The pinned commit has to be on the remote before this pipeline runs, so
the spec branch is pushed first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no site stored, every launch probes its relay, and the default is
relay.flamenet.io: the unit-test host did so on every unit run, and so
did every UI suite that did not name a server, previews included.
A DEBUG test launch now resolves its site through
SiteConfig.forThisLaunch. A test launch is the unit-test host
(XCTestConfigurationFilePath) or the app launched by a UI suite, which
says so in FNMSG_TEST_LAUNCH. For one, the default site is a closed port
on 127.0.0.1, and a stored production site left by an earlier suite is
replaced by it as well, unless a UI launch named that very host with
-FNMSGServer, the way an opt-in live suite says it means it. The unit
host has no way to name one. Release builds and ordinary launches are
untouched: the shipped default is still relay.flamenet.io.
Every UI suite now builds the app through XCUIApplication.underTest(),
which sets FNMSG_TEST_LAUNCH. UITestSafetyTests pins it: no suite builds
an XCUIApplication directly; a test launch with nothing stored, or a
production site stored, resolves to loopback; production only when a
UI launch names it, never in the unit host; an ordinary launch is
unchanged; and this unit-test host itself resolves off production.
Suites that signed in on production by relying on the default must now
pass -FNMSGServer with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy merged from test/vectors-0-32 predates the spec's 86def06,
which took lat and lon out of the "cc, tz, lat and lon" case, and its
d07ed14 adds a case for an envelope version written as a string,
unknown-version, for 34 cases in all. Refreshed from the spec branch at
d07ed14; the loader's pinned count moves to 34. The other in-tree
vector files already match it byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UITestSafetyTests requires every UI suite that launches the app to be
able to skip or to stay on the device, and the two new suites did
neither. Both run a DEBUG preview needing no account, but the app still
probes its relay at start-up, and with no site stored that relay is the
production one. Each suite now points the app at a closed port on
127.0.0.1 with -FNMSGServer, and the safety test counts their two
preview flags as local, beside Files and the wallet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 0.32 shared vectors, loaded by the app's own code: content kinds,
the Today file, hello links and the refreshed backup payload (TEST-A).
Test-only. Merged after the Today refusal reasons, which its Today
loader expects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 48 sets one list of refusal reasons for both apps and
the vectors: unknown-version is a v present but not exactly the integer
1, in the envelope or the body. iOS called a body v of 1.4 or 1.5
bad-body, and an envelope v of 1.4, 1.0 or true malformed, because a v
that was not an exact integer failed the integer check before the
version was compared.
Presence and value are now asked apart. No v at all is still the shape
being wrong: malformed for the envelope, bad-body for the body. A JSON
null counts as no v.
The version test now expects unknown-version for 1.4, 1.0, 0.6, 1e0,
"1" and true in the body and 1.4, 1.0 and true in the envelope, plus the
two missing-v cases; it failed on every one before the change. The
malformed-file test's boolean envelope v moves to unknown-version with
it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 47 (owner). The app signed in only once the relay had
answered who the stored credential belonged to, so with no network at
launch a signed-in phone opened on the sign-in screen.
An account signed in on this phone now opens from what is on it: the
credential is presented without a request, the inbox is built from its
own keys and history without registering, and the phase is signed in,
so the chats and the saved Today page are there at once. Behind them the
app asks the relay who this is, waiting 1, 2, 4 seconds and so on up to
a minute between tries; the first answer finishes connecting (device
registration, delivery key) and fills in the account's names. Only a
relay that answers and refuses the credential goes back to sign-in, as
before. No stored credential is signed out, network or not.
Opening without the relay needs the account's user id, which the relay
used to supply; it is now kept beside the username at sign-in, sign-up
and every restore, and cleared at sign-out. An install updated from an
earlier build learns it on its first launch with a network.
The test plays the relay with a real socket on 127.0.0.1: closed at
launch, the app is signed in with an inbox within seconds; opened on the
same port, the app asks for the account and connects, still on the
chats. A relay answering 401 still means sign-in, and no credential is
signed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test pass found the picture composer's audience line barely
readable on the pale page and, once, the typed caption invisible. The
Stories tab opens its composers from a button in its header, and the
header hands its buttons white text and a white tint so they read on
the navy; a sheet inherits both from the button it is opened from, so
the composers drew near-white words on a light page.
All three composers (a picture, a sequence of pictures, and words) now
put the theme's ink and tint back at their root, and their secondary
lines use the theme's secondary text colour, which is chosen for the
page in light and in dark, rather than a shade of whatever was handed
down.
The UI test opens the picture and the words composer from a header
button exactly as the tab does, in light and in dark, and reads the
pixels of the audience line, the typed caption and the typed words:
each must reach WCAG's 4.5:1 between text and page. Before the change
they measured 1.05, 1.39 and 1.22 to 1. The DEBUG stories preview now
follows the appearance setting, as the app proper does, so the test can
ask for each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 49. The test pass watched a sealed card stay sealed 25
seconds past its moment, through scrolling, opening only when the chat
was left and opened again. The bubble drew its face inside a
TimelineView whose content never read its context's date, and that
tick did not redraw it.
The face is now drawn for a time held in state. A task waits for the
moment the card opens, a quarter second past it, and sets the time, and
a state change always redraws. The task runs again whenever the card
comes back on screen, so one that opened while away is drawn open.
The UI test launches a DEBUG preview, a scrolling thread with one card
sealed for eight seconds, and touches nothing: the card has to open by
itself. Before the change it was still sealed after 20 seconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copies content-kinds-v1, today-v1 and hello-link-v1, and the extended
backup-payload-v1 canonical set, from flamenet-e2e docs/spec-0-32 at
cbe2226 into Tests/Vectors, and adds a loader suite for each new file:
- content kinds: every written envelope parsed and written back by the
app's encoders, byte for byte; every envelope through parse() and the
route deliver() takes, checked field by field against the file.
- Today: TodayFile.verify on the sample, every case and the 512 KB edge;
the sequence through TodayStore; If-None-Match through TodayPolicy.
- hello links: the link cases through HelloLink.address(inText:) and the
scan cases through HelloLink.scannedAddress.
Test files only. The copied backup-payload-v1.json differs from the
flamenet-e2e commit in .e2e-ref, so the CI vector gate reads red here
until .e2e-ref moves to the spec commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mute holds the banner the app posts for a call CallKit is not showing;
a snooze did not, so a snoozed chat still asked when its caller rang.
postIncomingCall takes a required snoozed flag and folds it into mute,
and the call manager passes whether the caller's chat is snoozed right
now, so the banner is held exactly as mute holds it and comes back when
the snooze ends. CallKit's own ringing is not touched.
The tests ring a call manager with a fresh offer, as the inbox hands
one over, and read a new debug hook for the banners that the settings,
mute and a snooze let through, ahead of the app-in-front check a test
host always fails. Snoozed, the offer rings and no banner gets through;
with the snooze run out, the same banner does. Each test hangs the call
up, so no ring or poll outlives it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
§8.4 submits silent the traffic that is not a message to a person, and
names delivery keys among it; §6.10 counts avatar as control with them.
Both went out announced. A delivery key sent on its own, as a session
repair or carrying a sealed group's key or a group's name, parses as an
unsupported attachment, and a profile picture carries an attachment, so
warrantsAlert counted either as something to see. Creating or renaming
a group, adding a member, or changing a picture buzzed the other phones
with "New message" and raised the icon badge, with nothing new to read.
warrantsAlert now answers no for a delivery_key and for an avatar. A
delivery key riding ahead of a message is unchanged: it shares the
message's request and takes the message's flag. Android already sends
both silent.
The new test reads the flag off the request for a group's name at
creation and on a rename, a sealed group's key, a new profile picture,
and a repair sent after the receiving side lost its session. The table
test moves the four kinds from their reported list to the silent one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A snoozed thread says "Snoozed, messages still arrive, this chat just
stops asking", and nothing on the way to a notification read the
snooze: it was stored and drawn, and every notice was posted as before.
postIncomingMessage takes a required snoozed flag and folds it into
mute, so a snoozed chat is held exactly as a muted one is, sound
included, and only while the snooze lasts. Every notice the inbox
raises passes it: messages, cards, events and their changes, and game
notices. Only a one-to-one chat can be snoozed, so group and federated
threads pass false. Reply reminders still fire, as the snoozed thread's
own line says, and the incoming-call banner is not changed.
The tests listen at both ends of the notification path, through a new
debug hook for what is handed to the system. In a snoozed chat a
message, a card, an event, its revision, a game invitation and a move
all arrive, all six reach the path, and none is posted; once the snooze
runs out, or "Wake now" is chosen, the chat is heard again; the same
person in a group, and somebody else one to one, are heard throughout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An edit carries its new wording in the text field, so warrantsAlert
counted it as something to see and the relay was asked to announce it:
the other phone got a second alert, sound and badge for a corrected
typo. §6.15 says an edit MUST NOT raise a notification, as §6.9 and
§6.19 say of reactions and pins.
warrantsAlert now answers no for any edit, reaction (to a message or a
story) and pin. Reactions, story reactions, pins and story caption edits
already went out silent, because their text is empty or they were
special-cased; now they do by rule.
The first two tests read the flag off the POST /e2e/messages request: a
one-to-one edit, reaction, reaction taken back, pin and pin taken down;
a group reaction, a private group reaction, a story reaction and a
story's words changing. An ordinary message and a group message are
the announced controls. The third pins the whole table of kinds as this
app sends them, including four that go out announced against §8.4 (a
profile picture, and a delivery key sent on its own as a repair, a
sealed group's key or a group's name), reported and not changed here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As on Android: only the number 0, false and null leave an envelope as
anything but control. The strings "0", "false" and "null", and an empty
string, are values like any other, so an unknown kind carrying one is
dropped and a card carrying one is not drawn. iOS already read them this way
after 9d1612f5; this pins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stories: take one down for everyone, fix its caption, and the whole photo,
never cut (DESIGN items 25 to 28).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 46. A §6.17 secs on a card, an event or a game set that
message's own timer; in 0.32 it is ignored, and the chat's disappearing
timer still applies to them. A text keeps its timer. The test failed on the
old code first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 45. days was clamped to 1 to 366 on arrival and the composer
sent up to 366; now above 31 shows as 31, below 1 as 1, and the composer
sends at most 31. The test failed on the old code first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 43, §6.10's "carrying ctl". ctl was read as a flag, so a
value it did not know, "1" written as a string among them, read as off and
the envelope was drawn: an unknown kind as the unsupported placeholder, and
a card or an event as a bubble although ctl wins over both. Now any value
other than 0, false or null is control. The other flags keep their own
reading. The test failed on the old code first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At most five notes of 200 characters each (the app kept ten of 300). A
weather day whose date is not a real yyyy-MM-dd is dropped, where it was cut
to ten characters and kept. A fractional made refuses the file, where it was
rounded. A country is two ASCII letters or is ignored. A zone over 64
characters is ignored rather than cut, and a zone the phone does not know
shows the days as the file gives them, where they were cut by the phone's
own date. Market figures were already capped at 24, and lat and lon are not
read at all. Each new test failed on the old code first, the 24-character
one excepted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 41. The host was lowercased with Unicode rules before it was
checked, so a Kelvin sign became a "k" and one host read as another. The
address is now checked to be ASCII first and lowercased by ASCII rules
alone; a pasted address gets the same check. The scanner read codes through
the paste path, which trims round the at-sign for somebody typing, so
"fnid:… @ relay" was taken; it now reads the code itself and refuses any
space in the address. A slash after the app link's hi was already refused.
The test failed on the old code first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview's bars were white on the white sheet, so a narrow photo did not
look the way the feed will draw it. Both composers now sit on the feed's
own page colour. The debug preview's fixture photos keep their labels
inside the picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An `unpost` names exactly one of `sid` and `set`. One naming both is now
dropped, like one naming neither, instead of taking down both. A per-message
`secs` (§6.17) on an `unpost` or a caption edit plays no part; a test pins
both rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feed card drew every photo in a fixed 240pt band, filled and cropped,
so the top of a tall photo was cut off. The card now takes the photo's own
shape between half and one and a quarter times its width (180 to 451pt on
a 393pt iPhone, 204 to 510pt on a 440pt Pro Max, at most 560pt anywhere),
with plain bars beside or above a photo narrower or wider than that. The
composer previews the photo with the same view at the same width, so what
is posted is what friends see. Nothing was cropped on upload: ImagePrep and
VideoPrep both scale to fit.
My own story has Edit caption and Take down on the card, in the long-press
menu, in the full-screen viewer, and Take down in the archive. The take-down
asks first, in the words both apps use. Small thumbnails in the sequence
composer centre on the top third. `-FNMSGStoriesPreview` (debug builds only)
shows the feed, the confirmation, an edited caption, the composer and the
caption editor with fixture photos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking a story down (§6.35) removes my copy at once: the strip, the archive,
the decrypted bytes, the reactions and seen marks it carried, and any copy
still queued for somebody who could not be reached. Then an `unpost` goes to
the people the story went to and to my other devices, as silent control
traffic: `{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}`, or
`"set":"<set id>"` for a sequence, which comes down whole. A story now
records who it was sent to when it is posted; one posted before that, or
restored from a backup, goes to today's audience, or the group's roster
today. The relay has no way for an uploader to delete a blob, so the blob
waits for the retention purge.
A caption change is §6.15's edit with `st`, naming the story by the id
replies use, and a text story changes its words the same way. It is shown
as edited, refused when empty, ordered by the relay's time, sent silently,
and never notifies.
A receiver applies either only to a story it holds from the sender of the
envelope, and only a proven device of my own can take down or change a
story of mine. An unknown id is dropped. `set` is read as an object on a
story and as a string on an `unpost`, and a `set` or `sid` of any other
shape no longer costs the whole envelope. The backup marks an edited
caption with "edited".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 38. The kid is lowercase hex, the form the app computes; the
app lowercased whatever arrived, so A9A94E74 was taken for a9a94e74. Both
versions, the envelope's and the body's, must be the JSON integer 1: the
lenient integer reader rounded, so 1.4 and 0.6 were taken for 1. A market
needs a name and its last figure, the symbol staying optional; an empty
figure was kept. Each test failed on the old code first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 38: the file comes from the home relay and nowhere else.
URLSession follows redirects by default, so a relay answering 302 sent the
request on to wherever it pointed. The session now has a delegate that
refuses every redirect. The 3xx comes back as the relay's own answer, which
counts as a relay without a file right now: the last good copy stays and the
page says so.
The test points a loopback relay's 302 at a second loopback server holding a
good file. It failed on the old code first, the second server receiving the
request; now the fetch reports 302 and the second server hears nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 37. Until the invitee has moved, the invitation is still
open: the invitee can decline it and the starter can cancel it, and there is
no game yet to call a draw. This phone's own button already held back, but an
offer from the other phone in that window was taken. Now it is ignored. The
test failed on the old code first; its control, the same offer after the
invitee's first move, counts.
The other parts of decision 37 already held here: a gmove with no log or an
empty log and a gend with no log are dropped when read, and a draw-accept
counts only at the full known log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 35. Only https://flamenetmessenger.com/hi#, /es/hi# and
flamenetmessenger://hi# are hello links: no query string, the path exactly
/hi or /es/hi in its own case with no trailing slash, the scheme and host in
any case. The app took /hi/# and /hi?ref=x#, which the other app and the
relay's page do not. The test that now refuses them failed on the old code
first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 34: §8.11 is push-token registration, which older code
already cites, and the spec gains that section to match. The Today file is
§8.12. Every 0.32 citation of §8.11 for it changes; the push-token ones stay.
A test reads every Today file and the tab bar for a stray §8.11, with the
push-token citation as its control.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test waited until the stub relay had recorded the revoke request, then
checked that the app had cleared its note. The stub records the request
before it answers, and the app clears the note only once the answer reaches
it on the main actor, so on a busy host the check could land in between and
fail an app that was about to do the right thing. It now waits for the note
itself, the app's own record of the reply, and then checks the relay was
asked.
Measured with the two waits side by side while the main actor was kept busy
in 40 ms stretches, ten rounds each: the old wait failed 10 of 10 and the new
one passed 10 of 10. The committed test also ran 30 times in a row with the
machine at a load average over 160: 30 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On iOS 26 and later `.searchable` puts its field at the foot of a sheet, on
a light ground, while the app styles every search field's text white for the
navy bar, so what somebody typed was white on near-white. Both sheets now put
the search box on a strip of the chrome under their own bar,
PortalSearchStrip, which carries on from the bar's gradient. The same thing
was seen and fixed on the 0.32 weather and send pickers.
Nothing in the app uses `.searchable` now, and a test keeps it that way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 32. Both places the scanner is opened, Home and Messages,
already handed on the address alone, and the card the scanner returns has no
place for the `?u=` an older card carries. A test now holds both: a code with
a username comes back as the same card without it, and every scanner call
site passes the address on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 32. signOut now clears the kept Today file and the cities
picked for weather, and every way out of an account goes through it: the
sign-out screen, a device removed from the account, and a lost-device wipe.
A request already on its way when the phone is cleared is dropped when it
returns, rather than putting the file back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 31, the owner's: while supervision is on, the Today tab is
not in the bar and its screen is not drawn even if it was the tab last open,
and the small-web directory, reached only from Today, goes with it. Both lead
straight out to the open web. It is not one of the gates a parent can
release; the supervision screen says so.
Tested beside the other supervision tests, which share the one process-wide
PIN: Today is in the bar without supervision, leaves when it is switched on,
stays hidden with every gate released, and comes back when it is switched
off. A source check confirms the screen sits behind the same rule and that
nothing but Today opens the directory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 30. Chess, checkers and reversi turn round for the second
player; tic-tac-toe's cells keep their places on both phones. The board view
already did this inside a private property nothing could test. The rule and
the cell mapping are now GameBoardView.turnsRound and boardCell, tested for
every game and both sides, with chess and reversi as the control that a
turning game does turn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 29. The invitation already read "Sam invited you to play
chess" and came with a sound. The unread rule did not hold: every game
message that arrived as a new bubble added to unread, and a move whose
invitation was lost builds a new bubble from its log (rule 1), so a lost
invitation turned the first move into an unread one. Now only an invitation
adds to unread.
Tested between two inboxes on a stub relay: an invitation is unread and said
out loud; a first move whose invitation was dropped on the way builds the
board, is said quietly and adds nothing to unread; a later move adds nothing
either.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 0.32 release: stickers in FlameNet's art, cards with sealed until,
events with answers, games in a chat, the Today tab, the small-web
directory, and hello links. Build number follows the release, per the rule
above it in this file.
Checked: after a regenerate, project.pbxproj carries MARKETING_VERSION =
0.32.0 and CURRENT_PROJECT_VERSION = 32. No archive and no upload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two branches met in project.yml without a textual conflict: the
stickers and cards folders became folder references so the bundle keeps the
paths both apps name, while directory/directory-v1.json was still copied to
the bundle root by the Resources group. It now keeps its path too. The
loader looks in directory/ first and at the root second, so either layout
reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Today tab, the small-web directory shipped in the app, and hello links
(DESIGN sections 4 to 6).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stickers in FlameNet's art, cards with sealed until, events with answers,
and games in a chat, with the games engine (DESIGN sections 1 to 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picks are kept in the phone's settings, so a run inherited whatever the
run before it had picked, and tapping a city an earlier run had picked took
it away again. -FNMSGTodayNoPlaces clears them when the preview opens; the
suite passes it and checks each city is picked after its tap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lone Pass button sat on a row of its own above Resign, and buttons that
appear together belong in one row. Tic-tac-toe's O was drawn in the channel
red, which is hard to read on the dark board, so both marks now have colours
of their own for each appearance, and the dots marking where a move can go
are smaller on the big tic-tac-toe cells.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /hi page checks the host as letters, digits, hyphens and dots with at
least one dot, which is DESIGN decision 21 as written, and caps the fragment
at 400 characters. The app also refused empty labels, a leading or trailing
hyphen and labels over 63 characters, so a link the page offered to open
could be refused by the app it opened. Now both apply the same rule, and the
app takes the same 400-character cap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-FNMSGTodayPreview opens the Today tab, the directory or the address card
with no account, because simctl can launch the app but cannot sign in. The
Today screen is the real one: the store, the request, the signature check and
the copy on disk, against whatever relay is named on the command line, with a
folder of its own per state name so the app's own store is never touched.
DEBUG builds only.
TodayScreensUITests drives it: the page with a file, picking two cities, the
foot of the page, the same page with the relay gone, a relay that does not
offer the file, browsing and searching the directory, and the hello link card.
The Today cases need TEST_RUNNER_FN_TODAY_SERVER and
TEST_RUNNER_FN_TODAY_UNOFFERED pointing at relays on 127.0.0.1 and skip
without them; screenshots go to TEST_RUNNER_FN_TODAY_SHOTS when it is set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hello link (SPEC §6.34) is https://flamenetmessenger.com/hi#<address>.
The address is in the fragment, which a browser never sends, so the page's
web server never receives it.
The address card gains "Hello link": the link, Copy link and Share in one
row, and what it does and does not tell anybody. The QR code now holds the
hello link rather than flamenet:<address>?u=<handle>, so a phone camera with
no Flamenet app lands on a page that explains it; the code no longer carries
the username.
The app registers the flamenetmessenger scheme. Opening
flamenetmessenger://hi#<address> hands the address to Home's search box, the
landing a scanned code has, and does nothing else: the person reads who it is
and decides, first contact goes the way it always does, and nothing is sent
until they send it. Signed out, the address waits for sign-in and the person
is told. A link that is not one, or holds no address, gets a plain message.
Home now also goes back to its list first, so the address is seen even when a
chat was open.
Parsing follows DESIGN decision 21: the https link (/hi or /es/hi), the
app link, and bare or flamenet:-prefixed addresses; the fragment decoded
once; fnid: plus 52 characters of the FNID alphabet; the host lowercased,
letters, digits, hyphens and dots, with at least one dot. ContactCard.decode
reads a pasted hello link as its address and refuses one that holds none,
and no longer takes a URL with an at-sign in it for an address. The scanner
accepts only what passes the strict form, says so when a code is not an
address, and keeps scanning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Today sits in the tab bar right after Messages, as on Android: Home,
Messages, Today, Calls, Stories, Files, Wallet, Account. The file is asked
for when the tab opens and when the app comes back to the front, within the
hourly rule. The sections run in SPEC §8.11's order: headlines with their
source and the host each link goes to, markets under "Prices are delayed.
Not advice.", weather for the cities picked on this phone in °C or °F as the
phone's region and temperature setting say, the site of the day with the
file's note about its description, the blog post when there is one, then the
way into the directory and the credits. Every link opens in the browser on a
tap and not before. The header says when the file was made; offline or
refused, the kept copy shows with one plain line saying why. A relay that
answers 404 gets only "Today's page is not offered by this relay." and the
directory.
The directory browses by category and searches on the phone; the paper plane
on a site opens "Send to a chat", which lists 1:1 contacts and shows what will
be sent. Both pickers draw their search box on the chrome: a .searchable
field sits at the foot of a sheet on this system, light, where the app's
white search text cannot be read.
Eight tabs is one more than the bar was drawn for. On a 375pt phone each tab
has 46pt and "Messages" is 48pt wide, 51 in bold, so tab labels may shrink to
75 percent rather than lose their ends. A test measures every label against
that width. The tab list moved into MainTabView.tabItems so the screenshot
preview draws the same bar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
directory/directory-v1.json is FlameNet's export of public, approved
listings: 2,964 sites in 90 categories. It is read from the app bundle, so
browsing and searching it ask nothing of any server and work with no
connection. Search folds case, accents and width, needs every word typed, and
looks at the title, the host and the description. Sites with no category are
found by search only, as on the site. A description is optional, and the
file's note about who wrote the descriptions is kept to be shown once.
"Send to a chat" sends an ordinary 1:1 text holding the link, with a §6.12
preview made from the directory itself, so nobody fetches the site, not the
sender and not the receiver. The preview carries the title only: the
descriptions were drafted with software and a preview reads as the site's own
words (DESIGN decisions 6 and 18). desc is present and empty, because iOS
builds up to 0.31 cannot read a preview without it. With link previews
turned off the link goes alone.
E2EInbox.send takes the preview as an optional argument; when given, it is
sent as it is and the fetcher is not called.
Checked between two inboxes on a stub relay: the receiver gets the link and
the title-only preview, and a trap on the shared URL session saw no request,
while the same link sent the ordinary way was fetched, so the trap works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Today tab shows one public file from FlameNet (SPEC §8.11), served by
the home relay at /today.json and the same for everybody. This is the part
underneath the screen.
The file is shown only when its Ed25519 signature checks out against a key
compiled into the app, chosen by the kid the app computes for each pinned
key. The development key is pinned in DEBUG builds only; the production list
is empty until the release step creates that key, so a build without it
verifies nothing. Anything over 512 KB, unparseable, of another version,
signed by an unknown or wrong key, or without a valid date is refused and the
last good copy stays. Lists are cut to their bounds, strings are cut and
flattened, only http(s) links survive, and an older file never replaces a
newer one.
The request carries nothing about the person: its own ephemeral session with
no cookie store, no credential and no cache, a fixed User-Agent and
Accept-Language, no query string, and the obfs4 bridge applied as the other
relay clients apply it. It goes out at most once an hour, counted from the
relay's last answer, and five minutes after a connection that got none
(DESIGN decision 19). If-None-Match is sent only when the stored ETag is the
SHA-256 of the copy held, so no relay can tag a phone. The last good
envelope is kept on disk, excluded from backups, and checked again when it is
read back.
Tests sign their own files with keys they make, plus one fixture signed by
the development key with OpenSSL. The request is checked twice: as built, and
on the wire through a loopback server, with a control proving that server
sees a cookie when one is sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A group message that reached nobody at first, because no member had a
device yet, said "pending" for ever. Its queued copies carried an empty
`mid`, and the restate after they went matched only a bubble with no `mid`
at that moment, while every group send gives its bubble one. The queued
copies of group text, photos, stickers, cards and events now name their
bubble, and the restate after a flush, or after a refusal waiting cannot
fix, finds it by that id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sticker that named a group went into the sender's one-to-one thread, and
a group had no way to send one. A sticker with `gid` is now a group message,
delivered and notified like a group text, and the group composer has the
same sticker control as the one-to-one composer. The group envelope carries
`gid` and the group's name in `gname`, with one `mid` for the whole fan-out,
and members with no device yet get it from the queue like any group send.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A card or an event from someone on another relay was kept on the message
but the screen drew only its text, which is empty for both, so it showed
as an empty bubble. The thread now draws the card, or the event card
without buttons, since nothing there can answer or change one, and the
conversation list shows the same summary line as other chats.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Game is in the one-to-one attach menu: pick chess, checkers, reversi or
tic-tac-toe and who goes first. The card shows the board as the engine
draws it, turned round for the second player (tic-tac-toe keeps its
numbers in place), whose move it is, and the moves so far under a fold.
Tap a piece and the squares it can reach are marked; tap one to move. A
pawn reaching the far rank asks what it becomes. A checkers jump is entered
landing by landing and sent once it is a whole move. Reversi marks its
legal squares and shows a lone Pass button when that is the only move.
Every square reads out its name and what stands on it.
The buttons sit in one row: Decline or Cancel invitation while the
invitation is open, Resign and Offer a draw after it, Accept draw and
Decline on an offer, and Start a new game once the boards disagree, which
sends a fresh game of the same kind with the same sides. A game is never
offered for forwarding.
`-FNMSGKindsPreview` (debug builds only) has a mode for each board, a
finished game, an invitation, the out-of-step card and the new-game sheet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game is played inside a one-to-one chat (§6.33). `game` starts it and is
the message its board lives on. `gmove` carries the whole log after each
move and `gend` an ending or a draw offer, both as control traffic. Each
phone rebuilds the board from the log with the engine's capped replay and
checks that every new ply is the sender's and legal. A log that disagrees,
a move for this person's side or a log past the cap stops the game as out
of step, and nothing more is taken for it.
A draw is offered only by the side that has just moved and accepted only
against that pending offer. The invitee can decline and the starter can
cancel until the invitee's first move, judged on the log the gend carries,
so a cancel that crosses that move ends the game on both phones. A move for
a game this phone never saw rebuilds it from the log. A move whose send
fails is taken back with an error and never queued; the start is queued
like a card.
Moves and endings are announced, and the app says them itself: with no
sound for "Your move: chess with Sam" and "Sam offers a draw: Chess", with
one for an invitation and for "Chess with Sam: They resigned. You won".
Nothing is said for this person's own moves or a turned-down offer.
A group's game draws as unsupported and its moves are dropped; a start
from another relay draws as unsupported too. The game lives on its start
message, so history, the disappearing timer, delete and off the record
cover it. The backup carries the game object both apps read: id, t, me,
log, and r with rby when a gend ended it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A games-engine job runs `swift test --package-path Packages/FlamenetGames`
on the mac-local runner for merge requests and main, the same rules as the
other jobs. It needs no simulator; a cold run on this Mac took 12 seconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app target depends on the local games package from the merge before
this, so the chat code can replay a game from its log. Nothing calls it yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Describes the packs as they ship: every manifest under `Resources/stickers/`
is a pack, a sticker is named by pack and id together, `image` lives in a
folder named for the pack, what a sticker draws when something is missing,
and one picker section per pack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings events in line with the Android app:
- An answer's `n` is the unix second it was given, or one past the last `n`
this device sent for the event when that is later. The highest `n` wins,
and on an equal `n` the answer that sorts higher as a string (`yes`, then
`no`, then `maybe`), so every phone settles on the same answer whatever
order they arrive in. This also covers one person answering from two
devices.
- A cancellation notifies "<title> was cancelled" and the card reads
"Cancelled: <title>". A revision notifies "<title> changed" by the title
the reader had before. A rev 1 that settles a provisional organizer, a
duplicate and a stale revision say nothing.
- An event id only has to be a non-empty string.
- The organizer's time line names the weekday when the organizer's date
differs from the reader's ("Wed 7:00 PM New York time").
- The backup writes a card or an event with empty `text`, whatever the
message held, so a sealed card's words stay inside `card`.
Code and test names now cite §6.31 (greeting cards) and §6.32 (events and
answers).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sealed card read the time from its timeline's context, and the first draw
can be handed the scheduled opening time itself, so a card still sealed drew
as open: no "Opens on" under it and no "Sealed until" under the sender's
copy. It now reads the clock each time it draws and is woken a second after
it opens. Add to calendar and Edit take the deeper blue, which stays
readable on the dark card. The preview shows both sealed lines on one screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core pack and FlameNet's both have a `star`, a `cake` and a `coffee`.
The picker keyed its cells by sticker id alone, so SwiftUI drew the second
of each pair as an empty cell. `Sticker.key` is the pack and the id together,
the picker uses it, and a test checks the shipped keys are unique. The picker
also takes a height, for the debug preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local SwiftPM package with no dependencies: chess, checkers, reversi
and tic-tac-toe as DESIGN.md section 2 describes them. Moves are strings
in the wire notation, positions are immutable values, and apply accepts
exactly the strings legalMoves returns, so a log replays the same way on
both apps. Replay refuses a log over the game's cap before playing it and
names the index of the first move it cannot play.
The tests run games-v1.json, which the Android :games module runs byte
for byte: the perft counts from DESIGN.md and 147 cases. Deeper published
perft counts run with FLAMENET_GAMES_DEEP=1.
Not wired into project.yml or the app target yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Card and Event join Photo and File in a one-to-one chat's attach menu, and a
group's paperclip becomes the same menu. A card is one of the nine
flamenet.io fronts, shipped as `cards/<design>.png` with its title drawn as
text, and can stay sealed until a day up to 400 days ahead. The receiver sees
the front and "Opens on <date>" until then, and the card opens by itself on
the day; the sender's copy says "Sealed until <date> for <name>". Copy,
reply, forward, report, the conversation list and the notification never
show the words of a card that is still sealed.
An event card shows when and where, the organizer's time when their clock
reads differently, Going, Maybe and Can't go with counts and names, and
"Everyone in this chat sees your answer." The organizer can change or cancel
it. Add to calendar opens the system's own add-event sheet, which on iOS 17
and later needs no calendar access and no Info.plist key; a test presents it
and checks that nothing asked.
`-FNMSGKindsPreview` (debug builds only) shows the new screens with sample
data, for screenshots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three content kinds that travel inside the ratchet, so the relay sees nothing
new: `card`, a greeting card that can stay sealed until a date; `event`, an
invitation its organizer can revise or cancel under a higher `rev`; and
`rsvp`, an answer, marked `ctl` so older builds drop it.
Both apps write the same bytes: compact JSON, keys in the shared order,
slashes left unescaped. Reading is lenient: unknown fields are ignored, flags
take 1 or true, a number with a fraction is read as a whole number, a field
of the wrong type is absent, and a card or event that carries `ctl` is
consumed as control.
A card or an event lives on the message it arrived on, so history, the
disappearing timer, delete and off the record (held in memory, filtered from
every save) apply with no new store. A revision folds onto the card it
revises: the highest `rev` wins, and only the sender of rev 1 may revise. An
answer folds by the highest `n` per person, never notifies, and goes to every
member of a group; one that arrives before its event waits in memory for it.
With no sent copies yet, a proven envelope from this account's own device is
placed as its own in a group and dropped in a one-to-one chat.
The backup carries `card`, `ev`, `org` and `rsvp` on each message, in the
shape both apps read. The field test now reads the whole type, so the fields
declared after a computed property on a message are checked too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picker shows each pack under `stickers/` as its own section: the core
pack first, then FlameNet's 24 drawings (`flamenet.retro`). A sticker draws
its image when it has one, its glyph when it has none or the image will not
load, and its pack's fallback when this build does not know the id. A pack
this build does not ship draws the core fallback, as docs/STICKERS.md
describes.
`Resources/stickers` is now a folder in the bundle, not a group, so each
pack's PNGs sit in a folder named for the pack and two drawings with the same
file name cannot collide at the bundle root. The SVG sources and the render
script are in `Art/stickers/`; the PNGs are the bytes the Android app ships.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steps 1 and 2 are built on both apps (Mailboxes in Settings), step 4 partly
(publishes first, but does not delete the account left behind and cannot
remove the relay you are signed in to), step 3 not at all. The roadmap entry
said most of this was still to do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most of the queue has shipped. Moved those to a Done section with where each
lives. Open: quiet delivery (the wire flag exists, the choice does not),
relay migration steps 2-4, groups off the relay, per-contact identity,
local-network delivery, multi-device on the apps, passphrase floor,
key-log default, web client, duress, attachment tokens, credential
rotation, age-off and tombstones, the group-outbox recipient test. Held:
bridge distribution (OTF IFF concept note) and protocol-shape changes until
the audit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The source moved to gitlab.com/extant2000 and flamenet-elligator2 was
published, so BIS and NSA were notified again. The first entry is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The messenger repositories move from gitlab.com/paulhitt to
gitlab.com/extant2000. Old web and git URLs redirect; the relay's container
image path does not, so docker-compose.yml names the new registry path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scripts/Package.resolved had `"pins": []`. It emptied correctly when WebRTC
moved into the local Packages/WebRTCBinary (b66d2991), and stayed empty when
the Obfs4 submodule added swift-crypto and swift-nio from GitHub. So every
build resolved those against the network, and a build with
-disableAutomaticPackageResolution failed:
an out-of-date resolved file was detected ... dependencies were added:
'swift-crypto'
Now pinned to exactly what Packages/Obfs4/Package.resolved pins, the set its
lyrebird interop suite runs against: swift-crypto 3.15.1, swift-nio 2.102.0,
swift-asn1 1.7.2, swift-atomics 1.3.1, swift-collections 1.6.0,
swift-system 1.8.1. A fresh `generate.sh --update` would have taken newer
asn1, collections and nio than Obfs4 has tested.
Checked: after generate.sh, xcodebuild with -disableAutomaticPackageResolution
builds and reports those exact versions; the old empty file fails the same
strict resolve with the error above. iOS unit tests on the pinned set:
1371 passed, 0 failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App Review rejected 0.29.0 under guideline 3.1.1 on 2026-09-25: the
Profile row opening the project's donate page counts as a donation
toward the app's own development, which that guideline requires to go
through In-App Purchase whether or not anything is unlocked. The old
comment argued the opposite, citing 3.2.1, and was wrong. Opening the
page outside the app does not avoid it either.
So the row is gone. The choice 3.1.1 leaves is In-App Purchase or
nothing, and this app does not put a payment flow in front of people to
fund itself. The page is still served at /donate for anybody who looks.
`SiteConfig.donateURL` stays, unlinked, with a comment saying why it is
not dead code: the address is still right, and a constant explaining
itself is a better answer to "why is there no Donate row" than an empty
file and a git log.
`DonatingGoesToTheProjectTests` is deleted. It existed to assert the row
was present and pointed at the project's own site.
Version 0.31.0, build 31. Not 0.30.0: that was tagged before this fix and
would archive with the row still in it, so the tag is left alone and the
release moves up rather than being rewritten under itself.
One thing worth leaving a marker for. The first attempt at this comment
named the host in prose, and `ThePolicyIsReachableFromTheAppTests`
forbids that string ANYWHERE in this file, comments included, so a
self-hosted relay's users cannot be sent to somebody else's policy. It
reads raw source and cannot tell prose from code, which is the right
kind of blunt. It surfaced as "964 tests, 0 failures" followed by
"** TEST FAILED **", because that count is XCTest's and the guard is a
Swift Testing expectation, reported separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven source commits have landed since releases.json recorded 0.29.0 on
2026-09-22, none of them in any build: off-the-record threads no longer
reaching the disk, bridge lines out of backups, the parental PIN
stretched, the sealed-send fallback said at the time, one list of
keychain accounts, and an unproven key replacement naming itself.
Build number follows the release per the rule above it in this file, so
0.30.0 is build 30. The generated project picks both up from here, which
is checked: project.pbxproj carries MARKETING_VERSION = 0.30.0 and
CURRENT_PROJECT_VERSION = 30 after a regenerate, and Info.plist resolves
them by substitution at build time rather than holding literals.
No archive and no upload. The changelog entry in flamenet-relay's
Resources/releases.json is separate and deliberately not in this commit:
two commits dated 2026-09-22 cannot be attributed to a build from the
repo alone, because nothing marks what 0.29.0 contained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`OffTheRecordTests` asserted that `persistable(threads)` appeared
exactly twice, under the comment "Both call sites, and no third one that
skipped the filter".
It cannot catch that, and it caught the reverse. A site that SKIPS the
filter adds no occurrence, so the count stays at two and the test
passes, which is what it did for as long as `flushHistory` wrote
off-the-record threads to disk. The only thing that failed it was adding
a third FILTERED site, which is to say the fix.
So the suite written to protect §6.28 was, in that line, holding the
defect in place. Counting the good case cannot find the bad one. It now
looks for the bad one: any `threads:` argument that did not go through
the filter. Reverting the fix fails it.
Found because the run reported "948 tests, 0 failures" and TEST FAILED
at the same time. XCTest and Swift Testing both run here and the XCTest
summary is blind to the other 384, so a reader of the first number would
have called the suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The landing page says a conversation held off the record is gone when
the app closes. It was not. It was sealed to the history file and read
straight back on the next launch.
`flushHistory` passed `threads` through unfiltered while the two
debounced paths passed `persistable(threads)`. Twenty-four ordinary
actions flush, adopting a roster and scheduling a send among them, so
this was not an edge case, and the load side does not filter either:
`self.threads = restored.threads`. The conversation reappeared.
The comment on `persistable` read "This is the one filter every path to
disk goes through". It was a description of an intention rather than of
the code, and it is the reason this was easy to miss. It now says what
is true and why the sentence used to be false.
Four suites already tested the off-the-record filters and all four
passed throughout. They test the helpers. The defect was a call site
that never reached one, which no amount of testing the helper can find.
So the new test asserts the invariant instead: no path hands a thread
dictionary to disk without filtering it. It reads the source, which is
unusual and is the only level at which "every path" can be checked at
all, because `threads` is `private(set)` and a test cannot put a message
into one from outside. It carries a control that fails if the file is
not actually read, since that is how a test like this rots into passing
vacuously.
Reverting the fix fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The configured bridge line and every pending offer lived in
`UserDefaults`, which iTunes and iCloud back up. An offer carries
`fromUser`, `fromName` and the line, so a backup held a contact's name
in the clear beside the address of a bridge they use.
That is the wrong place twice over. It is a contact graph, naming
somebody who trusted this person enough to hand them a way through, and
it is circumvention metadata: the bridge's host, port and fingerprint,
and the fact that this person went looking for one. obfs4 is here for
people who expect the device to be taken and read. A feature built for
that threat cannot keep its state somewhere a backup carries off the
phone.
Both now sit in the Keychain under
`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, the attribute the
account credential already uses: never in an iCloud keychain, never in a
backup, and readable after first unlock so a bridge can still be dialled
before anybody has touched the screen.
Moving the store is not enough on its own. A phone that has already run
an older build keeps the old copy, and every backup taken from here on
carries it. `migrate` takes the value and removes the old one whether or
not the new write succeeded, because leaving it is the bug.
Android never persisted offers at all, so this was the iOS half of a
problem the other app did not have.
Four tests. One walks the whole UserDefaults dictionary asserting the
contact name and the bridge address appear nowhere, which tests the
property rather than the implementation. Two mutations, two dead:
leaving the old copy behind, and writing the line back to UserDefaults.
Found by row 2 of the audit readiness sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mutation testing found these unguarded: disabling any one of five refusals
left all 384 tests green, so the checks were implemented and unproven.
§20.1 and §20.5, in a new PQIdentityRefusalTests: an identity that
disappears from a device that had one, a signature over bytes other than
the prekey it is served with, and a substituted ML-DSA key on a device
already pinned. The bundles are decoded from wire JSON rather than built
in memory, so a change to what a relay actually serves fails here instead
of passing against a shape only the test believes in.
§19.4, appended to AutomaticKeyLogTests: a head smaller than the pin, and
two roots at one size. Every existing test in that file was about a log
being ABSENT; none pinned a head and then served a contradicting one.
Each group carries a positive control, because three refusal tests also
pass against a client that refuses everything. Five mutations, five dead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing allow_failure in the previous commit accomplished nothing, and
this is the correction.
A job with no `rules` and no `only` runs on branch pipelines and is left
OUT of merge request ones. Every other job in this file carries the two
conditions; this one carried neither. So the job was never in the
pipeline that gates a merge, and the flag that supposedly stopped it
blocking anything was decorative either way.
The pipeline on !230 is what said so: a real merge_request_event on
refs/merge-requests/230/head with four jobs in it, secret-sweep,
unit-tests, vectors-head and parity-scrub, and no ui-tests. The previous
commit was verified by checking that allow_failure was gone from the
YAML, which it was, rather than by checking the job ran, which it did
not.
It also revises the evidence in that commit. The three clean runs at
4.6, 5.0 and 5.2 minutes were branch pipelines, main and one triggered
by hand, so none of them was ever in anybody's merge path. The comment
above the job now says that too.
`unit-tests` is unaffected and the other half of the previous commit is
good: with the simulator shut down first it ran in 3.1 minutes, against
the thirty-minute timeout that prompted it.
`ci/lint` reports the file valid. Whether this worked is readable off
the next pipeline's job list rather than off this file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, one of them a correction to a diagnosis.
`ui-tests` no longer carries allow_failure. It earned that on evidence,
not on an estimate: three consecutive CI runs at 4.6, 5.0 and 5.2
minutes, the last of them immediately after an unrelated job on the same
host hung for half an hour, so it was not a quiet machine that earned it.
Both simulator jobs now shut the target device down before invoking
xcodebuild. On 2026-09-25 `unit-tests` sat for thirty minutes and was
killed by the job timeout having printed no test output at all, five
minutes after a pipeline was cancelled mid-build. Its normal run is
2.6 to 3.8 minutes. A cancelled xcodebuild can leave CoreSimulator
holding a booted, unusable device, and the next job waits on it forever.
`shutdown all` is the usual line for this and is deliberately not used.
This runner is a workstation, and a simulator somebody is debugging in
is not a CI job's to close.
The correction: two mac-local runners are registered, which looks like
these two jobs can collide over one simulator, and that was very nearly
fixed with a resource_group. Their start and end times say they have
never once overlapped, in either the healthy pipelines or the broken
one: 18:03:19-18:07:07 then 18:08:08-18:13:10, and 12:44:33-13:14:37
then 13:15:28-13:20:05. There was no race. The comment above the job now
says so, because the next person to read those two runner entries will
reach for the same wrong fix.
The stale half of that comment is gone too. It said allow_failure stays
"until it has shown a run of clean results on an idle runner, then delete
the flag", which would have been a lie about a flag that is no longer
there.
`ci/lint` reports the file valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other half of the cross-engine check. Both engines carry a comment
saying the integer width is load-bearing, both were unit-tested on their
own side, and nothing checked that they agree, while a mixed
iPhone-and-Android group poll is the ordinary case. A disagreement
surfaces as a wrong count rather than as an error.
Reads test/vectors/poll-tally-v1.json from flamenet-e2e, the same file
the Kotlin engine reads. Three checks: the fixture loaded and contains
at least one case that distinguishes the widths, every pinned tally is
what this engine computes, and the cases marked as needing wrapping
really do need it, verified by adding them up in Int and requiring the
WRONG answer. The last one pins the reason rather than only the result:
if it ever stops holding, the vectors have stopped testing what they
were made for.
936 XCTest, 410 Swift Testing in 84 suites, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two row-2 findings under claim E2, "nothing that identifies a contact or
a conversation is written outside the sealed store".
The parental PIN was salted and then hashed with ONE round of SHA-256,
with both the hash and the salt in UserDefaults. The salt stopped a
rainbow table and nothing else: four digits is ten thousand candidates
and a single SHA-256 is nanoseconds, so whoever held the hash held the
PIN, and UserDefaults rides iTunes and iCloud backups, so holding the
hash never required holding the phone. It now stretches with
FlamenetAccountKDF's PBKDF2-SHA256 at 650,000 iterations, the same
function the account passphrase already uses, and lives in the Keychain
under AfterFirstUnlockThisDeviceOnly. Verification is constant-time.
An existing PIN still opens, and is rewritten in the new format the
first time the PIN is in hand, which is the only moment it can be: the
old value cannot be upgraded without it. The gates are restored as the
parent left them rather than as `enable` sets them, because `enable`
turns everything on and a parent who had released the calls gate would
find it back.
Mute flags were keyed by the conversation itself:
settings.muted.buddy.4211, settings.muted.group.<gid>, and
settings.muted.address.<fnid@relay>. The backup therefore carried a list
of contact ids, group ids and full cross-relay addresses in the clear.
Moving them into the sealed store would have broken them: the flag is
read while deciding whether to raise a notification, and a push can
arrive with the app cold and the history not yet open. So the VALUE
stays in UserDefaults and the KEY becomes an HMAC-SHA256 under a device
key kept in the Keychain and not in the backup. A backup now carries
opaque keys and no key to read them with; what it reveals is how many
conversations are muted and nothing about which. MuteKeys.migrate runs
once at launch and rewrites existing flags, including the mention-break
flags that are keyed off them.
A Keychain that cannot answer reads as NOT muted, deliberately. The
other default silently stops a call ringing.
One of my own tests was deleted rather than kept. It asserted that
feeding the old digest as the PIN fails, and putting the one-round
scheme back made it PASS: that digest is not the PIN under either
scheme, so it proved nothing while reading as though it proved the
stretch. The cost test is what distinguishes them and it failed against
the mutant as it should.
MentionsTests lost its assertion that the key equals
"settings.muted.buddy.7". Its comment was right that changing the key
loses every existing mute, and that concern is answered by the
migration rather than by keeping a name that was the defect.
936 XCTest, 407 Swift Testing in 83 suites, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F2, and SPEC section 14.5: "Clients SHOULD surface which mode a
conversation is using, and a user who has enabled sealed sending SHOULD
be told when a message could not be sent sealed."
A sealed send falls back to an identified one when the recipient's
cached delivery key has gone stale. The message is still encrypted per
device; what changes is that the relay is told which account and which
device sent it. SealingRecord counted that from the beginning and
Privacy Settings showed the running share, which answers "how often"
and never "did this one". Somebody who believed a particular message
went sealed had to go and read a counter to find out it had not, and a
counter cannot say which message.
The conversation now says so, once, on that conversation. In memory
rather than in the sealed store: the fallback is a fact about a moment,
the notice exists to be seen at that moment, and persisting it would
mean a new per-contact record to answer a question the running total
already answers across restarts. It reads as a note rather than an
alarm, because nothing is wrong and the usual cause is the other person
reinstalling.
F5, the half that does not need the salt moved. The account identity is
PBKDF2(passphrase, salt) expanded, and the salt is held by the account
service, handed back at every sign-in, and never written to the device.
So that service can serve a different salt: the client derives a
different account key, a different fnid and a different authSecret, and
the same service verifies the result. Sign-in succeeds and the person is
now a different identity.
Nothing compared the two. savedMyAddress() was read to validate wipe
orders and to display, and the newly derived address overwrote it
without a glance, so the substitution left no trace on the device at
all. Silence is the whole value of that attack. It is compared before it
is saved now, and a difference surfaces in Profile beside the address it
replaced, naming both the ordinary cause and the other one.
What this does NOT fix, and the register says so: the salt still lives
on one service, and losing that service still loses the identity. That
is custody, and custody is a design change.
One of my own tests failed and was right to. It searched the whole of
ChatView for words the notice must not use, and ChatView already
carries "Flamenet won't send messages unencrypted... in the clear" in a
different notice, where those are the correct words. A search for
forbidden wording has to know whose wording it is reading. Bounded to
the notice now.
934 XCTest, 402 Swift Testing in 82 suites, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F9. KeychainE2EStore had two lists of the same accounts and only one was
maintained. wipe() named thirteen; migrateAccessibilityIfNeeded() named
the four it was written with and never grew. Accessibility is fixed at
write time, so an item an older build stored keeps its class for ever
unless something rewrites it, and that migration is the only thing that
does. Nine accounts sat outside the one mechanism that exists to stop
them being backup-eligible.
What it cost was small, and by luck rather than design: of the nine,
only device_id predates the 2026-08-11 switch to ThisDeviceOnly, and a
device id opens nothing. history_key, which unlocks the entire
conversation history, was introduced two weeks AFTER the switch. Two
weeks the other way and this would have been an at-rest break instead
of a tidiness finding.
So: one list, fixedAccounts, read by both. Rewriting an item that is
already ThisDeviceOnly costs one read and one write, once per install,
which is the price of not having to decide per account whether it is
old enough to need this and getting it wrong silently.
OneListOfKeychainAccountsTests keeps it one list and fails if either
caller grows a literal of its own. Falsified by restoring the old
four-name array.
F11. UITests/ holds 83 tests and the pipeline ran none of them: the iOS
job passes -only-testing:FlamenetMessengerTests and stops. 69 of the 83
gate on a live relay and skip cleanly without one, by design. The other
14 need nothing and were being left on the floor.
The new ui-tests job runs the target and gates on the log rather than
on grep's exit status, for the reason unit-tests already spells out. Its
floor counts what ACTUALLY ran rather than what was scheduled, because
every one of these can skip and "0 executed, 0 failures" is a perfectly
green log for a target that did nothing. The arithmetic is checked
against all three shapes of the summary line; an earlier draft would
have failed a run with nothing skipped, for being healthy.
It does NOT gate yet, and the flag is the honest part. The case for
gating rested on an estimate of "about three and a half minutes, one
flaky test". Measured: on an idle machine, 211 seconds, 14 ran, zero
failures, which is the estimate. On the same machine at a load average
of 115, nineteen minutes and three failures, every one of them
app.launch() timing out with "Error getting main window", two in tests
nothing had touched. That is the host, and CI shares it. allow_failure
comes off when a run of clean results on an idle runner says it can.
One real flake was found and fixed on the way.
FilesVaultUITests.testAFileCanBeDeletedOnItsOwn failed one run in three
by swiping a row the list had not finished settling. A swipe that lands
early does nothing and leaves no trace, so waiting longer cannot help:
nothing re-sends the gesture. It waits for the row now, and retries the
swipe once.
Unit target: 934 XCTest, 392 Swift Testing in 80 suites, no failures.
UI target on an idle machine: 83 tests, 69 skipped, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claim E1, from the landing page: "Messages are locked on your phone."
HistoryAtRestTests proves the file does not open under a different key,
which is a weaker statement than the sentence makes: a store that sealed
the message bodies and wrote the participants into a plaintext header
would pass it. MessagesAreLockedOnYourPhoneTests asks what somebody
holding the file actually sees. It saves a snapshot carrying a message,
a contact handle and a display name, then searches the raw bytes of the
file the store actually wrote for all three.
Two controls, because every assertion in it is a search for ABSENCE and
a search of nothing always succeeds: the file must exist and be of a
plausible size, and the byte search must find the same string when it IS
present. Falsified by writing the snapshot unsealed, which trips all
three needles.
It also pins KeychainE2EStore.accessibility, which is why the constant
is no longer private. The whole answer to a stolen backup is that the
history file rides it and the key does not, and that rests on
ThisDeviceOnly and nothing else.
Claim F4: a call moved between devices produces one log entry, not two.
The peer's half is covered, recordCall files nothing new for a call id
it has already filed. The half that makes the peer see one call at all
is that the device TAKING the call re-offers under the ORIGINAL call id,
and that is one argument on one line of CallManager.takeHandoff. Mint a
new id there and all eleven tests in HandingACallOverTests still pass:
they encode a signal with a hardcoded "c1" and check it survives the
round trip, so they test the encoder rather than the manager's choice.
The person handing the call over sees nothing wrong either, because
their own log is right. Only the peer gets two rows.
AMovedCallIsStillOneCallTests pins it, with a control for the source
being read, and states the rule twice so that renaming an argument label
cannot quietly retire the check.
Unit target: 934 XCTest and 388 Swift Testing in 79 suites, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay separates a device key replacement the old key signed for,
`rotate`, from one that arrived with no signature at all,
`rotate_unproven`, and keeps them apart in the log on purpose.
`IdentityRotation.report` says why: "a client watching the log can tell
this apart from a rotation the previous key vouched for."
No client could. Both kinds fell to `default` here and read "Their key
set changed", the same sentence a routine change gets, so the
distinction the relay records reached nobody. The unproven case is the
one that matters: it is exactly what somebody who cannot sign with the
old key would produce.
Both kinds now name themselves, and the unproven one carries a warning
that says what to do about it rather than only that something happened.
The warning is a separate sentence after the date, so the clause naming
the day stays intact and the existing exact-string tests are unchanged.
Four new tests. One is the control: a test that only checked the
unproven wording would pass if `rotate` were given the identical
warning, so the two are asserted to differ.
Android carries the same change, with a test pinning the two sentences
to each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five flows wrote a plaintext copy of something encrypted everywhere
else into the temporary directory, and not one of them removed it: a
story saved to Photos, a story played back, a picked video, the
transcode of one, and every file opened from the vault. Nothing swept
them either, so `eraseLocalData` erased the history and the keys and
left the pictures. Stories were the sharpest case, the viewer treating
them as ephemeral while the media outlived them by an unbounded margin.
`VoiceRecorder` already had the answer, removing its file on teardown
and sweeping stranded ones on init, with a comment naming this exact
risk. Five sites did not follow it. Android deletes in MediaScrub,
VoiceNote and StoryMedia and sweeps orphaned attachments off the poll
loop, so this was the iOS half of a problem the other app had solved.
`TempMedia` is one swept directory rather than a filename prefix. The
Files browser writes under the name the person filed the file under, and
a prefix sweep would be deciding from a filename whether somebody else's
temporary file is ours to delete.
Per-site discards where the lifetime is unambiguous, sweep at launch and
in `eraseLocalData` as the floor for a kill mid-flow. The transcode is
deliberately sweep-only: it becomes `previewURL` and the composer plays
it until dismissed, so discarding it there would break the preview.
Seven tests, including a control proving the sweep does not reach
outside its own directory. Three mutations, three dead: dropping the
sweep from erase, making discard a no-op, and pointing the sweep at the
whole temporary directory each fail exactly the test that should catch
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The em dash was the last one in Swift outside a markdown table, and it
reads out when the test fails, so it is text somebody sees.
The bold was the only one left inside a code comment in any of the four
repositories, in the sentence naming the dialog title the bug produced.
Quotation marks say the same thing and are what the rest of the file
uses.
Counted while here: zero bold inside comments across all four now, and
the em dashes that remain are markdown table cells, which are reference
content rather than prose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An outbox entry carries `to`, `body` and `wire`: the message text and the
encoded content envelope. That is the conversation, not metadata about
it, and it was passed to the debounced save and the flush raw. A send
queues there when the recipient has no registered device, so an
off-the-record message ended up in the sealed file and came back at the
next launch. §6.28 says it lives in memory while the app is open and is
gone when it closes.
`persistableOutbox` sits with the other two filters and is applied at
both save sites. `currentHistorySnapshot`, which feeds the backup, has
always passed an empty outbox, so backups were never carrying these.
Dropping a queued send across a restart is the promise rather than the
cost of keeping it: the thread is not restored either, so a message that
survived would arrive into a conversation the app no longer has. It
still retries for as long as the app is open.
Falsified: with the filter returning its input, three of the four tests
go red, including the one that asserts the dropped entry's text is not
among what is kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`unreadPeers` is `[Int: Int]`, a count per peer, incremented in
`deliver` for every message that arrives with no off-the-record check,
and passed to all three save sites raw. An off-the-record conversation
left the peer's id and a running count in the sealed file, and it came
back on the next launch. §6.28 says the conversation lives in memory
while the app is open and is gone when it closes.
Same fault as the drafts and pins one, on a dictionary keyed by user id
rather than by `u:<id>`, so `persistableKeyed`'s prefix check never
looked at it. `persistableByUser` sits beside it and is applied at the
debounced save, the flush and the sign-out write.
Filtering costs nothing here. The badge it feeds is read from memory
during the session, and an off-the-record thread is not restored at
launch anyway, so there is nothing for the saved count to badge.
Falsified: with the filter returning its input unchanged, the two tests
that assert an off-the-record peer is dropped go red and the two that
assert nothing else is touched stay green.
Left alone deliberately: `heldEnvelopes` is keyed by sender and also
unfiltered, but it holds ciphertext for a peer whose identity change has
not been accepted, and dropping it would lose exactly the messages that
file exists to keep. That is a decision about which promise wins, not a
missing filter. `addrThreads` already carries its own note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seen on a TestFlight build on 2026-09-21: reporting a message raised a
dialog titled "Files". The report itself had worked. It was filed on the
relay, emailed, and delivered; the only thing wrong was what the screen
called it.
Two places, one shortcut. A piece of state was named and titled for the
first outcome that used it, then reused for others.
ChatView had `.alert("Files", ...)` over a bare `saveNotice: String?`.
That heading was written when saving an attachment was the only thing
that went through it. Six outcomes do now, so reporting a message,
blocking somebody, and failing to send a file all announced themselves
as "Files". The notice now carries its own title and each outcome
supplies one.
GroupChatView put "Reported to whoever runs this server." and
"<name> blocked." into `errorMessage`, which renders in a red
ErrorBanner. Both of those SUCCEEDED. They now go to a `NoticeBanner` in
a colour that does not claim something went wrong, and it takes itself
away after four seconds rather than sitting there until the person
happens to send something, which is right for a failure and wrong for
this.
The tests read the source. What is being asserted is a literal that a
later edit can quietly reintroduce, and a rendering test would not notice
a title that is merely wrong. Putting either bug back turns three of the
five red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-09-08 sweep took the em dashes in these repos to zero. Twelve
were back here, and the four outside markdown tables are now commas or
colons: three in README and one in a test's doc comment.
The other eight are table cells in DESIGN.md, SCOPING-v1.md and
docs/BUGS-2026-09-01.md, and they stay. A dash in a table cell is
typography, and in several of them it IS the content: the empty-cell
marker in a comparison row. Rewriting those to satisfy a prose rule
would damage the table to fix nothing a reader would notice.
Measured across all four public repos while checking: emoji 0,
comment density 18 to 26 percent of non-blank source lines, down from
the 30 to 38 percent recorded before the September sweep. The structure
is in good shape; this was the residue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files was folded into the messenger, so the account service issues
pseudonyms for one product and a delete can no longer half-succeed.
The rule this branch was built around, keep the shared account alive
while Flamenet Files still holds an identity, can never fire now.
`deleteAccount` returns nothing instead of a flag saying which of two
outcomes happened, `AccountDeletion` loses its `sharedAccountKept` case,
and the sheet no longer offers to explain that somebody's Flamenet
account survived because Files was using it. The endpoint half is in
flamenet-files!10.
What stays is what was right for other reasons: the passphrase is proven
before anything is deleted, so a typo costs nothing; the relay goes
first, because reversed a failure in between leaves an account nobody can
finish deleting; and `sharedAccountFailed` still tells somebody plainly
that their Flamenet account is standing when it is.
Copy: the sheet said the Flamenet account is the one you also sign into
Flamenet Files with. It says it works across relays, which is the thing
that is actually true and the only reason the shared service exists.
Packages/FlamenetFiles stays. It is the engine that was rolled in, and
flamenet-e2e's interop build reads Label.swift out of it.
Full suite: 927 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same gap Android had, closed the same way. Deleting an account
removed the relay's copy and stopped there. On a relay using the shared
accounts that is half the account: the row at accounts.flamenet.io
stayed, the handle stayed taken, and the person stayed registered with a
service they believed they had left.
Found on 2026-09-21 by creating an account against production and
deleting it. The relay row went; the account service row had to be
removed by hand.
The endpoint is new, in flamenet-files, and has to be deployed before a
build carrying this ships or the second call answers 404.
Three things decide the shape, and they are the Android ones:
The passphrase is proven FIRST, by signing in, and nothing is deleted
until it is. A typo is the likeliest thing to go wrong, and finding out
after the relay call would mean the messenger account is already gone,
the Flamenet account is not, and the person has been signed out of the
app that was offering to remove either. `deleteAccount` therefore throws
only when NOTHING was deleted; a returned value always means the relay
account is gone.
Then the relay, then the account service. Reversed, a failure in between
leaves an account that can no longer sign in, because a shared account
signs in AT the account service, and so an account nobody can ever finish
deleting.
A shared account that Flamenet Files still holds an identity on is kept
on purpose, and the person is told so rather than being told their
account is gone when it is not. `notice(for:)` is pure so that wording
can be pinned by a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
afd0e4f..3564030 is three commits touching SELF-HOSTING.md and docs/SPEC.md
and nothing else. The six vector files are byte identical across the range, so
the gate cannot move; verified by running it locally against both ends.
What it buys: the spec this repo pins no longer says sealed sender is DRAFT /
proposed while section 15, and the published security page, both record it
wired end to end on the server, the web client, iOS and Android.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The retry test polled 40 times at 50ms and then asserted, so it had a
two second budget for a fire-and-forget task. This suite runs on the
machine that is also the mac-local runner; at load 17 the background
task got none of it and main went red on a version bump. Poll to a
30 second deadline instead. A healthy run leaves in 0.2s, faster than
before, because the granularity is now 20ms.
Falsified: with the note never written, the test fails at 30.7s with
"the relay was never asked to revoke the dead device". The deadline is
real and the assertion still bites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything since 0.28.0 went to Apple: held envelopes replayed when a
peer's keys are finally accepted, so a reinstall no longer loses the
messages sent in between; remote erase for a lost phone, both
platforms; group and multi-picture stories; the blocked list no longer
emptied by a failed fetch; screenshot notices shown rather than only
received.
project.yml is the only place the version lives. The .xcodeproj is
gitignored and regenerated, which is how the repo once said 1.0 while
TestFlight had 0.11.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were built and neither was reachable.
§6.24, group stories. The receive side has been complete for a long
time -- `.groupStory` is routed ahead of `.mediaStory` in ingest, with
a comment saying that ordering is what stops a group story being filed
as the author's own -- and `postGroupStory` had no callers, so nothing
could ever produce one. Both composers offer the groups this account is
in as destinations now: the Stories tab's button and the Home strip,
because a feature wired to one of them is a feature half the people
never find.
A group REPLACES the audience rather than filtering it. Running a story
audience over a group's roster would quietly drop the members somebody
excluded from their contact stories, which is not what posting to a
group means, and the composer says so where the audience picker would
otherwise be.
§6.23, a sequence posted as one story. Every piece of this existed and
none of it was connected: `postStorySet` had no callers so a set could
not be made, and `grouped` and `setExpiry` had none outside their own
tests, so a set that arrived from elsewhere was drawn as N separate
stories from the same person, one after another. The model carried
`set` the whole time.
The feed groups through `E2EInbox.grouped` and the card says how many
pictures it stands for. The composer takes up to ten pictures, shows
them in the order they will be seen, and puts the caption on the first
only: a sequence has one thing to say and repeating it under every
picture reads as a stutter.
Ten, not unbounded: each item is sealed, uploaded and sent to every
recipient separately, so a set of forty is forty uploads and forty
fan-outs, and the person waiting has no way to tell it from a stuck app.
921 XCTest and 376 swift-testing, green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bug hunt listed it among the dead functions with the note "keep or
document", and it is the one on that list that must not be wired up.
The protocol pin exists so a peer cannot be talked DOWN to an older
version. Anything on a screen that clears it is a button for undoing
that protection, and whoever tapped it would not be the person who
wanted it undone. It is a debugger's hatch and the comment now says so,
along with the condition for actually deleting it: when the protocol
stops having a version to be pinned to, not when a sweep notices it is
unreferenced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The iOS pipeline has failed at checkout since 2026-09-18. Not a test,
not a compile: `git submodule update` could not fetch
38a2af32, because four commits in flamenet-elligator2 were
made locally and never pushed. Every run since said
fatal: remote error: upload-pack: not our ref 38a2af32...
and every run since has been red. Three pipelines, two days, nobody
looked. That is the failure mode PUSHED-IS-NOT-GREEN is about, and the
only reason it surfaced now is that a push today was read by API
instead of assumed.
The four commits are de-dashing: comments, README, NOTICE, the
Dockerfile and the CI file. No logic. Pushed after running the
submodule's own suite -- 68 of 73 pass and the 5 that do not are the
lyrebird interop cases, which need a binary CI supplies and a laptop
does not.
Merged rather than rebased on purpose: the remote had moved on by two
commits, and a rebase would have rewritten 38a2af32 out of existence
and left this pointer dangling all over again. A merge keeps it
reachable, so this bump is only the pointer moving forward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay has served a wipe order since it was written and iOS has had
the DeviceWipe type and its validator just as long. Nothing ever
fetched an order or called the validator, and Android had none of it,
so a lost or stolen phone could not be erased although the code to do
it existed on both ends. It was the first finding of the bug hunt,
ordered by consequence to a person holding the phone.
The receiving half: the poll asks the relay once a session whether
this device has been told to erase itself, and feeds whatever comes
back to the validator rather than to the disk. A relay that carries no
orders answers 501 and one too old to know the route answers 404; both
mean "nothing waiting" and neither is worth a word.
The issuing half is on the Devices screen and asks for the passphrase,
because the account key is derived from it and is stored nowhere. That
is the whole security of this: the relay cannot mint an order, and
neither can somebody holding an unlocked phone. A passphrase that does
not derive this account's published address is refused here rather than
leaving an order on the relay that every device would ignore.
The screen shows DeviceWipe.honestDescription rather than a promise. An
erase happens the next time that device connects, and if it never
connects nothing happens at all; the sentence saying so existed with
nowhere to appear.
A cross-platform vector pins the two implementations together: a
signature Kotlin produced verifies here, and the canonical string
"fnwipe:v1:<device>:<seconds>" is asserted as a literal on both sides.
Not a signature comparison -- CryptoKit's Ed25519 signing is randomised
and Kotlin's is not, so the two never sign the same bytes the same way.
That test was written the wrong way round first and said so loudly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own documentation says a check that only runs when remembered stops
running, and this one had: it spent long enough reporting four HIGHs
that were all wrong that reading it was a waste of a minute, which is
exactly the state SCRUB.md warns about two paragraphs further down.
All three repositories are public, so the job clones the two siblings
with no credentials, the same way vectors-head already takes
flamenet-e2e. Shallow, because the scrub reads source and not history.
Verified by running the job's exact command against fresh clones of
published main: 0 findings, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own docstring says a scrub that cries wolf gets ignored, and that
this is worse than no scrub. It reported four HIGHs on every run --
delivery_key, poll, pshare and psum, "iOS has no branch for it, it is
swallowed silently" -- and iOS handles all four.
It knew two of the four shapes iOS uses to decide what an envelope is.
It now knows all four: a comparison on `.kind` or on the control-kind
accessor E2EEnvelopeRoute uses, the same through a decoded dictionary,
membership of a literal list (which is how the poll family is gated),
and the arms of a switch over a kind, bounded so an unrelated string
switch cannot grant parsing.
iOS parses 21 kinds by this reckoning, not 11. The run is clean.
Checked by hand before changing it, rather than assuming the tool was
wrong: E2EEnvelopeRoute routes delivery_key at :138, and
E2EAttachment gates the poll family at :499 and switches on it at :505.
And checked the other way after: renaming each handler in turn brings
its kind straight back as a HIGH, one shape at a time. A scrub that is
quiet because it stopped looking is the failure this was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ios#16 puts a sentence on the Privacy screen saying what share of your
messages the server could attribute to you. It is computed from
SealingRecord, and one send path never reached it.
A cross-relay send is always sealed -- transmitRemote seals
unconditionally and says so in a comment -- and it set
lastSendWasSealed instead, a published property nothing had read since
SealingRecord replaced it. SealingRecord's own call site three hundred
lines down is commented "every send's outcome, not just the most recent
one", which is precisely the replacement, and this path was left on the
old one.
So somebody whose contacts are mostly on other relays saw a share
computed from a fraction of what they had sent. One unsealed local send
among forty sealed federated ones reported 100%. The number was wrong
in the direction that would make a person change how they use the app.
lastSendWasSealed goes with it: four writes, no readers, and a fifth
path setting it instead of recording would be this bug again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
§6.27. This device posts a notice whenever iOS reports a screenshot in
a conversation that has a timer on it, and has done since the feature
landed -- it is in the build on the App Store. The receiving half put
every notice it was handed into a published array that no view read.
So one iPhone told the other and the other showed nothing. That is
worse than not having the feature: somebody who knows the app has it
reasonably concludes nobody screenshotted them, and the app is the only
thing that could have said otherwise.
There is a banner in the conversation now, scoped to that contact,
showing the most recent notice. It is OBSERVED rather than read through
the environment, because the notice arrives while the thread is already
open, which is the point of it.
And the disclaimer appears. It was written to say exactly what a notice
cannot mean -- another phone photographing the screen, a screen
recorder, a modified app, none of which leave a trace -- and it existed
with nowhere to be shown, which is the sentence that keeps the feature
from being a false promise. A test pins the three cases it names.
Android is a separate question and deliberately untouched: it neither
sends nor shows one, and treats an arriving notice as control traffic,
which is at least consistent with itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app adds people to the contact list on the user's behalf, and has
to: the list is one-directional and sealed sender means the relay
cannot annotate anything, so somebody who adds you and writes puts
nothing in your list. Their thread is the only trace.
Which makes the block check the only thing between that mechanism and
the app undoing a block by itself. It read the list as
`(try? await api.fetchBlocked()) ?? []`, so a request that failed -- a
timeout, a flaky connection, a 500 -- produced an EMPTY set of blocked
people, every stranger passed the check, and somebody the user had
blocked was added back to their contacts by the app. Silently, and
they would have to notice to undo it.
It gives up on the pass instead. That costs nothing: the whole thing is
idempotent and runs again on the next message, the next tab switch and
the next launch, which the comment two lines above already said about
the other failures in here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay has minted invite codes since relay#43; the app had no door to
them. Account now has "Invite a friend": it asks the relay for a code,
shows it large, and shares flamenetmessenger.com/join/<code>, the page
that shows who sent it, the store buttons and the code to type if sign-up
asks for one. A code read down a phone still works; the link is the same
thing with fewer steps.
SiteConfig.inviteURL repairs what a person did to a code on the way
(lowercase, spaces, a lost dash) so the link carries what the relay
hashed. MessengerAPI.mintInvite posts to account/vouch on the relay root
with the app credential; a 429 surfaces as the relay's own sentence.
Five tests: the address, the repair, the refusal of non-codes, the request
shape, and the 429. The project file is generated, so the two new files
are picked up by Scripts/generate.sh.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When a contact reinstalls, every message they send before the recipient
taps "Accept new keys" was destroyed, silently, on both ends. Observed
live on 2026-09-18: Android reinstalls and registers a new device key,
sends three messages, iOS fetches them, and the fetch acks -- so the
relay marks them delivered and forgets them. Ingest then refuses them on
the gate that says a session may not be built on an identity the person
holding the phone has not accepted, and dropped them. Accepting the
banner brought nothing back because there was nothing left anywhere to
bring. The sender saw three delivered messages; the recipient saw an
empty thread under a security warning; the two never meet.
The gate is right and stays. The discard is what was wrong.
A refused envelope is now set aside as it arrived, still ciphertext,
sealed with the history so it survives the phone being closed -- which
is the ordinary way this happens. Accepting the identity replays the
hold, oldest first, through ingest rather than the de-duplicating
wrapper: the envelope ids were recorded as seen on the way in, so
ingestIfNew would refuse every one and deliver nothing while reporting
success.
Holding costs no secrecy: there is no session until the identity is
accepted, which is the whole point. It is bounded at a hundred per
sender and thirty days, because an unbounded hold is a way for somebody
who will never be accepted to keep writing, and blocking or removing a
contact drops what is held for them rather than keeping a removed
contact's messages.
The banner says what is waiting, which nothing did. An empty thread
under a warning reads as "they have not written".
Not carried by the backup, and it could not be: a held envelope is
addressed to this device's prekeys, which live in the keychain and do
not travel, so on a restored phone it would be undecryptable for ever.
BackupCarriesEveryFieldTests catches exactly that omission and now
records the reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each verified by bare-word grep across Sources, Tests and UITests, not
by a counter: framework delegate callbacks and functions passed by
reference (action: confirmDelete, .onSubmit(addArea)) show zero call
sites and are not dead. PreviewChain.answering looked dead to a grep
for "answering(" and is called with a trailing closure, which is the
kind of thing that makes a scan like this worth checking twice.
resetConfiguration SignInView switches servers via applySiteIfChanged
reportOutgoing superseded by reportOutgoingStarted / Connected
clearSendFailure
resolveIdentity a client wrapper with no caller
canSeal / canSealTo the send path reads store.peerDeliveryKey directly
isCallSignal
flamenetSectionLabel superseded by PortalBand
settingsRow
acceptCorrespondent duplicated by ChatView.acceptBuddy
Removing noteRefusalUnrepaired left unrepairedRefusals incremented by
nobody and noteRefusalRepaired resetting a counter that never moved, so
those went too. That escalation is not lost: AppEnvironment's
noteCredentialRefused counts the same three refusals in a row and runs
the same RelayRepair, and it is the one that is wired.
postStorySet (§6.23) and postGroupStory (§6.24) are unreferenced by the
same measure and are staying. They are implementations of spec features
that were never given a screen, so taking them out drops the feature
rather than tidying up after it; that is a decision to make on purpose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ios#19 pins the key the relay signs with on first contact, so that a
server quietly swapping it is something this device can notice. It did
notice. It set a flag, stored the flag, and showed nobody: no view read
either half of the pair, and acceptRelayIdentityChange, the way back
out, had no callers at all. The one case the pin exists to catch
resolved to a boolean in UserDefaults.
There is a banner now, on Messages and on Home, saying what changed and
what both readings of it mean, with the accept behind a confirmation
that tells you to ask the server's operator on a channel this app does
not carry.
The warning also survives a relaunch. The store always kept it and the
published property was born false, so a warning raised on Monday was off
the screen on Tuesday. The check does raise it again, but only once a
session and only if the descriptor fetch succeeds, so a phone that
starts up offline said nothing about a change it already knew about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A per-contact presence token is a capability: whoever holds one can ask
the relay whether this account is online, and the relay answers for as
long as the published digest set still carries it. Dropping somebody from
the roster never touched that set, so a blocked or removed contact went
on watching this account come and go, which is the part of a block people
actually notice.
Both paths now forget the pair and republish the set without its digest,
which is the only revocation the design has. Block does it before the
delivery-token revoke, since it needs no network of its own beyond that
publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was the one square left in a composition of two concentric circles
and a row of round controls, so it read as the piece that had not been
finished rather than as the squared house style.
Nothing else on that screen changes. The local video preview stays a
rectangle, because video is one; the plate only ever draws initials, so
there is no photo to re-clip.
856 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The restyle squared 37 shapes and left the call screen's two animated
rings alone, because the kit draws a ring there on purpose. The controls
kept the squared face, so they sat inside those circles looking like
they had come from a different screen. A call's controls are round
everywhere a person has ever answered a phone, and this screen already
had the circles for them to sit in.
Only the controls. The avatar plate in the middle of the rings is still
squared, so it is now the one square left in that composition; that is a
separate call to make and it was not the one asked for.
The rest of the app stays squared: this is the second deliberate
exception, after the message bubbles, and both say so where they are.
856 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The double row is no longer a guess. A call that connected is filed, the
offer for it is seen a second time, and by then it is older than the
caller's own patience, so the stale-offer branch files it as missed.
`ACallIsFiledOnceTests` drives exactly that through `onCallSignal` and
needs no WebRTC to do it: with the merge removed it produces two rows
and the survivor says missed for a call that connected, which is the
screen the owner photographed.
`endedCallIDs` would have dropped the second sight of that offer, but it
is memory only and bounded to 200, so a fresh CallManager -- which is
what a relaunch produces -- starts with none of it. What makes the offer
arrive twice is still not established; what it does when it arrives now
is.
The second case is the one that keeps the fix honest: a stale offer for
a call this device never had is still a missed call, and is still filed.
AColdLaunchPushIsNotDropped flaked because seven suites reach for
`VoIPCallReceiver.shared` and six of them are swift-testing ones that
run in PARALLEL with this XCTest class. They all assign the same `wake`,
and the replay `pendingWake` drives is consumed by whichever assignment
lands first, so a case could watch its own signal be taken by another
suite and fail having done nothing wrong. Raising the timeout would have
hidden that rather than fixed it. `init` is no longer private and the
class owns an instance; nothing here was a singleton by necessity.
856 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design canvas is 375pt wide and nothing stopped a screen using all
1024 of an iPad's. A story became a band the width of the slab with its
caption alone at the far left and the unseen dot a thousand points from
the name; a conversation put one message hard against each edge with a
lake of empty between them, because the thread takes whatever width it
is given and the bubbles align to its sides.
Three screens had noticed and each capped itself at 440 for a form.
`portalReadableColumn` is that, said once, at 620 for content rather
than a form: a ceiling and a centring, so a phone is unaffected because
a phone is narrower than the ceiling.
`testStoriesWithBar` could not have caught this. It builds its own
composition out of a StoryCard, so it renders the same on a phone and
an iPad. The new case renders the feed the app actually shows. A test
inbox cannot be given a story -- `stories` is private(set) -- so the
live view only draws its empty state, and the card is rendered
separately in the column the feed puts it in. Checked at 2048x2732.
The settings and file lists are left alone: a row spanning the width is
what a list does on a tablet, and the two surfaces here are content.
854 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A call answered and talked on appeared twice on the Calls screen, once
received and once missed, on both phones.
Two paths file a call: the call ending, and the missed-call note for a
ring nothing answered. The log had no notion of "the same call" at all
-- rows are keyed on their timestamp -- so nothing stopped both from
running for one call and nothing could tell afterwards that they had.
Entries carry the call id now. A call already filed is not filed again,
and a connected row replaces a missed one for the same call rather than
joining it. The field is optional because rows written before this exist
and an old log still has to decode; two of those are still two calls.
This belongs in the log rather than in the guard that already exists for
it. `endedCallIDs` drops a signal for a call this process has finished,
but it is memory only and bounded to 200, so it forgets across a
relaunch and under churn. The call log is the only thing that remembers
a call afterwards, which makes it the only place the question "have we
already filed this one" can actually be answered.
What this does NOT do is explain why a second file happens at all.
Neither path that files a missed call should run for a call that
connected, and I have not reproduced it. The owner has since said the
two devices are separate accounts, which rules out the multi-device
fan-out I first suspected; the likeliest remaining cause is the offer
envelope being processed a second time after the call is over, across a
relaunch, where the in-memory guard is empty and the stale-offer branch
files a missed call. Not yet proven.
853 unit tests. The new case fails, with two rows, when the merge is
taken out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files, Wallet and Account kept the system large title while the other
four moved, which is the rest of "the headers are inconsistent": four
tabs drawn one way and three another. All seven wear the portal header
now. Account says "Account" rather than "Profile and Settings", because
a header that disagrees with the tab you tapped is one you read twice.
Converting Files turned up something worse than a header. It walks into
a folder by appending to its own `breadcrumb` rather than pushing, so
there is no system back button to inherit, and nothing in the app ever
removed from that breadcrumb: no back, no breadcrumb bar, no gesture.
Opening a folder was one way, and the only way out was leaving the tab.
So the header carries a back control when there is somewhere to go
back to, and the trail as its subtitle so you can see where you are.
`testAFolderInFilesCanBeLeft` makes a folder, opens it and comes back
out; it is the case that would have caught this.
850 unit tests, and the header test now walks all seven tabs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sweep. iOS 26 gives every ToolbarItem a shared glass background, a
translucent capsule. On this app's navy bars that is pale blue on blue,
barely there, and a capsule is the one shape this design never draws.
It also takes width, which had pushed a sheet title into "Report me...".
`sharedBackgroundVisibility(.hidden)` is the only way off it and it
lives on ToolbarContent, so it has to be said at every item rather than
once on the view. PortalToolbarItem says it once; 51 items across 27
files now go through it. Checked in both appearances and all three
states: white enabled, red destructive, grey disabled. Delete had to
state its colour on the button rather than around it, because `.tint`
does not beat a foreground style on a plain button and the sweep says
white for the navy bar.
Two more surfaces were the system's, and both were reported as buttons
you could not see:
- `.textFieldStyle(.roundedBorder)`, on six fields, is drawn as glass:
on a dark page an empty outline with nothing in it. The unlock screens
on Files and Wallet are mostly that field, which is why they looked
like a different app. They wear the portal's own chrome now.
- Disabled controls faded to 0.45 everywhere. Against a light page that
reads as dim; against near-black it reads as absent, so Files and
Wallet looked like they were missing the Unlock rather than waiting
for a passphrase. 0.62 in dark, unchanged in light.
And two defects from the phone:
- A call to a contact added a moment ago was logged as "user 123". The
Calls list looked the name up only in the roster array it was handed,
which a new contact is not in yet, and fell through to the number
while the inbox already knew who they were.
- "Network error: cancelled". A cancelled request is not a failed one:
it is a screen going away mid-fetch or a newer load superseding this
one. The catch-all turned it into `transport(...)` and the roster
loader put it on screen in red. Cancellation is reported as
cancellation now, and a superseded load says nothing.
OffTheRecordTests asked whether the words `offTheRecordPeers` appeared
in the view. They did, in the line that never fired, so it was green
while the echo could not reach the screen. It checks the subscription
now, scoped to the watcher, and fails when that is taken away.
850 unit tests. AColdLaunchPushIsNotDropped flaked once in this batch
and passed alone and on re-run; it is a timing race in a test around a
shared singleton, not a break, and it is worth fixing separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BrandMark is the square emblem the app icon and the favicons are cut
from, and that crop stops at y=528 because the wordmark's F begins at
530 and the flame passes behind it. There is no empty row between the
emblem and the word, so the rectangle takes a piece of the emblem: the
bottom edge cuts straight across the globe and both speech bubbles. On
an icon that flat edge passes for a base. At 72pt on the sign-in screen
it reads as a logo with its bottom sliced off, because it is one.
BrandLogo is the whole artwork, trimmed to its own alpha bounds rather
than to a rectangle somebody chose, so there is no cut edge in it. It
is generated by make-brand-assets.py beside everything else; a mark made
by hand is a mark that stops matching, which is how the old one got cut
in the first place.
Re-running that generator was a clean no-op for every existing asset in
all three repos, which is what rules out a stale export and puts the
remaining crop where it actually is: the master itself is cut at its
right edge, 44 rows of the flame's outer arc running flat into x=1098.
No export can restore pixels that were never in the file. That one needs
new artwork.
The taller logo exposed a second thing: the Form section was handing the
description two lines and truncating it at "whichever server...", which
is the sentence losing its point. It wraps now.
Also, room between a section band and the first row under it on
Messages, and under the header on the Calls list. Butted together, a
heading read as part of the first row rather than as the label for all
of them.
846 unit tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four tab roots wore four different headers. Home and Messages had a
system large title with a search drawer; Calls and Stories had a
`.navigationTitle` that reserved about 120pt of navy and drew no text
in it at all, so each was a tall coloured band with one button
floating in it. That is what "large and inconsistent, some have titles
and searches, others are just solid colours" was describing, and the
Calls screenshot shows it plainly.
PortalHeader is the counterpart of Android's, and the shape the store
screenshots have drawn all along: FLAMENET over the screen's own name,
the subtitle where one belongs, and the actions on the title's row
rather than above it. That last part is the alignment complaint: the
buttons sat on the toolbar row and the title on a line of its own
underneath, so they could not line up.
Search could not simply go with the title. `.searchable` lives in the
navigation bar's drawer, and typing a username into Home's box is how
somebody who is not already a contact is found at all, so
PortalSearchField takes it on the chrome. It carries
`.accessibilityAddTraits(.isSearchField)` because nine live UI suites
find that field with `app.searchFields`, which a plain TextField is
not. Without the trait all nine go red at once.
Two things the header exposed: the story button painted itself
`Theme.primary`, a navy on navy that read as switched off, and the
search prompt defaulted to the secondary LABEL colour, near-black on
the chrome. Both now take the header's white.
FNMSG_TAB opens the app on a given tab, because simctl can install and
launch but cannot tap, and these four screens had to be looked at.
PortalHeaderUITests covers the titles and both search fields. 846 unit
tests green; the new test passes and fails when the trait is removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The urgent-push rule, the vault key store and the fast-poll loop. Each
keeps the trap and the reason; each loses the retelling of the bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four more long blocks: the file-protection class, the roster-adoption
rule, the navy bar trap and the poll guarantee. Each keeps the reason
and the trap; each drops the account of the bug that produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six of the longest comments in Sources were essays: the fact, then the
history of the bug, then a closing line shaped like an aphorism. Each
keeps what it knew and loses the narration. E2EBackupPayload 26 to 18,
RelayRepair 23 to 15, E2EHistoryStore 23 to 15, AppDiagnostics 21 to 13,
the key-set classifier 12 to 7.
One was not only long. `restoreWhenUnlocked`'s doc had come unstuck and
run into `discardUnreadableHistory`'s, so a reader of the second met two
openings that contradicted each other ("the conversation is on screen"
then "stop waiting for a history that will not open") and
`restoreWhenUnlocked` had none at all. Split, and each is on its own
function again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagram labels keep theirs: inside an SVG <text> a dash is typography,
and changing the string shifts a middle-anchored label. Table rows in
BUGS-2026-09-01.md keep theirs for the same reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
History of a change belongs in the commit message. These comments
narrated their own corrections instead: "this used to run first", "this
used to live on the Home tab's view model", "which is what this used to
be". A reader without the commit history gets archaeology rather than a
description of the code in front of them, and the style is one of the
tells that gets a public repo dismissed unread.
Each one keeps the reason it carried, stated as the property it is
rather than the change it was. The Obfs4 submodule moves with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things this got wrong, each of which reported a green or a red
that was about the test rather than about the app.
Two elements carry `history.discardUnreadable`: SwiftUI puts the
identifier on the accessibility CONTAINER for the whole banner as well
as on the button inside it. The container is full width, 158pt tall and
reports itself hittable, so tapping "the first match" landed on the
banner's background and nothing happened, which reads exactly like a
dead button. The label matches only the real control.
The confirmation is a SwiftUI `.alert`, which is its own hierarchy, so
`app.buttons["Start fresh"]` found nothing at all.
And the earlier version printed FNMSG_IOS_HISTORY_OK whenever it could
not find the button, which it reported at the same moment a screenshot
showed the banner on screen. Absence is not proof. It checks for the
BANNER first and fails loudly if the banner is up and the way out cannot
be reached.
The story case now backgrounds the app before finishing. The history
write is debounced and XCUITest kills the app the instant a test ends,
so a story posted in the last second of a test could be gone from the
next launch for a reason no person would ever hit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three more, all driving the account already on the simulator:
* a call placed on the other platform rings this one, and declining it
clears the screen
* place a call at the other platform and hold it ringing, so the other
side can be watched while it does
* a group made on the other platform, with a message in it, arrives
The ring case needed a settle window and the reason is worth keeping.
`CallManager.finish` tells the caller through a detached, fire-and-forget
`Task`, and the call screen clears from local state the instant the
button is tapped. So the test could finish, and XCUITest kill the app,
before that task reached the relay: the caller then rang forever and it
looked like the product had lost the decline. It had not. With twenty
seconds of life after the tap the `call-end` reaches the other side in
about three. Worth remembering that the send is `try?`, so a real
network failure there strands the caller just as silently.
Both directions now connect: Android answering an iOS call reaches
`ice connection -> CONNECTED` and stays up until the caller ends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tests, all driving the account already on the simulator rather than
signing up a throwaway one:
* a story posted on the other platform reaches this one's feed
* post a story here for the other platform to go and look for
* discard a history this device can no longer open
Stories fan out per contact like a message does, but they land on a
different screen and are read by different code, so a thread that works
says nothing about whether the feed does.
The first two immediately found something. A story is visible in the
feed of the session that posted or received it, and gone from the next
launch: posted, confirmed on screen, relaunched thirty seconds later,
"No stories yet". Thirty seconds is not expiry, and the default lifetime
is a day, which is what the other platform shows for the same story. The
only place stories are dropped is the `isActive` filter on load, and
expiries are written with `Date()` and judged with
`DeviceClock.expirySeconds()`, which is not the same clock. Not yet
proven to be the cause; the loss is reproducible either way.
android-signin.sh hits trap 2 one field earlier than its own header
describes: filling the handle leaves the keyboard up, the form is inside
an imePadding(), so the scroll viewport ends above the passphrase field,
its label is never laid out, and it is absent from the accessibility
tree. "FAIL no passphrase field" on a screen that plainly has one.
KEYCODE_ESCAPE does not close the IME; KEYCODE_BACK does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A peer that reinstalls comes back with a new device key, so the thread
sits behind the safety-number check until somebody answers it. The test
waited ninety seconds at a banner nobody was going to tap and reported
"no reply arrived", which names the wrong thing: the reply had arrived,
the relay had marked it delivered, and the screen was the only place
that said so. It taps the banner the way a person would, and a failure
now prints the screen instead of a verdict.
That turned up something worth knowing. Envelopes collected BEFORE the
banner is accepted do not come back after it: ingest drops them at
guard recordTrust(userID: fromUser, offered: [aliceIK_sig]).isUsable
else { return Self.dropped("identity key not accepted for this sender", ...) }
and the fetch that carried them has already acked, so the relay is done
with them too. Three messages sent by a freshly reinstalled Android peer
were gone for good; the next message, sent after acceptance, arrived in
thirty-one seconds. The gate is right, discarding the envelope is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other send in CrossPlatformUITests signs up a throwaway handle
first, which proves the relay accepts a brand-new pair and nothing about
an account somebody actually carries. There was no way to drive a real
account from a test, because a real account's passphrase is not going to
be pasted into a test runner.
This one takes no credentials: it launches without -FNMSGResetState,
refuses to continue if that lands on the sign-in screen, and sends from
whatever session is on the device.
It earned its place immediately. Run against fnreviewalpha it failed,
and the screen dump carried the reason: "The server's key log no longer
matches what it published before." Six leaves had been deleted out of
the relay's transparency log, the log shrank, and every client holding a
pin above the new size refused to send to anyone. Nothing reached the
relay at all, so the envelope table looked like a client that had simply
gone quiet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment said a real iPhone had forty-two envelopes stacked up over
twenty hours while collecting none of them. That was wrong, and I wrote
it into the source and the commit message before checking.
`envelopes` keeps rows after delivery, because this relay does not delete
on ack; they sit until retention sweeps them. I counted the rows without
filtering on `delivered` and read a healthy phone as a stuck one. Split
properly that device was 42 delivered and 0 undelivered, which is a phone
working exactly as it should.
The fix it was offered as evidence for still stands on its own: a history
whose key is gone never opens, the refusal to write over it never lifts,
and before this there was no way out but restarting the phone and hoping.
That is reached from the code and from a simulator genuinely in the state.
The claim about a device in the wild is withdrawn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refusing to write over a sealed history that did not open is right, and on
its own it is a trap. If the key is genuinely gone the file never opens,
so the refusal never lifts: the app saves nothing, acknowledges nothing,
and the relay's queue for that device grows without end while the app
looks signed in and perfectly well.
That is not hypothetical. A real iPhone on this account sat with
forty-two envelopes stacked up over twenty hours, polling the whole time,
collecting none of them, while the only advice on screen was that
restarting the phone was "worth trying first". When that does not work
there was nothing else, and nobody is going to guess what happened.
So there is a way to stop waiting, and it is a person's decision rather
than something automatic: the other readings of an unopenable file, a
locked phone or a later build that can open it, both repair themselves,
and only someone who has waited can say it is time. The file is
unopenable by definition, so nothing legible is destroyed.
The test caught me out and is worth reading. It passed with the file
deliberately left behind, because `save()` writes over it and `load()`
reads that back happily, so it proved nothing about the removal. It asks
the question properly now: after letting go, and BEFORE anything is
saved, a store holding the key that COULD open the old file must find
nothing there. That version fails when the removal is taken out, and
passes when it is restored.
376 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It said "N of 3 registered". The cap is the relay's `FN_MAX_DEVICES`, not
the app's: production runs six and a self-hoster picks their own, so an
account at the limit read "6 of 3 registered" on the one screen somebody
goes to when they are being told they have too many devices.
It states the count it actually has. The relay publishes the cap on
/metadata if this ever wants to show it properly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Calls and Stories drew their own 24pt title inside the content and had no
NavigationStack at all, so they were the two tabs with no navy header on
them. Both have one now, with their action in the toolbar.
The tab strip ignored the bottom safe area for the whole bar rather than
just its background, so the icons and labels sat down in the home
indicator and in the display's rounded corners. The navy runs to the glass;
the row stays above it.
Two more came from the bar forcing the dark colour scheme so the system
draws light text on it. That flips the environment for everything in the
bar, so appearance-aware tokens answered for the wrong appearance:
The plus and scan buttons took `Theme.primary`, which resolves dark to
0x0A5C92, within a shade of the bar's own top stop. They looked switched
off and were perfectly live.
The search field took `Theme.surface`, which resolves dark to a near
black, so the same field was black on Messages and pale blue on Home.
Both state their colours now. The bar is navy in either appearance, so
anything sitting on it has to.
And the search placeholder came up empty until SwiftUI reapplied it a
moment later, which was my own doing: colouring the placeholder through
the appearance proxy sets its TEXT too, and I passed an empty string.
846 unit tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One list had two sections headed two different ways: Chats as a small
grey uppercase label, Groups as a `.headline`. Both are the portal's
ruled band now, with New chat and New group sitting on the band itself,
which is where Android already put them.
`SectionHeading` draws the band and takes the trailing action, so the
HStack-with-a-Spacer dance at each call site is gone.
846 unit tests green, and the shot run walks all seven tabs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `.searchable` placeholder was drawing its light-mode grey on the navy
bar, which read as an empty control rather than a prompt. An appearance
proxy does not reach SwiftUI's search field in any containment spelling,
so the screens that have one state the bar themselves.
Asking for the dark toolbar scheme alone made it worse and was backed
out first: the bar then dropped the background image the global
appearance gives it, went transparent, and left a white title on the pale
page ground. The background has to be supplied in the same breath, which
is what the conversation's maroon bar was already doing and why it never
hit this.
The Stories empty state kept `circle.dashed` after the tab icon moved off
it. Both are `photo.stack` now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`testNothingIsSnoozedByDefault` failed once on CI (pipeline 2859118649)
while the whole 846 test suite passes locally. The suite isolated itself
with a random user id out of 100_000 values, and
`testSnoozeIsPerConversation` leaves a snooze for conversation 42 behind,
so that isolation was odds rather than isolation. Clearing the rows this
suite writes, before and after each test, costs nothing and removes the
question.
It does NOT explain the failure, and the comment says so rather than
implying a fix. Pinning the id and running the suite twice in a row
passes both times, so rows are not surviving between runs and a collision
cannot be the cause. The failing job also logged a 600 second timeout
collecting diagnostics from the simulator, and reproducing this locally
crashed the test host before it finished bootstrapping. That points at
the runner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The iOS half of the 0.27 restyle, matching the Android commit.
PortalChrome.swift paints the system bars once at launch rather than per
screen: applied per screen it would be right on the ones somebody
remembered and stock on the rest, and the ones nobody remembers are
sheets and pushed views. iOS keeps NavigationStack rather than growing a
hand drawn header, because the bar carries large titles, searchable,
toolbars and swipe back, and replacing it would cost all of that to gain
a gradient.
The tab bar is drawn here, so it is ours: a full width navy strip ruled
along its top, the tab you are on marked in gold. It was a floating pill
inset by a margin, which is the one shape this design does not have.
Both button styles forced full width, so every control that sits beside
another control stayed on the system style. There are inline styles now,
and the 27 call sites on .bordered and .borderedProminent take them. The
segmented pickers become portal chips; a grey capsule with a sliding
white thumb is unmistakably stock iOS and it sat in the middle of the
settings screen contradicting everything around it.
37 round shapes are squared. The two the call screen animates are left
alone, because the kit draws a ring there on purpose.
Three defects found by looking at renders rather than at code:
BubbleShape defaulted to a 16pt radius, which kept a conversation
reading as a stock messenger however the colours moved.
The conversation bar runs toolbarColorScheme(.dark) so the system
draws light text on the maroon. That flips the environment for
everything inside it, so an appearance aware token answered with its
dark value and the avatar tile came out navy on a maroon bar. What
sits on that bar is stated now, not derived.
The tab cell draws its title as well as its icon, and a visible Text
beside an accessibilityLabel stops the cell being one element carrying
that label. VoiceOver read the badge and the name as separate stops and
buttons["Home"] stopped resolving.
Scripts/conversation-shot.sh could hand back last month's screenshot.
It never cleared build/conversation-shot/attachments, xcresulttool will
not export into a directory that already holds something, and the
refusal went to /dev/null. The extractor then found a conversation
attachment in the STALE manifest, copied it, printed a size and exited 0.
That is the picture on the front page of the website. store-shots.sh
already cleared its directory; this one never did.
AppStoreShotsUITests can capture on a throwaway account now. The release
set still wants a real handle, but looking at the screens is the commoner
reason to run it, and needing somebody's credentials meant that during a
restyle the only screen anybody saw was the sign-in screen, which is the
one screen the design work does not touch.
846 unit tests green, which is the baseline exactly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 30s wait for a recent-strip button that does not exist during
first-contact turned phase 1 into a dead wait and destabilized it, so the
gate got worse, not better. Reverted to the openChat that clears phases 1
and 2 and stops at the phase-3 composer. The per-side result bundle stays,
it fixed a real xcresult-collision crash.
Honest state: the gate runs the whole sign-out and sign-in sequence and
fails only re-opening the chat afterward, which is post-sign-in restore
timing in the harness, not an app defect. Messaging is proven green by
MessagingFeaturesUITests. Left here deliberately rather than chased
further.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more restorations, both the same shape as the rest, the app moved and
the harness did not:
- Phase 3 re-opens the chat with a contact whose buddy list was just
restored from the relay after signing back in. It is a `recent.<id>`
button in the Home strip now; the bare name label is not the hittable
element, and the strip item can report "not hittable" while present, so
tap the button, coordinate-tap if the frame says otherwise, and wait
for the buddy list to repopulate first.
- Each simulator writes its own result bundle. Both sides shared one
derived-data path, so their xcresult writes collided and crashed one
side with a CASTree fatal that had nothing to do with the test.
With the earlier five, the gate now builds, signs up, crosses a message,
reaches Account, signs out and back in, and re-opens the chat to run the
assertion it exists for: that a plain sign-out and sign-in is not seen as
a key change. Remaining flake is the post-sign-in restore timing on the
phone that only sleeps, not an app defect; the app's messaging is proven
green by MessagingFeaturesUITests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0.25.0 build 25 is on TestFlight, so the build number goes up, as the
rule in project.yml says: it tracks the release it belongs to and is
bumped once the previous one is uploaded, not when the next is archived.
Builds clean for the simulator at the new version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gate has been unrunnable since 2026-09-05. It exercises a real
sequence, two simulators, sign out, sign back in, and prove a contact was
never asked to accept new keys, and every step it touched had rotted with
the app underneath it:
- It signs up its own throwaway accounts, so it needs
TEST_RUNNER_FN_ALLOW_SIGNUP=1 (ios#62). It never set it and timed out on
refused sign-ups, the same break rounds.sh had.
- The two gate simulators are reused every run and carried the previous
run's signed-in account, so the sign-up screen never appeared. The one
initial launch resets state now; the in-session sign-out/sign-in it
checks is unaffected.
- openAccountTab is shared with the UI suites and now pops out of a chat
first, because the floating tab bar is hidden inside a pushed detail.
- The sign-out confirm is a confirmationDialog button that matches twice
on this runtime; .firstMatch.
With these the gate now builds, signs up, crosses a message in phase 1,
reaches Account, signs out, signs back in, and re-opens the chat. It
still fails one step short: openChat taps the peer by name, which since
the Home redesign also appears in the story strip, so the re-opened
thread has no composer. That is the last choreography fix, and it is
test navigation, not an app defect. The app's own messaging is proven by
MessagingFeaturesUITests, which passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three Account cases failed on every run and a fourth with the identical
preamble passed: a tap on the bar in the second after sign-up is dropped
while the signed-in view settles, and the tab stays on Home. The tests
now go through openAccountTab, which taps and waits for the tab's own
bar, "Profile and Settings", and taps again if it is not there.
scrollUntilExists returned the moment a row existed, which on a lazy list
is the bottom edge, where the floating tab bar sits; a tap there landed on
the bar and "Chat Settings" never pushed. It keeps nudging until the row
is hittable.
rounds.sh passes TEST_RUNNER_FN_ALLOW_SIGNUP=1. Since ios#62 a UI test
refuses to create an account unless told it may, and every case in the
suites rounds.sh runs signs up a throwaway handle; without the flag the
script failed every case in five seconds, from 2026-09-05 until today.
Against relay1 on an iPhone 17 Pro simulator: SignInFlow, MessagingFeatures,
AccountFunctions, AppSurface and LiveSignUp, 32 passed, 0 failed, 2 skipped
by design (the live sign-ups on the managed relay opt in by touching
/tmp/fn-live-signup).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every pipeline since 2026-09-15 21:39 failed in three seconds. The job
exported a hardcoded /Applications/Xcode-beta.app, the beta had just
become the release and the path went with it, and because git on a Mac
is an xcrun shim the job died on its first git command. The lifecycle
gate defaulted to the same path. Both now take the selected Xcode, and
the job prints the version so the log says what built.
Behind that, the vector gate would have failed next: Tests/Vectors was
refreshed to flamenet-e2e HEAD today while .e2e-ref still named a commit
from before the backup payload widened. The pin moves to that HEAD; the
copies already match it, checked file by file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
It ran by hand until now, and a by-hand gate is one that rots: the e2e
copy sat red for nine days after a formatter moved a marker, and nothing
said so. The job is the same script the release runbook already names,
called from the repository root, and it takes seconds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Registration was tried three times under try?, and when all three failed
the code read the relay's prekey count as ?? 0: "I do not know how many it
holds" became "it holds none", the low-pool guard passed, the local pool
was refilled to a hundred, and all hundred went up for a device the relay
had no row for. Every failed registration anywhere in the fleet left a
hundred orphan prekeys behind. The relay's orphan count grew from 43 to 53
in a day, about 5,300 dead prekeys, and nothing on either side said so.
No confirmed registration, no upload. The stub relay learned to refuse a
registration and to count prekey uploads, so the test can prove there were
none. Falsified by removing the guard: exactly one upload, and the test
fails naming it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`persistable` kept off-the-record THREADS out of the sealed file, and
nothing kept out the draft typed in one or the pin set in one. Both are
separate dictionaries keyed by conversation, passed to every save raw, so
a half-typed message in an off-the-record chat sat on disk under a key
naming the person. The Android engine had the pin half of the same fault,
fixed in the same session. One keyed filter, applied at all three saves.
The guard that checks the debounced write and the full write carry the
same fields read a fixed 2200-character window and `polls:` had moved to
+2213. Worse, the three fields added today sat at +2715 and beyond, so the
guard had silently stopped checking them. It is bounded by the code now,
the debounced block up to `flushHistory` and that function to its own
end, and it checks fourteen fields across both paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`retireThisDevice` swallowed the revoke and then logged "retired device"
at info level regardless, so a failed sign-out read as a clean one in the
one place anyone would look. What it leaves behind is a dead device row
the relay goes on fanning envelopes out to and every sender goes on
spending a prekey for. One was found this morning with 42 undelivered.
The outcome is logged as what it was. On failure the device id is kept
for the next sign-in to the same account, and kept in the standard
defaults rather than the per-user store, because sign-out wipes that
store three lines after the retirement returns and a note left there
would not survive to be read. `make` retries it off the launch path and
clears the note only when the relay accepts.
`setBlocked` had the same swallow with a reasoned comment: the block must
not fail because the revoke did. Kept, and no longer silent: a revoke
that fails leaves the blocked person able to deliver sealed, which is the
opposite of what the screen now says, so it is logged and shown.
The stub relay learned to answer a DELETE with the shape the client
decodes. Its first answer did not, the client threw on a 200, the app
correctly kept its note, and the test blamed the app for the harness.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`callRefused` was added earlier tonight as a plain optional, the shape the
three cold-launch closures had just been found to be wrong in. The log
line is unconditional, so a refusal was never lost entirely; only its
on-screen notice was, on exactly the launch a push causes, before
`AppEnvironment` had wired anything.
It gets the same `pending` plus `didSet` replay as the rest. Small on its
own; the reason to do it now is that an odd one out in a file is the
version that gets copied next.
Falsified by restoring the old drop-when-nil: the new test fails alone and
the other five stay green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`VoIPCallReceiver.shared.start()` runs at app init, so on a cold launch
PushKit delivers the pending push before `AppEnvironment` has assigned
`wake`, `endCall` and `answerCall`. All three were plain optionals called
with `?()`, which on exactly the launch a push causes made them no-ops.
The call was reported and rang, nothing fetched the offer behind it, and
a push that was really the END of a call was never taken back. Tapping
Answer on that lock screen fulfilled the action and accepted nothing: the
ring stopped, and the caller heard a phone picked up and then silence.
That is the 09-13 "lock-screen answering" item.
The token upload had this exact race and a `pending` plus `didSet` replay.
The other three get the same: a request made before the closure exists is
kept and run the moment it is assigned, and a served request does not run
again on re-assignment. Assignment order is wake, end, answer, so by the
time a pending answer replays the offer has been fetched.
The first version of the test could not tell this fix from the fault. It
assigned `wake` on the same actor turn as the request, so the old
fire-and-forget task was still queued, found the new closure, and passed
with the bug in place. It yields first now, so a dropped request is
dropped, and the falsification fails on that one test and no other.
One source-reading test moved with the code it reads: the answer-first
ordering it guards now lives in `requestAnswer()`, and it is asserted
there, with a new check that the handler still reaches it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When `reportNewIncomingCall` failed, the app cleared the call id and said
nothing: no log line, nothing on screen. A call filtered by a Focus mode
was then indistinguishable, on the phone and in every log, from one that
had rung. Tonight's report was exactly that shape, "nothing on the lock
screen, works once the app is open", and the relay could prove the push
was accepted by Apple and fetched by the phone eight seconds later, and
could prove nothing after that, because the phone kept the reason to
itself.
The refusal is logged at error level with CallKit's code, mapped to words
a person can act on, and shown on the app's own error line. The two
filters matter most: a Focus mode or a block list means the phone was told
not to ring and nothing in the app is at fault, which is the difference
between a bug report and a settings change. An unknown code still carries
its number so it can be looked up.
This does not fix a lock-screen call that should have rung and did not.
It makes the next one diagnosable from the phone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`unreadPeers` is the Home badge and every per-row count, and it lived in
memory. Kill the app with three unread conversations and every badge was
gone at relaunch. Sixth feature to reset itself this way.
Two halves, and the second is the one the debounced writer could never
have caught. Receipt happened to be sealed, because delivering also
appends to `threads`, which the writer watches. The CLEAR touched nothing
it watches, so a thread read and then the app killed had its badge back
the next morning. `markThreadRead` flushes on change now, only on change,
since it runs on every view of a thread and sealing re-encrypts the whole
history.
Not carried by the export, on purpose and with the reason in the guard's
exemption list: a new phone has shown nothing yet, so every restored
thread is genuinely unread there, and a count from the old phone would
understate it.
Tested through the real receive path, a sealed envelope from a second
account rather than a poke at the dictionary, because the increment lives
inside `deliver`. Falsified both ways: delete the restore and the restart
assertion fails along with the read-direction guard naming the field;
delete the flush and the clear assertion fails alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Muting a contact's stories (ios#33) reset at every launch. Not because it
was never saved: setStoryMuted flushed on every change and the set was on
disk the whole time. The load path never read it back. A field written
and not restored is lost exactly as thoroughly as one never written.
That is the fifth feature to reset itself, and the guard added for the
first four could not see this one. It proved every Snapshot field reaches
save; it never asked whether anything reads it at launch. It does now,
from the name the load path binds the snapshot to, with the keychain-
backed fields exempt because the inbox reads those from the store and
restoring the snapshot's copy over the keychain would be the wrong
direction.
Falsified by deleting the restore line: the behaviour test fails, the
write-direction guard correctly stays green, and the read-direction guard
fails naming mutedStoryAuthors. Restore verified by diff.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The other engine was not persisting whether a reply was about a story. It
parsed the field, unit tested it, and read it nowhere, so a story reply
arriving there quoted a `mid` that matched no message: a story key is not
a message id. This app has carried `reStory` all along, which is why the
divergence listed it as ours alone.
Both sides carry it now, so it moves into canonical where a byte-exact
round trip is the bar, and the divergence entry splits: `mentionsMe` is
still genuinely one-sided and keeps its own entry.
A copy, per Tests/Vectors/PROVENANCE.md, byte-identical to the authority.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This app had the unread mark on a story and wrote it nowhere, so every
ring relit at each launch and a backup taken here lost which stories had
been watched. The other engine had been writing it all along and its
comment said the field was ours alone, which is why nothing caught it.
Both write it now, so `stories[].seen` moves out of the divergence list
and into the canonical set, where a byte-exact round trip is the bar.
A copy, per Tests/Vectors/PROVENANCE.md, byte-identical to the authority.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mark saying a story had been read existed and was never written down.
`seenStoryIDs` was set by `markStorySeen` and read by `hasUnseenStories`,
and the ring around every contact's picture relit at each launch.
That is the fourth feature to ship this way, and the four are one bug. The
only automatic save path is
Publishers.CombineLatest3($threads, $groupThreads, $callLog)
so anything added since inherits the assumption that saving is handled. It
is not: everything else is in memory until `flushHistory` is called by
hand. Story reactions, conversations on other relays, these marks, and
most of the encrypted export all missed it, and each was reported as its
own small annoyance.
So the fix is two parts. The marks are sealed and exported, per story in
the shared shape the way the other engine already writes them, gathered
back into a set on the way in. Writing happens only when the mark actually
changed: paging a strip calls this per story and sealing re-encrypts the
whole history, so an unconditional flush would re-seal everything on a
swipe.
And `TheSealedStoreCarriesEveryFieldTests` reads the Snapshot declaration
and fails when a stored property is not named by BOTH the store's `save`
and the inbox that calls it. Both halves, because a parameter nobody
passes defaults to empty and writes the field away on the next message.
Falsified by adding an unwired field, which is exactly how the four
arrived; the message tells the author what to wire up. `version` and
`outbox` are exempt with reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`remoteThreads` held every conversation with somebody on another relay in
a published dictionary with no write to the sealed store and no read back.
They were gone at the next launch. Nothing said so, because an empty
thread looks exactly like somebody who has not written to you.
The vector's divergence list recorded this as a field only the Android
engine kept, which understated it: this app kept them nowhere at all. They
now go into the sealed snapshot and the encrypted export under
`addrThreads`, the name the other engine has used all along, so a restore
brings them back and a move between phones carries them.
The tests are worth a word, because the first set was useless. Three of
them encode a Snapshot and decode it, and they passed with the fix removed
and would have passed every day the bug existed: the bug was never in the
codec. Two more drive a real inbox, flush, and read the file back the way
a launch does, and those fail when the restore line goes. The codec suite
now says in its own header that it is not sufficient.
`appendToRemoteThread` is reachable from tests for that reason, stated at
the definition rather than left looking incidental.
Those two live in XCTest with every other inbox test, not in a parallel
Testing suite. And their setup purges the history FILE as well as wiping
the key: a wipe deliberately leaves the file, the next launch finds one it
cannot decrypt and refuses every save after that, which is correct and is
why the tests passed once and failed every run after. Both real sign-out
paths pair the two calls; so do these.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This app numbers a story locally in `id` and keeps the identity that means
something on another device in `key`. The Android engine put that identity
straight in `id`. One name, two meanings, so a story travelling between
the two phones came back unrecognisable and the import filed it as a
second copy of itself.
`key` is written always now, falling back to the post time rather than
letting a story travel with no way to recognise it, and `id` is not
written at all. A local handle has no business in a shared shape.
On the way in, the local number is minted here rather than taken from the
file, and `id` is accepted as the key only when it is a string: a numeric
one is the writing device's row handle and names nothing here.
That was the last divergence holding stories out of the vector's canonical
set, which is eighteen keys now and covers both `stories` and
`storyArchive`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flamenet-e2e widened the canonical set from eight keys to sixteen. The
story audience, muted authors, story reactions, drafts, pins, polls and
both 8.9c presence token maps are fields this app already reads and
writes, so they now have to survive a byte-exact round trip rather than
sitting outside the comparison.
That is where the drift was. The audience mode had become an enum name on
Android and lower case here, and because the field was outside canonical
nothing compared them: an audience crossing between the two phones was
dropped in silence and restored as EVERYONE, which shows stories to the
people somebody had left out. Inside canonical it is a failing test.
A copy, per Tests/Vectors/PROVENANCE.md, byte-identical to the authority.
A vector moving is a protocol change and this app is one of the two ends
that has to agree about it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two holes in the export, both found by writing SPEC 16.3 down and then
checking the code against it.
A poll is counted only once every member has voted and published, so a
restore without the pieces already gathered leaves a question nobody can
finish answering. Polls travel now, under the field names the Android
engine archives under, so one in flight survives a move between the two
phones and not only a restore onto the same kind. An entry missing its
member set or its options is dropped rather than half restored: the member
set fixes which index each member's piece sits at, and Android drops the
same entries for the same reason.
The audience read demanded an exact lower case mode and skipped the whole
block when it did not parse. Skipping it restores an account on EVERYONE,
which shows stories to exactly the people somebody had left out, with
nothing on screen to say it happened. The case is normalised now, and a
mode that still will not parse falls to the narrow reading rather than the
wide one. The Android engine keeps this as an enum name and had shipped it
upper case, so this was not hypothetical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all reported from a real session.
A story went to its audience and was then saved locally. Nothing reached
this account's other devices, so a story posted on the phone did not exist
on the tablet at all. Reactions DO go to every device the author has, so
the tablet received all of them and had no story to attach them to. The
person saw a story drawing no response, and the only way out they found
was to stop using the second device. Each posted story is now mirrored to
this account as well, carrying the same story key, best effort and outside
the audience count. On the way in, a story from this account is filed as
ours only when the sender is proven, or an unproven envelope claiming our
id could put a stranger's story on our own strip.
Removing a contact existed only as a long press on a 56pt circle in Home's
recents, with no confirmation and nothing saying it differs from blocking.
It is on the Messages rows now, which is the list people actually scroll,
and both surfaces ask first in the words Android already uses. The rule
itself moved into ContactRemoval, because three callers were restating it
and the /remove command had drifted: off the server it asked the relay who
was on a list the relay no longer keeps, and reported removals it had not
made.
The encrypted export carried threads, groups, calls, stories and the
roster, and silently dropped everything else the sealed store holds:
story reactions, the presence tokens, drafts, pins, polls, group names and
secrets, muted authors and the story archive. Restoring on a new phone
lost the lot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults from tonight's reports.
A contact showed offline while online. 8.9c gives two ways to be granted a view,
the relay's contact graph and holding a token, and while both exist they can
disagree: a token that has gone stale answers offline for somebody the graph
says is online, and yesterday's merge let the token win outright. It takes the
more visible answer now, because either grant is a grant and a stale token is
not somebody going offline. Once the relay keeps no graph the token stands
alone, including when it says offline, which is revocation working rather than
a disagreement.
Story reactions were never persisted, on either platform. They went into a
dictionary in memory and were gone at the next launch, so an author watched a
tally empty itself overnight and a reactor watched their own reaction vanish,
neither of which is anything the other person did. They are in the sealed
snapshot now and carried by the encrypted export, which the backup test caught
me leaving out, exactly as it exists to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yesterday's guard refused every empty list, because an empty list could mean
the relay had stopped keeping the contact graph. It could also mean the account
had no contacts, and refusing both left somebody who removed their last contact
still seeing them on every other device.
The relay says which now, so the rule gets simpler rather than more clever: a
relay that keeps no graph is not a source of one and nothing from it is
adopted; a relay that does keep it is the source and its answer stands, empty
included.
fetchBuddies keeps its shape for callers that only want the list. fetchRoster
is the one that also says who is holding it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I had proved this by reading the source for the guard, which is not proof of
anything for a rule whose failure mode is somebody's contact list. shouldAdopt
is its own function now and the test asks it: empty never replaces held, empty
onto empty is allowed, and anything non-empty always lands. That last case
matters because a guard that refused everything would pass the first two and
break the app.
Written this way because the end-to-end version cannot safely be run. Verifying
it against production means setting FN_ROSTER_ON_SERVER=false there, and twelve
of thirteen devices polling that relay are on builds without this guard, so
they would take the empty list literally and overwrite their stored rosters.
That is up to five accounts' contacts for the length of a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The User-Agent said 1.0 and had said 1.0 since it was written, so a relay
asking "is every client new enough yet" got the same answer for ever. It sends
CFBundleShortVersionString now, the marketing version only, with no build
number, device model or OS version alongside it.
This is what the relay needs before the contact graph can be removed: an older
client reads its contact list from the server, and taking that away while one
is still polling empties it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.9c ends with the relay keeping no contact graph, and a relay that has stopped
answers the buddies route with an empty list. Taken literally that erases
somebody's contacts on the first poll after an operator throws that switch, and
on any transient fault that answers empty.
adoptRoster now refuses an empty list over a held one, and both screens fall
back to the sealed roster rather than showing the empty answer. Guarding the
store alone would have left a screen that reads as having lost your contacts
while the data sat intact underneath.
Empty onto empty is still allowed, or a new account could never take its first
contact from the server.
One source-reading test re-anchored: it pinned the line that assigned the fetch
result, and the result goes to a local now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file handed Apple flamenetreview1 and flamenetreview2. Neither is an
account on the relay, and the users table says the only review accounts are
fnreviewalpha and fnreviewbeta, which are also the pair verified end to end
against the live accounts service on 2026-09-04. A reviewer typing a handle
that does not exist cannot get past the sign-in screen, which is the same
failure that had 0.4.0 rejected under Guideline 2.1.
The walkthrough was wrong in a second way. It sent the reviewer to a Community
tab to add the other account. There is no Community tab, and has not been: the
tabs are Home, Messages, Calls, Stories, Files, Wallet and Account, and adding
somebody happens from the field at the top of Home. The two accounts are
already contacts, so the step is not needed at all and the walkthrough is four
steps now rather than five.
Two checklist lines added. One for the Sign-In Information field, which is the
one thing a reviewer cannot work around. One for re-running generate.sh after a
version bump, because the version lives in project.yml and the Xcode project is
generated from it, so a bump alone archives the old number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build 25, matching the version as every release since 13 has.
Carries 8.9c, the envelope routing rework, and the group story fix, none of
which are in the 0.24.0 archive now with Apple.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults, both found by running it rather than by any test, and both of a
kind a test could not have caught without the live pairing.
The routes were sent with the e2e bearer. authorize prefers that whenever a
relay session exists, which is right for /e2e/* and wrong for these two: they
live on the mobile surface, whose caller reads the Basic header and nothing
else. The relay refused both as credential_rejected. My comment in the first
commit claimed authorize picks whichever the deployment accepts; it picks
whichever the e2e surface accepts, which is a different sentence.
Publishing was attempted exactly once, at the moment a token was minted, and
the refusal was swallowed because it is best effort. So the token was stored,
nothing asked again, and that contact would never have seen a presence. The set
is now compared against what the relay last ACCEPTED and republished until it
agrees. The route replaces rather than merges, which is what makes repeating it
free.
Verified on the live relay: a digest appears for the sending account, both
directions publish their own, and with the buddy row deleted the contact still
reads online, so the token is what answered rather than the graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec 8.9c, the client half. Each contact gets a random 32-byte token, sent over
the encrypted channel on the delivery-key envelope, which is already the
per-contact one. The relay is given only SHA-256 of it. Presenting the token is
what reads a presence, so the server no longer needs to hold who knows whom.
Per pair rather than one shared secret, which is what makes taking somebody off
the list cost the others nothing. That is the difference from the sealed-sender
delivery key, where cutting one person off replaces a key everybody holds.
Where a token is held its answer wins; a contact with no token yet keeps
whatever the roster said, so a half-migrated pair does not read as offline
while both ends catch up.
Tokens live in the sealed snapshot and are carried by the encrypted export. The
backup test caught that they were not, which is exactly the hole it exists for:
a new phone without them would have lost every contact's presence and minted
fresh tokens nobody holds.
The field is absent rather than empty when no token has been issued, so an
older build parsing this envelope never sees something it would treat as one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deliver() was twenty sequential if branches whose correctness depended entirely
on their order, with nothing enforcing or recording that order except the
comments between them. Both of the silent message-loss bugs that file produced
in one day were ordering bugs rather than logic bugs: a control-traffic guard
before appendToThread ate every text message, and the same guard one branch too
early ate every incoming call.
Not a rewrite, which is what the issue asked for. E2EEnvelopeRoute.Order.all is
the sequence, written down once, and route() is a pure function of an envelope:
no inbox, no network, no handler runs. deliver() switches on the result, so a
new kind is a compiler error until it is handled, and every handler body is the
one that was there.
THE BUG THIS FOUND
A group story carries an attachment of kind .story AND a gid. The top-level
story branch came first and took every one of them, filing it as the author's
own story with no group on it. The 6.24 branch that handles group stories sat
below, unreachable, looking like it worked. GroupStoryTests never caught it
because it builds an E2EStory directly and never goes through deliver.
groupStory now sits above mediaStory, and a test asserts that ordering rather
than the outcome, because the outcome is what stops being checkable the moment
somebody moves a line. Putting the old order back fails three tests, which is
how I know they would have caught it.
Two source-reading tests were anchored on the ifs and now anchor on the case
labels. They were reporting the refactor as a lost feature.
Also, a username somebody already has says so in the same words on both kinds
of server. The shared account service's refusal said "handle", which is not the
word on the field somebody just typed into.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The capture half of that issue was already built and I did not know it until I
looked: MetricKit, on the device, no SDK, no network call, no identifier, kept
by AppDiagnostics and read in DiagnosticsView, with the screen saying plainly
that nothing has been sent anywhere.
What was missing was the other half. The detail screen offered a share sheet
holding raw JSON and named nowhere to send it, so somebody who wanted to help
had a blob and no address. It names support@flamenetmessenger.com now, with a
tap to copy, and shares a file rather than a string: a mailto cannot carry a
payload this size and clients truncate it without saying so.
The project's address rather than the signed-in relay's, for the same reason
the donate link is the project's site. A crash is a defect in this app and a
relay operator did not write it.
A test now refuses URLSession, URLRequest and dataTask anywhere in either file.
The promise on that screen is that nothing uploads on its own, and that is the
kind of promise a later change breaks by accident.
Android has no crash visibility at all, which is the half of ios#24 that is
still open. ApplicationExitInfo is the same shape of answer as MetricKit and is
not built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign out is the last row of the Account list and the pill sits on it. That was
called fixed in eb177ce by giving PillTabBar a safeAreaInset, and it was not
fixed.
Measured rather than argued about. A 40 row List inside the bar draws to the
last pixel of the canvas, rows running under the capsule and past it. The same
inset applied directly to the List works and stops the content 350px up. So the
inset reaches a ScrollView and does not reach a List, and the tabs built from a
scroll view came right while Account, the one screen built from a list and the
one the bug was reported against, went on running its rows under the bar.
Padding instead. It is coarser: content stops above the bar rather than
scrolling under it, so the bar loses its float over content. It also works on
every kind of child rather than on some of them, and a control nobody can reach
costs more than a floating bar earns.
The UI test carried a swipe-up rescue with a comment naming ios#67, so the
workaround was the only thing making the test pass while the screen stayed
unusable. It asserts hittability now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The account screen gets a Donate row, matching Android. It opens
flamenetmessenger.com/donate in Safari. Nothing is taken in the app, nothing is
unlocked by giving and nothing is withheld without it, which is the line
guideline 3.2.1 draws.
The address lives on SiteConfig rather than in the view. It is the project's
site, not the signed-in relay, which is the opposite of the rule the privacy
and terms links follow: those open the server you are on, because a self-hosted
relay's operator writes the policy that binds its users. A donation is not
that. Keeping the literal out of ProfileView also leaves
ThePolicyIsReachableFromTheAppTests able to go on refusing that host anywhere
in the file, which is what stops a policy link being hardcoded to it.
ios#12 asked for one remaining thing: the filtering position stated in the App
Review notes rather than left looking like an oversight. TESTFLIGHT.md section
2b answers all four of guideline 1.2 in one place, with filtering answered as a
position, the relay holds ciphertext and has no key to anything, and with what
replaces it written out. The response time 1.2 asks for is marked as unset
rather than invented: it has to be a number the operator will meet, and the
same words have to go on the safety page.
Two things in that file were stale enough to be pasted into a submission as
written. Push notifications are not unimplemented any more, and the export
compliance answers still described the mass-market route rather than 740.13(e).
ios#7 is closed: the owner verified the locked-phone ring on a real handset.
The hazard it leaves behind is on the checklist instead, because the APNs
environment is per-relay and the wrong one looks like a call bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Custom product pages override screenshots, previews and promotional text and
nothing else, so each one is a different set of pictures and one different
sentence. Three, because a page is a destination and this project has three
links to point at one: the site's download button, the self-hosting page, and
whatever ads or posts carry the no-phone-number claim.
Promotional text in both languages, all six under the 170 character limit. No
comparison to another messenger, nothing about audits, same rule as the
description.
The sign-up screen gets a fixture of its own. ios-signin was standing in for
it and its field is labelled "Username or email", which argues against the
caption above it. SignUpView takes a defaulted username and birth year so the
shot is a form somebody is partway through rather than one showing a list of
what is still missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second iPad shot on the Calls tab: the call log in the sidebar, the
conversation still open beside it. That is what MainTabView draws, because
the detail column belongs to the conversation rather than to the tab, so
moving to the call log does not clear what was on the right.
The call itself stays a full width shot. CallScreen is a fullScreenCover and
covers both columns, so a split picture of it would be a layout the app never
draws.
The sidebar goes from ideal 380 to 420, inside MainTabView's own 320...460,
because a missed call's stamp needs the width to sit beside the two ring-back
buttons. The fixture rows now carry the lineLimit(1) that CallsView has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every iPad shot was a phone layout stretched to iPad pixels, and the split
view collapsed to a single column with a sidebar button. The renderer
hardcoded a 3x scale, which is right for every iPhone size the store
accepts and wrong for the iPad: 2048x2732 is 2x of 1024x1366 points, so
the canvas came out 682 points wide. That is phone width, so SwiftUI laid
it out as a phone and NavigationSplitView had no room for a second column.
The scale is an environment variable now, and the script passes it.
MainTabView gives a second column to Home, Messages and Calls and to
nothing else, so one fixture renders that split and the other screens stay
full width, which is what an iPad actually shows. The messages list and the
conversation are shared properties rather than a second copy of each.
The caption band was sized off the canvas WIDTH, so the same fraction gave
a 1284-wide phone and a 2048-wide iPad type that differed by 60 percent and
squeezed the iPad screen into the bottom half of its own screenshot. It is
sized off height now, and both formats get the same proportion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both agencies accepted it on 2026-09-15 at 17:37 UTC: BIS returned 250
through its gateway, NSA returned 250 with a message number. The document
now carries the text as sent and the acceptance lines, because the date is
what a later question about this would turn on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This document used to choose the mass-market route: self-classified
5D992.c, an encryption registration through SNAP-R, and an annual
self-classification report every February. That route needs an Encryption
Registration Number, because registration is what issues one. It also
cited the reporting rule for License Exception ENC rather than the one for
mass market.
All four repositories are public and the licence is MIT, which is what
EAR §740.13(e) is for: publicly available encryption source code, not
subject to an express agreement for a licensing fee. The obligation is one
notification to BIS and one to NSA naming where the source lives, and
nothing annual. Nothing had been filed under the old route, so the switch
costs nothing.
The notification is drafted here, addressed and ready, and has not been
sent. One question is flagged for a lawyer rather than assumed: whether
the binary Apple distributes is the object code corresponding to that
published source, given it also links CryptoKit and WebRTC. The App Store
Connect answers change too, because the exemption question now has an
accurate yes where it used to have a no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both came from a crop of the source artwork that started after the flame
arc began and ended before it finished, so the icon showed a globe clipped
on the left and a flame sheared off at both ends. The crop is the whole
emblem now.
The sign-in mark was not generated by the brand script at all, which is why
it had drifted: it was made by hand from the old crop and never revisited.
It comes out of the same generator as everything else now, so the icon, the
favicons, the social card and this cannot disagree again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer's screens check layouts, with placeholder text about
bubble corners. The App Store listing needs screens somebody would
recognise as a conversation, so six store screens sit beside them on the
same renderer: a full message list, a chat with a reaction, an edit and
a disappearing timer, a call, three stories, files with a label, and the
account rows including App Diagnostics and the policy link.
The script pinned a runtime id that no longer exists on this machine,
so `simctl create` said "Invalid runtime" and an iPad set rendered
nothing. It picks the newest installed iOS runtime unless told
otherwise, and takes FNMSG_DEVICE_NAME so an iPad simulator can sit
beside the iPhone one rather than replacing it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6.9-inch iPhone and 13-inch iPad, with the device type and size for each,
in place of a list that stopped at the 6.5-inch phones.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first build after 0.22.0 that a stranger will install. It carries the
lock-screen first-call fix, App Diagnostics, call audio in the background,
the policy links, the dead commands removed, and the suspension sentence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The relay can now suspend an account, and it refuses one with a single
sentence at every door. On the E2E paths that arrived as a bare 403, which
the screens render as a generic failure. A 403 whose body names
account_suspended now surfaces the relay's sentence as the unauthorized
reason the screens already show, on both the JSON and the blob path. The
profile API already passed the relay's message through.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Twenty-four slash commands in the composer, /search, /video, /file,
/photo, /forum, /jobs, /rooms, /radio and the rest, were the site's
features from when the messenger lived inside it. They called
flamenet/v1/search, /videos and /commands on the relay, which serves
none of them, so every one answered "Something went wrong" after a
round trip. /clear, /encrypt and /plain asked for a plaintext mode the
relay no longer has, and /tier read a constant. All of them are gone,
with the result picker, the photo upload path, the search response
types, and the relay's command dispatcher and tier route they were the
only callers of. What stays is what works: status, presence, buddies,
blocking, whois, star and archive.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The background modes were remote-notification and voip: the first wakes
the app for a message, the second delivers the push that reports a call
to CallKit. Neither covers the call itself. CallKit activates the audio
session, but the capture and playback are WebRTC's, and without the
audio mode iOS suspends them when the screen locks or the person
switches apps mid-call: the far end hears silence while both screens
say the call is connected. Apple's own CallKit sample declares both
modes. The built Info.plist now carries all three.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
App Store Review Guideline 5.1.1 wants the privacy policy reachable from
inside the app, not only from the listing, and there was no link to it
anywhere. Two rows under About open the policy and the terms.
They open the pages the signed-in relay serves, at that relay's own
origin. A self-hosted relay serves its operator's policy at /privacy, so
a hardcoded address would send those users to read somebody else's. A
source-reading test pins both rows to the relay origin and refuses a
hardcoded host.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two things, both about what happens when nobody is watching the screen.
The first call to a phone that was not already running never reached the
lock screen. The second one did. Reported iPad to iPhone: both calls rang
with the app open, only the second rang the locked phone, and the relay
had accepted every attempt, so the fault was on the device. It was the
same mistake in two places: work that has to happen now, written as
`Task { @MainActor in ... }`. Neither hop crossed a thread, because
didFinishLaunching runs on the main thread and the PushKit registry is
built with `queue: .main`. All the hop did was defer the work to the next
main-actor turn, behind everything a cold launch has already queued. The
registry was built after the push that launched the app needed it, and
the call was reported after iOS had stopped waiting, so iOS killed the
app instead of showing the call. On a warm app the queue is empty and
nobody notices, which is why the second call worked. Both are now
`MainActor.assumeIsolated`, inline. A source-reading test pins both,
ignoring comments so that quoting the old hop in an explanation cannot
fail the assertion that it is gone. The simulator cannot receive a push,
so the fix still needs one cold-start call on a real device.
The second is MetricKit. Until now a crash was something a person had to
tell us about. The app now subscribes at launch and keeps the last twenty
crash, hang, CPU and disk reports on the device. Nothing is uploaded, not
behind a setting and not on a timer: a messenger that promises the server
cannot read messages cannot also mail itself diagnostics about the phone.
A person opens Account, App Diagnostics, reads a report, and shares it
deliberately through the share sheet if they want to. Performance
metrics are ignored on purpose. The file uses
completeUntilFirstUserAuthentication for the same reason the history
store had to, because MetricKit delivers whenever it pleases, including
to an app woken in the background by a call. The subscription happens
inline at launch for the same reason PushKit now does: iOS hands over
what it has been holding shortly after a subscriber exists.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six things, and they are one thing: the app detected a problem in more places
than it let anybody solve it in.
SIGNING A PHONE OUT LOOKED LIKE AN IMPOSTOR
`classify` returned `.changed` the moment anything pinned went missing, so
removing a device made every contact's app refuse to send until they accepted
"new keys" that were not new. The Devices screen offers that sign-out. The app
was booby-trapping its own feature, and nobody would ever connect the two
events.
A key that GOES AWAY can only shrink the set of devices able to read a
message; it cannot let a stranger read one. Only a key that ARRIVES can, and a
new key alongside a missing one is still `.changed`. So removal is its own
reading now, benign, and it re-pins the smaller set so it settles instead of
being noticed on every send.
A REFUSED CALL TOLD THE CALLER NOTHING
The answering side raised its own review and finished with `notifyPeer: false`.
The caller sat on "Calling..." for forty-five seconds and was then told "No
answer", which is not what happened and sends them to look in the wrong place.
Measured on a real pair of phones: eight calls in five minutes, not one
envelope coming back. It says "They need to check your security code first"
now. Naming it leaks nothing -- the caller's own keys are what changed.
ACCEPTING WAS IN ONE PLACE AND WARNING WAS IN THREE
The profile showed the key-change warning, carried the safety number somebody
had just been told to compare, and then said to go and accept "in the chat".
It accepts there now.
And accepting in the chat left the banner on screen until you navigated away
and back. THIRD time this exact mistake: the view read live state through
`environment`, and `trust` is `@Published` on the INBOX, a nested observable
SwiftUI does not forward. The state was right every time; nothing told the
screen. The banner observes the inbox now, like the call buttons do.
THE REST
Story Report sits beside the reactions, where the safety page says it is
rather than in an overflow menu at the top of the screen.
The wallet's receive index was `@State`, so it reset to zero on every launch:
somebody who tapped "New address" precisely so as not to reuse one was handed
the old one again next time, silently. A privacy feature that undoes itself is
worse than one never offered, because the person believes they are covered.
THREE TESTS PINNED THE OLD BEHAVIOUR
Two asserted what was being fixed, which is what a good test does when the
behaviour was wrong. One was mine, matching its own doc comment -- the comment
quotes the old expression to say what was wrong, so the test now reads code
and ignores comments.
308 tests in 62 suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The safety page says: "A story. Open it. Report is on the story itself,
beside the reactions." On Android that is exactly what it is, a tap in the
same row as the quick reactions. On iPhone it was in an overflow menu at the
TOP of the screen, next to Save and Close. A different place entirely, and
anybody following that sentence was looking in the wrong half of the screen.
Stories are the surface a stranger is most likely to reach somebody through,
which is the reason the affordance was built at all. Putting it where nobody
looks wasted it.
The feed owns one sheet rather than each card owning its own: a sheet per card
in a lazy list is a sheet that can be torn down while it is on screen.
AND THE TESTS I WROTE TODAY WERE NOT BEING RUN
This project has no filesystem-synchronised groups, so a new file is invisible
to Xcode until `Scripts/generate.sh` runs. Three test files added today --
the findable-reporting ones, the group-report ones, and these -- were never
compiled, and the suite happily reported success without them.
The tell was there and I read past it three times: the count stayed at exactly
281 while eight tests were added. A number that does not move when you add to
it is not a passing suite, it is a suite that cannot see you.
Regenerated: 292 tests in 58 suites, and now that includes the ones written to
catch these very bugs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A group had no Report. The context menu offered reactions, Copy and Delete,
while the safety page told people to press and hold a message and choose
Report -- and a group is where somebody is MOST likely to need it, because it
is the surface on which a person who is not your contact can reach you. It
reports the author now, which is the only thing that makes sense: a group has
no single other party, and reporting "the group" would name an account that
wrote nothing.
Reporting a person is in the conversation menu too, beside Block. It lived
only behind a press and hold on a message. That is the right place to report
ONE message and it stays there, but nobody finds it by looking: the person
who wrote this app went looking, following the safety page, and could not
find it. Nobody upset is going to do better.
TWO TESTS WENT RED AND THE CODE WAS RIGHT
Both matched inside a guessed number of characters after a `case` -- 400 and
3000 -- and both branches had since grown past it. `cancelMediaGone()` sat at
606, and the decline latch at 4577 still correctly preceded the answer latch
at 4983.
Widening the numbers would have been the wrong repair. They are bounded by
the next `case` now, because a test that fails when a comment is added
teaches people to widen windows instead of reading failures, and the next
real regression is the one they will widen past.
281 tests in 55 suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both clients already refused an envelope id they had seen before. Neither
ever sent one on the ordinary route: the field existed only on the federated
send, so every envelope reached the relay with a null id and the check on the
other side was asked about nothing. Dead on both platforms, in both
directions, since the day it was written.
It matters because envelopes do arrive twice. Two loops poll one mailbox and
race on the inbox cursor. Measured across a real iPhone and a real Android
build rather than reasoned about: ONE call offer written to the relay at
17:52:15, delivered to Android at 17:52:22 and again at 17:52:24. The second
copy landed while that phone was ringing for the first, fell into "one call at
a time", and answered busy -- to the very call it was ringing for. The caller
was told the callee was busy by a phone that was ringing at them.
Calls made it visible. Messages and call log entries have been doubling the
same way all along and more quietly, which is worse.
So every ordinary send now carries an id, on both platforms, and Android's
carries a default so a new call site cannot forget one.
Two guards at the call layer as well, because a signal arriving twice has to
be harmless whatever the transport does: an offer for the call this phone is
already on is not a second caller, and it says which call it is on when it
does answer busy.
CLOSING THE APP DURING A CALL
The far end works it out when the media stops, which is now a few seconds
rather than the half minute it was. A few seconds of a dead call is still
somebody holding a phone to their ear saying hello into it, so both platforms
now say so outright: iOS on `willTerminate`, Android on `onTaskRemoved`, which
is what fires when the app is swiped out of Recents.
Android's runs on a thread with a bounded join rather than a coroutine. The
scope is cancelled a moment later and a launched coroutine would be killed
before the envelope left; the bound is so a relay that is not answering cannot
hold up the teardown. Neither is the guarantee -- iOS promises nothing at all
for a force quit -- which is why the media-path fallback stays and has a test
saying so.
0.21.0 build 21. 264 tests in 51 suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all of them the same shape: something that worked only because
the app had never run on a locked phone, and the first build that can receive
a push on one found them all at once.
HISTORY COULD NOT BE SAVED ON A LOCKED PHONE
The history file was written with `.complete` protection, which means what it
says: the file cannot be opened at all while the phone is locked, to read or
to write. Survivable only for as long as this app never ran locked, which
until a build could take a VoIP push it never did. Now it wakes for a call,
takes delivery of messages, cannot open its own history, and loses them at
the next launch. "Messages still arrive but they cannot be saved."
It is now `.completeUntilFirstUserAuthentication`, the same condition the E2E
keychain items already use, so the key and the file it protects are reachable
under the same rules instead of contradictory ones. The contents are AES-GCM
sealed with that key either way, so this is the outer of two layers: a file
lifted off a powered-down phone is still ciphertext. Files already on disk
keep the class they were created with, so the attribute is rewritten in place
on the way past -- without that, every phone that has ever saved history goes
on failing exactly as before.
That change alone would have been worse than the bug. `load()` treated a read
that threw as "no history here", cleared the latch that refuses to write over
a file it could not read, and would have sealed an empty snapshot over a full
one. Nothing came of it before only because the file was equally unopenable
for writing at the same moments. What decides whether writing is safe is now
whether the file EXISTS, not whether it could be read.
And a load that fails because the phone is locked no longer poisons the whole
session: `restoreWhenUnlocked` runs when protected data comes back, restores
what is still empty, keeps everything that arrived meanwhile, and flushes.
A CALL ENDED ON ONE PHONE RAN ON FOR HALF A MINUTE ON THE OTHER
Two waits, stacked.
The loop that exists to make a call feel immediate was calling `pollOnce()`
with the default holding of twenty seconds, so it spent its life inside a
twenty-second request, one second of sleep at a time. Mine, from the change
that added the parameter. It asks for nothing held now.
Worse, because it does not depend on a signal arriving at all: the far end
hanging up shows up locally as ICE `.disconnected`, and that was treated as
nothing on the grounds that only `.failed` is terminal. WebRTC takes fifteen
to thirty seconds to say `.failed`. So the call sat there, by design, waiting
out a timeout -- which is not something anybody sits through; they decide the
app is broken. `.disconnected` now starts a four-second window, long enough
to ride out a wi-fi handover and short enough that hanging up feels like it,
and `.closed` is terminal at once. The media path and the end signal are
independent now, and whichever arrives first ends the call.
HANGING UP FROM THE NOTIFICATION TOLD NOBODY
The VoIP push carries no caller: PushKit demands a reported call before there
is time for a round trip and a decrypt, so the phone rings as "Flamenet call"
and works out who afterwards. Hang up inside that second or two -- and the
lock screen is exactly where somebody declines a call fast -- and the decline
arrived with nothing to decline, returned, and left the caller ringing. The
answer case was already held for the offer it belonged to; a decline is now
held the same way, bounded to fifteen seconds, and read first, because if
some sequence of taps wants both then hanging up is the safer one to honour.
264 tests in 51 suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things the relay can refuse a request for now have names on the wire,
and until this the app read two of them. The other three arrived, meant
something precise, and were handled as an unexplained failure -- including
the one that says the account is full, which the app answered by retrying
into it for ever while looking perfectly signed in.
So there is one ladder, walked in order, and a person is asked only once
every rung has been tried:
1. Forget what is cached about this peer, when the refusal named one.
2. Fetch a working credential. Present the stored one again; if the relay
still refuses it, exchange the account session for a new app password.
This runs FIRST, because every step under it authenticates: registering
under a refused credential re-registers into the same refusal and the
repair reads as having failed when it never had a fair try.
3. Register the device again.
4. Send both halves of the push token again.
Two of these were already written. What was missing is that `run` returned
`.repaired` unconditionally: `needsPerson` was declared and never returned,
so the sentence explaining a refused credential could not reach anybody, and
the app went on looking signed in and silently unable to send. A test caught
it. And when a person does have to be told, they are now told which of the
two failures it was -- a credential only they can replace, or a registration
that failed on a good one -- because "sign out and back in" and "check the
connection" are not interchangeable advice.
A named credential refusal repairs at once rather than on the third one. The
count of three exists because a single bare 401 across a token refresh is
ordinary and an app that shouts about it is one people learn to ignore; a
code that says exactly what happened is not ordinary and needs no quorum.
Also here, the call buttons in a chat. They went grey after one call and
stayed grey, and leaving the conversation and coming back brought them back,
which was the tell: the state was right, the screen never heard it had
changed. `AppEnvironment` publishes the call manager, not the manager's
insides, and SwiftUI does not pass on a nested object's changes. They watch
the manager themselves now.
The worse half of the same bug: a call refused by the trust gate parks in
`.ended` until its key review is let go, and only dismissing the review
sheet let it go. Accepting the same keys from the conversation banner -- the
obvious place to accept them, since that is where the banner is -- left the
review pending for ever, and the buttons grey for ever with it. Accepting an
identity anywhere now reaches the call.
0.20.0 build 20: the first build a locked phone can be rung on at all. Every
call proved so far was proved with the app open, because a build installed
from Xcode holds a sandbox push token and the relay sends to production. It
refused every push, forgot the token, and the app registered another sandbox
one on the next launch. Nothing in the app was wrong and nothing could have
been learned by testing it that way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing ever cleared `identityReviewPeer`. That did not show while the
tombstone dismissed itself after two seconds and took the "Review keys"
button with it, which was the bug reported first. Making the screen wait --
which it has to, or the button cannot be reached -- exposed the other half:
accepting the keys left "Review keys" on the screen for ever.
Waiting and never letting go are the same bug wearing opposite faces, so the
test pins both ends.
Closing the review is the end of the call screen's business with it either
way. Somebody who wanted to think about it still has the same banner in the
conversation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19 was cut before the last of the call fixes landed, so 20 is the first
build carrying all of them. The version stays 0.19.0: same release, working.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the ghost ringing, and the four fixes before it all worked
downstream of it: take the reported call back, check which call CallKit is
ending, keep a call you cannot judge. Each was a real bug. None stopped the
ghost being made.
iOS gives no choice about how it is made. Every VoIP push must report a call
to CallKit or the app is killed. Ends were marked urgent, urgent means a VoIP
push, so every hangup, every busy and every end for a call long over arrived
on the other phone as an incoming call. From the owner, on a real handset: "I
place a call, it calls me back." Cleaning up afterwards is not a fix, because
somebody has already seen it.
Ends were marked for a real reason, yesterday morning in 6ee32af: hanging up
before the other phone was picked up left it ringing for a caller who had
gone. What that missed is that the phone which needs to hear a hangup is, by
definition, ringing -- and therefore awake. It has a reported call, iOS keeps
it running for one, `startFastPolling` has it asking, and the relay answers a
held poll through Redis in under half a second. The end arrives on its own. A
phone that never rang has nothing to stop.
If that reasoning is wrong the failure is bounded and immediately visible: a
ring that carries on after the caller hangs up, until it times out at
forty-five seconds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A call refused by the trust gate ends with "Review keys" as its primary
action and a Close beside it, put there deliberately so nobody has to go and
work out for themselves why their call failed. Then the tombstone cleared
after two seconds and took both buttons with it: "the dialog is so fast when
it ends I can't tap anything."
The flow was right, the timer was not. An ordinary end still clears itself,
or every finished call would leave its screen up.
Worth having found before review. A reviewer meeting the trust gate saw a
security warning go past with no way to act on it and no obvious way back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The query that settles whether an answer ever left a phone, the three
CallKit behaviours that cost today, and the bugs still open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found with the logging added this morning, in four lines:
20:00:14.861 the system ended the call, state=dialing
20:00:14.861 finish: Call ended (hangup) state=dialing tellPeer=yes
20:00:14.886 transmit call-end to 123 (98 bytes)
20:00:14.886 offer dropped: the call ended while it was being built
The app announced a call to CallKit before there was one. CallKit answers a
start action for a call that has not connected by ending it again a second or
two later, and nothing in an end action says whether it came from the system
or from somebody pressing hang up. So the app took the system's teardown for
a hang-up, told the far end so, and the call was over before it was placed.
This is the cause of the `call-end` that was arriving one millisecond ahead
of every offer. That was the symptom.
Three changes:
- An outgoing call is announced when it CONNECTS, not when it is dialled. A
call nobody answered is in the app's own log; it does not need to be in
Recents badly enough to cost this.
- The end action asks WHICH call. It asked nothing and hung up whatever was
in progress, so CallKit ending any call at all -- including a throwaway the
push handler is required to report -- ended the person's.
- The reconcile keeps a call it cannot judge. `callManager?.hasLiveCall ==
true` is false both when there is no call and when there is no call
manager, and a push can arrive while the transport is still coming up.
Treating the second as the first ended a call that was ringing perfectly
well.
Verified end to end against the owner's iPhone. Offer out at 00:05:44, their
answer back at 00:06:00 -- the first envelope from that phone all day -- and
the caller went `state=active`. Four calls before this produced nothing but
the caller's own offer and its timeout.
And the lock screen, reported straight after: answering there ran CallKit's
answer action, which called `acceptIncomingCall`, which asked CallKit to
answer the call it was already answering. That request fails, and the failure
path reports the call ended, so answering from the lock screen hung up
instantly. It only asks when the app is the one answering now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.18.0 went to TestFlight with calls broken: an answer never left the
phone, because CallKit answering its own call fetched the waiting offer and
stopped there. Proved four times against a real handset, the relay logging
the offer arriving and nothing coming back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`FlamenetMessenger.xcodeproj` is generated by xcodegen and is not in git. So
a pull updates `project.yml` and leaves the one file Xcode actually reads
exactly as it was. Everything on disk is correct and the project is a day
old, which is how a stale version number reaches an archive, and how the
owner came to say "i am always looking at a stale project".
`post-merge`, `post-checkout` and `post-rewrite` run the generator, and
`generate.sh` points `core.hooksPath` at them every time it runs, so a fresh
clone picks this up the first time anybody generates rather than needing a
setup step nobody remembers.
It does not help when Xcode already has the project open, because that is
Xcode's cache rather than the file. Closing and reopening is still the answer
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`SimulatorSignInUITests` signs a simulator in and stops. It now also grants
the notification prompt, which belongs to SpringBoard rather than the app and
so cannot be reached through `XCUIApplication()`; an incoming call needs it
and nobody is sitting there to tap Allow.
`PlaceOneCallUITests` places one call from a simulator that is already signed
in. It does not reset, because resetting mints another device on the account
and an account at MAX_DEVICES signs in, reaches the shell, and quietly has no
device at all.
Known limitation, stated rather than discovered later: it adds the contact
through the app's own flow, and the add lands on the server but the new-chat
picker reads `knownBuddies`, which is refreshed on a twenty-second loop. So
the first run after an add finds the picker empty and the second finds it
fine. That staleness is worth fixing in the app rather than worked around
here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A push reports the call to CallKit, which draws the full-screen
answer-or-decline. The app posted a notification banner for the same call on
top of that. One call, two screens, and two buttons that do different
things: CallKit's Answer answers it, the banner opens the app and answers
nothing at all. Tapping the banner left somebody in an app showing no call
while the system's call screen was still up behind it.
From the owner, on a real phone: "the call screen appears but when the app
auto opens it doesn't show a call while the call screen is up". Answering
from the system's own screen is the whole point of reporting the call to it,
and the app should not be competing for that tap.
The banner still exists for the case it is actually for, a call the system
knows nothing about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects, all seen on a real handset this morning.
**Answering did nothing for twenty seconds.** The lock-screen answer path
fetched before it accepted, on the reasoning that the offer might not have
landed. It usually has: the push woke the app and the fetch that followed
brought it with it. Asking again means asking the relay for something new
when there is nothing new, and the relay HOLDS such a request open for up
to twenty seconds by design -- that hold is how a message arrives without
polling every second. So pressing Answer sat there, and the caller gave up
before anything happened. It now accepts at once when there is a call to
accept, and only fetches when there is not, without letting the relay hold
the request.
`pollOnce` takes how long the relay may hold it. The steady loop still
wants twenty; anything a person is waiting on wants nought.
**A call that was answered sent nothing at all.** Proved three times
against the owner's iPhone: the relay logged the offer arriving and not one
envelope coming back. Fixed on main last night; this is the latency half of
the same path.
**A missed call left no trace.** An end whose call id this process had
never seen was remembered and dropped. The app is suspended between a ring
and the caller giving up on it forty-five seconds later, so that end
routinely lands in a process with no memory of the offer: no ring, no
entry, nothing to show anybody had rung. It now files a missed call, guarded
so a call that did ring here is not filed twice.
Build 19. Same release, corrected: 18 went to Apple with calls broken.
Four source-reading tests needed correcting rather than loosening. One
pinned the old fetch-then-answer order and now pins the new one, with the
reason. The other three read a fixed number of characters after a marker,
and a comment added inside the branch pushed the asserted line out of the
window -- a failure that looks exactly like the behaviour being gone. Those
windows are wider now. Worth remembering that these tests assert structure,
not behaviour: they would not have caught the interaction that broke calls
in 0.17.0, and they will not catch the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`SimulatorSignInUITests` signs a simulator in and stops there. No reset, no
account creation, no messages: running it again on a simulator that is
already signed in leaves it alone, because resetting is what mints a second
device on an account that has a perfectly good one.
`docs/TESTING-CALLS.md` is the evening's traps, which cost more time than
the bugs did. The worst by a distance: an account at MAX_DEVICES signs in,
mints a device locally, logs it, reaches the signed-in shell -- and the
relay refused to register it. That install has no inbox and no contacts and
says nothing about it. It looks exactly like a working app, and four
attempts to place a test call died on it before the cause was clear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A trace of one real call showed the whole of the outgoing signalling:
20:46:12.244 transmit call-end to 123 (98 bytes)
20:46:12.245 transmit call-offer+sdp to 123 (3651 bytes)
A hangup one millisecond before the offer it belongs to. `finish` resumes
the ICE-gathering wait, so a call ended while its offer was still being
built came back out of that await and sent the offer anyway. The person
being called got both, for the same call id, in whichever order the relay
handed them over: the ring appeared and vanished, or never appeared at
all because the end had already marked the call over. A race, which is
why none of the reported behaviour was consistent.
Six more, all seen on real devices this evening:
- Answering inside the app told CallKit nothing. The system rang on over
the conversation and, because a call CallKit believes it owns also owns
the audio session, the app never brought its own audio up; CallKit then
ended the call it thought nobody had taken. "Answer it on the iPad and
it never connects."
- Every VoIP push has to report a call, and most are not a ring: the end
of a call, a busy, a signal for a call long over. Nothing took those
back, so the phone rang for a call that did not exist. "I place a call,
it calls me back." They are now taken back as soon as the fetch says
what the push was.
- Answering from the lock screen fetched the offer and never accepted it,
and an answer that beat the offer to the phone was discarded. It now
waits fifteen seconds for the offer it belongs to, then lapses.
- The ICE-connected handler was two sequential `if`s and the second read
the state the first had just changed, so a call that reached
`connecting` before ICE finished -- nearly all of them -- never told
CallKit it had connected. The placing phone sat on "connecting".
- `tellOwnDevices("answered-elsewhere")` had been moved below the answer,
leaving the other device ringing until a peer connection was built, and
for ever if any of it threw.
- A caller who is not a contact showed as "Buddy 128". Sealed sender means
the push carries no name and the relay cannot add one, but it has had an
id-to-handle route since first-contact adoption; the call path never
asked it. It does now, and adopts them, so the call log and the
conversation after it carry the name too.
And two outside the call path:
- The Messages list was drawn from the contact list alone, so a first
message from somebody new was decrypted, filed and counted in the badge
with no conversation anywhere. Adopting now runs from the app rather
than from whichever tab happens to be on screen, which is why leaving
the app and coming back was what made a new chat appear.
- The alerts badge and the alerts list counted separately, so reading an
alert cleared the row and left the badge standing until the next launch.
`transmit` now logs the KIND of what it sends, and `finish` logs why a
call ended and whether the peer was told. Neither logs content. The trace
above is what they are for, and what took all evening to obtain without
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Somebody lost every message they had. This is how.
`historyKey()` read the key from the keychain and, on any failure at all,
minted a fresh random one and wrote it over the old. `readKeychain` returned
nil for every non-success status, so "the keychain cannot answer right now"
was indistinguishable from "there is no key yet". The keychain item is
`AfterFirstUnlockThisDeviceOnly`, and the app is woken in the background by
VoIP pushes, so a call arriving before the first unlock after a reboot is
enough. One unreadable moment and the old key is gone.
What follows is worse than losing the key. The sealed history can no longer
be opened, `load()` returns nil, the app starts with empty threads, and the
next arriving message seals that emptiness over the file. Every step is
individually reasonable. Together they destroy the history and there is
nothing left to recover it with.
Three changes, because any one alone still loses the messages.
`readKeychain` now says whether the item was absent or merely unreadable.
`historyKey()` mints a replacement only on `errSecItemNotFound` and returns
nil otherwise, so the app does without rather than overwriting. And the store
refuses to write at all over a history it could not open: a file that exists
and will not decrypt marks the account unwritable until a load succeeds, so
an empty snapshot can never replace a full one even if a key goes missing
some other way.
The refusal is visible rather than silent, and the copy says the thing that
matters: the old messages are still there and are deliberately not being
written over. The other failure cases mean data is being lost, and telling
somebody the same thing here would send them to reinstall and destroy the
file this is protecting.
Five tests, including the whole sequence end to end. This bug has been in
every build since history at rest was added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pin was nine commits behind, all of them spec. flamenet-e2e gained the
text for what 0.18 shipped: off the record, anonymous polls, handing a call
to another device, the private-reaction flag and the call-reaction kind, and
a call answered on one device stopping the others. Plus the harness fix that
reads a page's stylesheet href rather than naming the file.
No vector moved. Copying test/vectors over Tests/Vectors changed nothing, so
this accepts no protocol change: the app was already implementing these
features against a spec that had not yet described them.
flamenet-messenger-android pins the same commit and moves in step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Polls nobody can attribute, handing a call to another of your own devices,
Face ID opening Files and the wallet, the password per area, and the call
fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs behind one report: a group arrived on the other phone only after
the app was restarted, and when it did, it was empty.
The poll was never written down. `pollBook` was passed to the save call, so
the code read as though it worked, but the history writer watches threads,
group threads, calls, outbox, stories and drafts, and a poll changes none of
them. Nothing triggered a write, so the poll lived in memory until the app
closed. A poll waits for every member to vote and then to publish, which is
not a thing that finishes in one sitting, so it has to cross restarts or it
never completes at all. `pollBook` is now in the writer rather than merely
carried by it.
And accepting an invitation stored the key that opens the group's roster,
dropped the group from the list because the relay's view of it stops
counting once it is sealed, and then fetched nothing. Only `start()` ever
pulled it back, which is why a group somebody made for you did not appear
until you next opened the app.
Four tests. The round trip with a vote, a piece and a published sum; the
extremes of a share, which are the normal case and not an edge; a snapshot
written before polls existed still decoding; and the one that would have
caught this, that a poll arriving is enough to trigger a write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`SensitiveScreenTests` asserted that `SensitiveScreen(` appears only on
rarely-visited screens, and the per-area password deliberately puts it in
front of messages, Files and the wallet. Three files, three failures, and the
guard was right to fire: a lock on something somebody opens twenty times a
day is a lock they turn off, and then it protects nothing.
What changed is the reason those three are allowed to carry it. They are not
rare, so they answer to the other rule instead: nobody meets that gate unless
they asked for it. The test now enforces that rather than widening the
allowlist into a list of exceptions. Each of the three must reference its own
preference and the app lock, and any other screen that grows a gate still has
to justify itself as one people rarely visit.
It also fails if one of the three loses its gate, which the old shape could
not have noticed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three switches under the lock, for messages, files and the wallet. The
lock is the requirement; this makes the password a second step on top of
it, and it is the passphrase that already opens Files rather than a new
secret to remember.
Where it is on, the kept key stands down: files and the wallet ask for
the passphrase again, because a second factor the first factor can skip
past is decoration. For messages there is no unlock of its own, so the
tab is what the gate wraps.
The password is checked by deriving the key with it. A wrong one cannot
open the vault, so it cannot open the gate either, and there is no string
comparison to get past.
What it is worth differs by place, and the footer says so. For files and
the wallet the password is the key itself. For messages it guards the
screen: those messages are already readable on this phone, so it keeps
out somebody holding it and adds no encryption. An account that has never
set a passphrase is let through rather than shut out of its own messages
by a setting it cannot satisfy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the app lock on, unlocking with your passphrase keeps the key it
derives, and after that a face, a finger or your passcode opens both. With
the lock off nothing is kept and the passphrase is required, as before.
Turning the lock off forgets the key, and a sweep at launch makes that
true even though the switch lives in Settings.
This existed once and was removed because it never worked: the biometric
requirement sat on the keychain item, so the read prompted from inside
itself and returned nothing, twice patched and still broken. The two
halves are separate here. LocalAuthentication proves who is holding the
phone, through the same call the app lock already makes, and only then is
an ordinary keychain item read. A read that cannot prompt cannot deadlock
against its own prompt, and a test pins that order.
What is kept is the derived key, never the passphrase: that is the one
you sign in with. The unlock screens now say which of the two states they
are in, because claiming the passphrase is never kept while keeping the
key it derives would be true only by a technicality.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a call on the phone, walk to the desk, take it on the tablet, and keep
talking. The other person hears a short gap and nothing else.
This rides the channel that already exists: the device that answers a call
tells this account's others to stop ringing. Handoff is that channel carrying
two more signals. Nothing new reaches the relay.
Every rule here is about not breaking a call that is currently working.
The peer is never hung up on. No end is sent to them at any point in either
direction, including when taking the call fails, because the other device is
still carrying it until the moment the offer is away. An end would make the
far end drop the call and redial, which is the failure this replaces.
An offer nobody takes lapses at both ends rather than being withdrawn. A
withdrawal envelope can be lost and leave a device believing an offer still
stands; a timeout cannot, and either way the call stays exactly where it is.
And whether the far end can take a renegotiating offer is known before any of
this starts, from a flag both ends set during call setup. A build that
predates handoff sets nothing, absent reads as no, and the control is absent
rather than disabled. Finding out by dropping the call is the wrong way to
find out.
On the receiving side an offer for the call already in progress is answered
where it stands: no ring, no second entry in the call log, and the call
screen never goes away.
11 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card at the top of the group thread rather than a bubble in it: a poll
changes as pieces arrive and has to stay reachable while people are still
voting. Asking one is in the group menu.
The copy is the part with a test on it. The guarantee here is a collusion
bound, not secrecy, so the screens say everyone else would have to work
together to know how one person voted, and never that nobody can know. A poll
of two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why. The rule that everybody must vote before there is a result is said
before somebody votes rather than after.
A count renders from exactly one branch, the one where every member has
published. Counting what has arrived so far would show a number that is noise
rather than a partial answer, and it would look entirely plausible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asking a poll fans it out to the group. Voting does not: each piece is
addressed to the member it belongs to and sent to them alone. That is the one
group envelope in the app which is not the same bytes to everybody, and the
reason is the whole feature, so a test pins it. Fanning a share out would
hand every member the same piece, and any one of them could subtract it from
the tally to read the vote, with the counts still coming out right and
nothing at runtime to notice.
Publishing stays gated on the book: a sum sent before the last vote arrives
leaves noise in the total rather than a smaller count.
Polls are kept at rest, or one could never finish. It waits for every member,
and the pieces already gathered would be gone the first time the app closed.
They are deliberately not carried into a D4 export: a poll restored days
later belongs to a group that has moved on, and the thing somebody wants back
is the result, which is already a message in the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A book of every poll this device knows and what it owes each one. Held
separately because the ordering is the part that goes wrong and it is worth
testing without a relay.
The rule it exists to enforce: no sum is published until every member has
voted. Not "most", not "after a while". A member who has not voted has handed
nobody a piece, so nobody holds a complete set, and a sum published early is
noise rather than a partial answer. The standing it reports names who it is
waiting on, because "waiting" with nobody named is not something a person can
act on.
Redelivery is the other one. A piece or a sum counted twice is a wrong tally
and nothing downstream would notice, so both are idempotent, a second vote
sends nothing, and a redelivered poll does not reset the votes gathered
against it.
12 tests, including a whole three-person poll carried end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android engine has had the secure-aggregation core since it was written;
iOS had nothing, so a poll could not have crossed between them. This is the
same scheme in Swift, plus the three envelope kinds on both sides of the
wire.
The ring is 32 bits, and that is the part worth stating: Kotlin's `Int` is 32
bits and wraps, Swift's is 64, so `Int32` with `&+` and `&-` here. Plain `+`
would trap on exactly the overflow the hiding depends on, and `Int` would
make the same shares add up to a different number, which the two engines
would experience as disagreeing about the result of a vote rather than as an
error.
19 tests. The tally ones ask what the scheme guarantees rather than what the
code looks like: the counts come out right, no piece and no proper subset of
pieces recovers a vote, and a member who never publishes gives noise rather
than a smaller count, which is the rule the interface has to obey.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Envelopes are fetched in batches, so the caller's hang-up is regularly
decrypted before their offer. The end was checked against the call in
progress and, when it matched nothing, dropped and forgotten. The offer
behind it then rang a moment later for a call that was already over.
Answering that ring did nothing, because nobody was on the other end, and
the next queued offer arrived behind it.
An end is now filed whether or not it matches the call in progress, and
every signal is checked against that before it can ring, connect, or wait
on anything. The call this device ends is filed the same way, so a late
signal from the peer cannot restart it.
The memory is bounded oldest-first at 200. A phone left running for weeks
must not keep every call id it has ever seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It left a "Missed call" line in the conversation and nothing else. The
Calls screen is where somebody goes to find out who rang, and a call the
phone slept through was the one kind it never showed, so the log filled up
with outgoing calls alone.
It still appears in the conversation too. The two surfaces answer different
questions and one does not replace the other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all on the path between hanging up and the other phone
going quiet.
Only a call OFFER was marked time-critical, so only an offer got the VoIP
push that reaches a locked, suspended phone. Hanging up sent the end as
ordinary traffic, which a sleeping phone does not wake for, and it went on
ringing for a caller who had already gone. The end of a call is as
time-critical as its start, on both clients.
A push arriving for a call already on screen used to overwrite the id of
the ringing call. Nothing held that id afterwards, so nothing could end it
and it rang on alone. PushKit still demands a reported call out of that
handler, so report a throwaway and close it in the same breath.
And answering told this account's other devices before it told the caller,
putting a key fetch for every one of them in front of the answer on the
same actor. The caller sat on "Calling" through all of it. The person
waiting is the caller; the tablet in the next room can wait its turn.
The urgent rule is now tested for what it decides rather than for how it
is written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the Android release going out with the same three features, so
one number describes both. Build 17 has never been uploaded under this
version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files asked for the passphrase every time the tab was opened. Unlocking
publishes the key so the wallet and saving a received document can use
it, and the wallet reuses it; Files set it and never looked, so it asked
for something the app was holding in memory. It now adopts that key, and
signing out still takes it away. Typing it once a launch is the design,
because the passphrase is stored nowhere; typing it on every visit was
not.
The wallet's Done button did nothing. That screen is a tab as well as a
sheet from Home, and in a tab there is nothing above it to close, so the
button called dismiss into the void. Removed: the sheet still closes by
swiping it down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A switch in the conversation's own menu, and a line under the header
saying so while it is on.
The switch asks; it does not assert. What it shows is what the other side
has echoed back. That echo arrives as control traffic which appends no
message, so nothing on screen would change when it lands: the setting is
published from the inbox the conversation already watches, rather than
re-read when the thread opens, which would leave the banner claiming the
old state until it was closed and opened again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A conversation held in memory and written to disk by neither side. Close
the app and it is gone: no file to seize, no backup to leak.
It is a proposal, not a switch. Nothing is saved until the peer echoes
the envelope back, because a build that does not know the kind never
echoes and would go on writing while this side believed it had stopped.
Persistence here is reactive: the whole snapshot is written whenever the
state changes, so the gate is what goes into the snapshot rather than a
call that writes one message. Both paths to disk, the live writer and the
backup, pass through one filter, and a test counts those call sites so a
third cannot appear without going through it too.
The control for it comes next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tap a reaction while somebody is talking and it shows on their screen
for a moment. Agreement without interrupting, on a voice call as well as
video.
The signal rides the ratchet like every other call signal, is accepted
only for the call in progress and only once it is up, and is recorded
nowhere: when the call ends there is no trace of it. An older build
ignores an action it does not know, and the emoji key is absent on every
other kind, so nothing else on the wire changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React to a message in a group and only its author sees it. The reaction
is sent to the author and to nobody else, which is the whole guarantee:
asking the other members' apps not to draw it would be a request, and a
request is not privacy. The flag on the envelope only lets the author's
app label what it received.
Private reactions are kept apart from the public tally, and the chip
reads from whichever side you are on: the sender is told who saw it, the
author is told that nobody else did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A synthesized decoder throws on a key that is not there rather than
falling back to the property's default, so giving group messages their
reactions made every snapshot written before today fail to decode. The
repo's own test for old snapshots caught it.
Stored optional and read through a computed property, the arrangement a
story already uses for its expiry and key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android has carried reactions on group messages since the beginning. On
this side the send path took a buddy id and nothing else, group messages
had nowhere to keep a reaction, and an arriving one was folded into the
one-to-one with whoever sent it, landing on the wrong message in the
wrong conversation or on none at all.
Group messages now carry reactions, the envelope names the group, an
arriving reaction is routed by that group id after the same membership
check group messages pass, and the backup carries them so a restore does
not drop them. The group thread gets the same row of emoji the one-to-one
menu has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
allowBluetooth is renamed allowBluetoothHFP, the same 0x4 flag,
available on every iOS this app supports. The outgoing call's handle is
built inside the main-actor closure instead of captured, since CXHandle
is not Sendable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In the right-hand column the passphrase field ran edge to edge. Capped
at 440 points and centered; a phone is narrower than that, so iPhone is
unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build 16 was archived as 0.16.0 from Xcode and discarded; 17 keeps the
build number above anything already archived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The answer goes to the caller only, so an iPad on the same account kept
ringing for its full minute after the iPhone picked up, and then filed
the call as missed. The device that answers or declines, in the app or
from the lock screen, now sends call-end to its own account's other
devices, reason answered-elsewhere or declined-elsewhere. A device still
ringing for that call stops, tells CallKit it was answered or declined
elsewhere, and files nothing; one already on the call ignores it.
A send to this account's own devices carries no delivery key or key-log
prelude and is not refused for going unsealed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build 15 went up and is not for testers. It has two faults, both fixed
on main since:
- With one account on two devices, the second device's ring guard sent
the caller a timeout when it expired, and the caller hung up the call
that had been answered on the first device.
- On iPad, Files, Wallet and Account were squeezed into the list column
beside a "Pick a conversation" placeholder.
Same release, corrected, so the version stays 0.15.0 and only the build
number moves, which is what App Store Connect needs to accept it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Home, Messages and Calls are lists that lead to a conversation, which is
what a second column is for. Everything else is a single screen, and
splitting those went badly: Files and the wallet pushed their unlock
screen into a column a third of the width, and the other two thirds said
"Pick a conversation" on a tab with no conversations in it. Account did
the same. Found in the 13-inch screenshot set.
Those tabs now take the whole width, the way a settings screen does on a
tablet. Whether a row opens beside the list or on top of it follows
whether the right-hand column is actually showing, so a row on a
full-width tab cannot open into a column that is not there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With one account on two devices, an iPhone and an iPad now being the
ordinary case, both ring. Answer on one and the other keeps ringing,
because nothing tells it. When its ring guard fired it sent the caller
an end with reason "timeout", the caller matched only the call id, and
the call in progress was hung up.
The guard was added this week, so before it the second device simply
rang until the caller hung up, and the call survived. This is the fix
for a regression, not an old bug.
Two halves, both needed.
The ring guard releases the ring locally and sends nothing. The caller
gives up on its own 45 second timer long before this fires, so the
message was redundant, and on a shared account it was destructive.
Once a call has connected, the end handler ignores an end whose reason
means the ring was never picked up: declined, busy or timeout. Those can
only have come from another device that was still ringing, somebody
silencing their iPad, or an Android build already in people's hands that
still announces its timeout. A plain hang-up carries no such reason and
still ends the call. A ringing call still stops for any of them.
`RingTimesOutTests` asserted the old behaviour line for line; it now
guards the new one, bounded to the function each check is about so a
neighbouring function cannot fail or pass it. Its Android half only runs
with the two checkouts side by side under the expected names, so Android
guards the same fix in its own repo, where its CI runs it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.14.0 build 14 is on TestFlight. This is the release that follows it,
and it carries everything merged since:
- The Face ID passphrase store is gone from Files and the wallet, and
the passphrases it had saved are purged from the keychain at launch.
- A call nobody answers stops ringing on its own.
- The iPad: two columns, the lists beside the conversation, in every
orientation App Store Connect requires.
- The backup no longer drops replies, stickers, link previews, drafts
or story sets.
- The call screen offers the key review itself, and four UI fixes.
Build number follows the version, as it has since 0.13.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App Store Connect requires an iPad build to support all four
orientations unless it gives up Split View and Slide Over, so every
screen has to survive being wide and short. That was the one part of
the iPad change nobody had seen.
Rotating the simulator window from outside does not reach iOS at all.
The window turned; the device kept reporting portrait, the status bar
stayed on the short edge, and the app never received an orientation
change. So this rotates from inside, with `XCUIDevice.shared.orientation`,
which is the one rotation the app is guaranteed to get.
It signs in, photographs Home, Messages, Calls and Account both ways up,
then opens a conversation into the right-hand column and photographs it
in portrait, both landscapes, upside down, and in landscape with the
keyboard up, which is the case most likely to leave the transcript no
room. All of them hold: the list stays on the left, the conversation on
the right, and with the keyboard up the composer and header stay visible
while the transcript shrinks.
Credentials come from the environment only, since this repository is
public. It skips on a phone rather than photographing the phone layout
and calling it iPad.
`ipad-build/` is ignored alongside `ci-build/`, for the same reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seeing it run found what reading it did not. Opening a chat from the
recents row put the conversation INSIDE the left column, squeezed into
the list, beside an empty right-hand side still saying "Pick a
conversation". That path set `openedRecent`, which pushes onto the
list's own stack, and in two columns that stack is the left one.
Checked every place a conversation is opened from rather than fixing
the one that was caught. There are seven, across three different
screens: a buddy on this relay, somebody on another relay, and a group.
Any list left out does not fail loudly; it pushes its screen into the
sidebar, which is exactly how the recents row looked fine in a diff.
So the selection now carries all three kinds, and the right-hand column
renders whichever it is. `SplitPane.open` answers the one question every
list needs asked, whether there is a column to open into, and returns
false on a phone so the caller pushes as it always has. The three
recents, search and new-chat sites go through one helper; groups and
federated conversations go through `ConversationLink`, which takes the
phone's destination as a closure because only the list knows how to
build it.
One slip on the way, caught before it ran: rewriting the call sites also
rewrote the helper's own body into a call to itself.
Verified on an iPad Pro 11-inch simulator, signed in as the review
account: the Messages list on the left, the conversation with
fnreviewbeta on the right with its header, transcript and composer. The
iPhone suite is unchanged at 126 tests in 24 suites.
Not verified: landscape. The built app declares all four iPad
orientations and the iPhone stays portrait, checked in the built
Info.plist, but the simulator panel cannot be rotated from here, so no
screen has been seen wide-and-short yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app was iPhone-only by build setting. It now targets both, and an
iPad gets the layout a tablet is for: the lists on the left, the
conversation on the right, the way every messaging app on a tablet does
it. Nothing about the protocol, the storage or the call path changes.
The device family had to move at TARGET level, not project level. The
comment already in `project.yml` says why, and it is the reason an
archive once shipped as iPhone+iPad by accident: xcodegen emits a target
default of "1,2" that silently overrides whatever the project says.
Orientations are the part that is not free, and the issue said so
before this started. App Store Connect requires an iPad build to support
all four unless it declares `UIRequiresFullScreen`, and declaring that
opts out of Split View and Slide Over, which is most of what running on
an iPad means. So the iPad gets all four and the iPhone stays portrait,
because nothing about holding a phone sideways improves a chat.
The split itself is one decision in one place. `SplitPane` holds which
conversation the right-hand column is showing and whether there is room
for two columns at all, and `ChatLink` is the row that pushes on a phone
and fills the column on an iPad. That is what keeps the choice out of
every list: `MessagesListView` changes by one wrapper, `BuddyListView`
by routing its two open sites through one helper, and the phone's
behaviour is the branch that was already there.
It reads the SIZE CLASS, not the device. An iPad in Slide Over is as
narrow as a phone and wants the phone's layout, and something asking
`userInterfaceIdiom` would get that wrong and keep getting it wrong
while somebody dragged the divider. Narrowing also drops the selected
conversation, so a chat is never left open with nothing on screen
pointing at it.
Builds for iPad, and the iPhone suite is unchanged at 126 tests in 24
suites. The two-column layout itself is not yet verified running,
because driving the simulator needs device access this session does not
have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ci-build` reaches ~700 MB and is rewritten every job, on a machine that
is also the mac-local runner for both app repos, so indexing it lands in
the merge path for everything. `mds_stores` and `mdworker_shared` sit at
the top of CPU while no `xcodebuild` process is near it, which is why
the load looked unexplainable.
Measured, same from-scratch build twice: load peaks at 12.5 with the
output excluded and over a hundred without, and the indexing stops
accumulating rather than trailing the build by minutes.
The marker is written by the job rather than committed, because the
directory it belongs in is created by the build and removed between
runs. The stable caches outside the repo were excluded on the host
itself: DerivedData, the Android SDK, the runner's builds directory and
the Gradle home, 11 GB between them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The content layer had no vectors. Everything from section 6.6 onward was
guarded by round trips through this same codec, which would pass
unchanged if this app and Android disagreed about every key. That is how
SPEC 6.18 specified `ment` for as long as it existed while both clients
have always sent `men`: the only thing that could have caught it is a
conformance test written from the spec, and there was none.
`ContentVectorGenerator` builds fifteen envelopes with the shipped
encoders and, when asked, writes them out. That file is now the
published vector. Generating it by RUNNING the encoders is the point:
transcribing a wire format by reading it is exactly how `ment` got into
the spec.
Android reads the same file with a different codebase, which is the
independent half. This side checks drift: the vector records what these
encoders emit, so if somebody changes one, every implementation built
against that file is wrong from that moment, and this turns it into a
failed test rather than a report from somebody whose mentions stopped
working.
The comparison is structural, and it has to be. Nested objects here go
through `JSONEncoder`, which does not order its keys, so the same
message encodes as `{"mid":…,"author":…}` on one run and
`{"author":…,"mid":…}` on the next. That costs nothing on the wire,
where everyone parses, but it does mean a vector can pin what an
envelope says and never the bytes it says it in. Comparing strings
failed nine of fifteen cases on the first run, every one of them key
order, not one a difference any receiver could observe.
The drift gate did not cover `content-v1.json`, and had not covered
`backup-v1.json` either until yesterday. Both loops now list it.
Proven capable of failing: flipping `men` to `ment` in the pinned file
turns three tests red.
Full suite: 126 tests in 24 suites, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both apps were taught one backup shape and nothing checked that they
agreed. Each side's tests asserted against what the OTHER was believed
to write, transcribed by hand from reading its source, which tests one
person's reading rather than interoperability.
`backup-payload-v1.json` is now the authority and the bar is exact:
decode the canonical payload, encode it again, and the result must equal
what is pinned. Android checks the same file, so the two are compared
against one document rather than against each other's source.
The drift gate did not cover `backup-v1.json` either, in both the pinned
job and the advisory one that runs at vectors HEAD, so the envelope
vector could have moved upstream without anything here noticing. Both
loops now cover it and the new payload vector. Verified that every
copied file matches the pinned commit before widening the gate, rather
than widening it and finding out from a red pipeline.
Full app suite: 121 tests in 22 suites, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the outbox to the backup was wrong, and the source check is what
made me look at it rather than let it through.
`flushOutbox` runs on every poll cycle, a few seconds apart. Anything
restored into the outbox is therefore sent almost immediately. A backup
exists to be opened on a different phone, possibly months later, so
carrying queued sends would mean a restore quietly delivering messages
somebody wrote long ago to people no longer expecting them. A scheduled
send with a `notBefore` in the past would go out at once.
Losing them across a device move is the safer of the two failures, and
it is the one a person can actually see. Within a device nothing
changes: `E2EInbox` still restores the outbox from local history, so an
unsent message survives a relaunch exactly as before.
It is listed in `notCarried` with the reason rather than dropped
silently, which is the whole point of that list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The biometrics test kept two expectations from before the removal, both
requiring copy that went with it: the "is on" confirmation and the line
saying the passphrase still works. Renaming the test and replacing its
first assertion left those two behind, so the suite demanded the exact
thing the commit deleted.
They now assert the absence they should have from the start.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`E2EStory.set` says which item of a multi-part story this is, and the
backup never wrote it, so a set restored as loose pictures that no longer
knew they belonged together.
The source check now reads `E2EStory` as well, and skips computed
properties, which have nothing to carry and are derived from something
already on the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Snapshot` declares fifteen properties and the encoder named eight, so
an export forgot the account's own settings as well as the contents of
its messages: unsent mail in the outbox, half-written drafts, who your
stories go to, who you had muted, your own story archive, and whether
the roster is being kept off the server.
Same defect as the message fields, one level up, and it survived for the
same reason: everything it drops is state you set once and rarely look
at, so a restore that lost it looks like an account that never had it.
The source check now reads `Snapshot` too. Its collections are encoded
by name rather than through an accessor, so they are listed as checked
by the round trips instead of by the source scan, with the reason
written down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`E2EBackupPayload` builds a fresh JSON object out of typed properties, so
anything it does not name by hand is not carried. It named five things on
a one-to-one message and four on a group message, and `E2EMessage`
persists eleven.
So the cross-platform format shipped without replies, stickers, link
previews or delivery state, and group messages additionally lost their
message id and whether they mentioned you. Nothing failed. A backup that
restores a conversation with the replies flattened into loose messages
looks exactly like a backup of a conversation that had no replies, which
is why this survived a review and a merge.
The file's own doc comment claimed unknown keys survived a round trip
untouched. They do not, and never did in this implementation. That claim
is replaced with what is actually true, which is that every persisted
field has to be listed here by hand.
Keys are the ones both apps already use on the wire, `re`, `stk`, `lnk`
and `delivery`, so the Android half of this is a copy rather than a
translation. All of them are additive and optional, so a v1 file written
before today still opens, and one written today still opens on a build
from before it.
Two guards. Round-trip tests for each field, and a source check that
reads `E2EMessage` and `E2EGroupMessage` and fails if either grows a
property the encoder never mentions, so the next field added is either
carried or deliberately listed as left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files and the wallet offered to keep your vault passphrase on the phone,
released by Face ID or Touch ID. On a real phone it reported success and
then did nothing: enrolling showed "Face ID is on", and every unlock
afterwards left you at the passphrase field with no prompt, no error and
no explanation. It has now been patched twice, once for the read
deadlocking against its own prompt and once for the write, and it still
does not work.
Part of why it was impossible to tell what had gone wrong is in the
caller. Both screens read it as:
guard let stored = try await FilesPassphraseStore.load(...)
else { return }
and `load` answered nil for three unrelated outcomes: the person
cancelled, the face was not recognised, and no item is stored at all.
Whichever happened, the screen returned silently, so the only symptom
available to anyone was "nothing happens".
Rather than patch it a third time, take it out. App Lock is untouched:
that is a separate mechanism on `.deviceOwnerAuthentication`, it falls
back to the passcode, and it works. Face ID still opens the app; it just
no longer holds a vault passphrase.
Removing the code alone would have been the wrong half of the job. Every
phone that accepted the offer has the passphrase itself in its keychain,
and with the store gone nothing would own it or release it. So the
launch path now deletes those items by service, naming no account so
every handle's goes rather than whichever one is signed in. Deletion
never raises a prompt, and deleting what is not there is a no-op, so it
is safe on every launch.
The two tests that pinned the old store to the main-actor fix are
replaced by one that pins the purge in place, so nobody removes it later
as unused code and leaves the abandoned secrets behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite had not actually run in a long time, so nobody knew.
The message menu asserted a thumb among the quick reactions. The set is
heart, cry-laugh, surprise, sad, fire, and has not had a thumb in it for
a long time; the thumb lives in the emoji picker. The menu was fine and
the expectation had rotted.
The app-lock check swept every static text with a predicate while the
screen was still settling, which is what "Timed out while evaluating UI
query" means. It takes one snapshot and searches that now, the same
lesson the recorder test had already learned and written down.
Account Settings was the only row in the profile without an
accessibility identifier, and the only row the suite could not find. Its
neighbours all have one. A label is whatever a row happened to compose
out of a title, an icon and sometimes a subtitle; an identifier is a
promise the view makes.
And the microphone. `start` returns false when permission was refused
AND when permission was given but the audio route would not open, and
the app blamed permission for both. That sends somebody to a Settings
switch which is already on, and it is why a recorder that cannot start
has never been diagnosable: the failure was misreported. The recorder
says which of the two it was now, and the app says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dialing and connecting each had a timeout and ringing did not, so a call
nobody picked up stayed .ringing until the app was killed. Nothing
showed it: the banner can be dismissed while the state behind it
survives. The phone then answered the next caller busy while looking
completely idle, with nothing anywhere to explain it.
Sixty seconds, longer than the caller's own forty-five, so this end
gives up on its own only when their hang-up never arrived. It ends with
"timeout", which the far end already renders as "No answer"; an invented
reason would have shown a flat "Call ended".
Android had the identical hole and the identical fix. One of the tests
checks the sibling checkout for it, because if one platform's guard
moves without the other, a call between them behaves differently at
each end.
Found on Android hardware, where an offer at 12:51:07 released itself at
12:52:07 and told the caller, rather than refusing every call after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 16 pinned the envelope, the KDF and the canonical header used as
AAD, and said nothing at all about what goes inside it. So each app
sealed its own model and stamped the file with the platform that wrote
it, and each then refused the other's, in as many words: "import it on a
device of the same kind". Moving between an Android phone and an iPhone
lost every conversation on it.
This is the missing half: one payload shape, written by both and read by
both. It rides inside the envelope the vectors already pin, so the
crypto does not change.
Two decisions carry the weight. Times are epoch milliseconds as
integers, because `Date` encodes as seconds since 2001 while Kotlin
holds millis since 1970, and a file that disagreed about the epoch would
restore every message at the wrong time without failing anywhere. And
encoding is deterministic, sorted keys, because a vector cannot be
pinned against bytes that differ run to run.
Attachments travel whole, not just the fields the common case uses. A
voice note without its duration has no UI, and a document without its
filename and classification comes back nameless and unlabelled, which is
worse than not coming back.
Group secrets carry their roster version. The relay refuses a rewrite
whose version does not increase, so a restore that reset it to zero
would lock the device out of its own group.
Reading falls back to the older per-platform file, decided by shape
rather than by extension, so backups made before this build still open
and a file renamed on its way through a cloud drive is still the file it
was.
Android still writes only its own format, so this does not yet let an
Android backup open on an iPhone. Half the bridge.
0.14.0, build 14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CallKit was a doorbell. It rang the phone when a VoIP push arrived and
took no further part, which left four things wrong.
Declining from the lock screen did not decline. The end action cleared
the local ring and fulfilled itself, and nothing told the call manager,
so the caller went on listening to a phone that had already said no.
The audio session was never handed over. There was no didActivate and
no didDeactivate, and manual audio was set only on the Simulator, so on
a device WebRTC raced CallKit to bring the session up. Doing that first
is the documented way to get a call that connects and carries no sound.
The unit is now off by default and exactly one thing turns it on:
CallKit for a call it owns, the manager for one placed or answered
inside the app.
Ending a call never told CallKit either, and a call left open there
holds the phone in an in-call state and refuses the next incoming one.
And outgoing calls were invisible to iOS: no Recents entry, no session
ownership, nothing stopping the phone ringing for something else in the
middle of one. They are reported now, connecting and connected.
⚠️ The audio path is reasoned from Apple's and WebRTC's contract and is
NOT observed. A Simulator cannot exercise it, which is why none of this
was noticed. iPhone call audio has never been proven on hardware, and
this is the most likely explanation; it is also the change most in need
of a real device before it is trusted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review is presented from a sheet inside a fullScreenCover, and it
was the only thing behind that cover reaching for an environment object.
An absent one is a crash, not a blank view, and the path it would crash
on is a call that has just failed, which is the worst moment to find
out.
Nothing proved it would. SwiftUI does carry the environment across a
presentation, and the unit suite says nothing either way because it
never builds the view hierarchy. That is the point: the only evidence
was that it ought to work, on a screen reached exactly when something
has already gone wrong.
So it takes the inbox as a plain value, handed down from the tab view
that already holds it. `CallScreenBody` next door does the same thing
for the same class of reason, which is what the convention here already
was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.13.0 build 13 is on TestFlight. The build number moves now rather than
at the next archive, because a duplicate is rejected at the END of an
upload and finding out then costs the whole round trip.
MARKETING_VERSION deliberately stays where it is. It moves when a
release is called, not when a fix lands. Build 14 cannot collide
whatever it ends up carrying, and if that turns out to be a new version,
0.14.0 puts the two back in line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ios#73. A call refused by the trust gate already said so, and already
offered a way to the review. The way led to the conversation.
That is the same gap one screen along. The reason the conversation
banner does not cover this case is that a call started from the Calls
tab, or answered from a notification, never passes through a
conversation; being sent to one to find out why a call failed is the
complaint, not the cure.
So the review is presented in place. It carries the two readings the
banner carries, the ordinary one and the one that matters, and it puts
the safety number on the screen rather than behind another tap, because
comparing the number is the thing that actually settles the question.
This reverses a decision the previous pass wrote a test for. That test
argued a hurried decision is better made somewhere else, so the call
screen pointed at the conversation and offered no accept. The issue
argues the other way, and the issue is right: a decision taken in a
hurry is the reason to say what it means, not the reason to hide it,
and an extra screen in front of the same tap slows nobody down. The
requirement therefore moves rather than disappears, from "no accept
button" to "no accept button without the sentence saying what accepting
costs", and the test now holds it to that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The build number now carries the release it belongs to rather than
counting uploads, so 0.13.0 is build 13. Set here and nowhere else: the
.xcodeproj is gitignored and regenerated, so a version typed into
Xcode's UI survives until the next generate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A proof of possession is single use, and the request that spends one is
often not a request this app made. URLSession replays an idempotent GET
onto a new interface when the phone moves between wi-fi and cellular,
headers and all, so the relay sees the proof twice and refuses the
second, correctly.
Every refusal arrived as a bare `unauthorized`, and the sensible reading
of that is "your token is bad", so the app discarded a perfectly good
token and minted another before it could carry on. Measured on a real
iPhone: it flapped between wi-fi and cellular twice while a call was
being set up, and each flap put a token mint in front of the signalling.
A spent proof is now its own case: sign the request again, keep the
token. The relay names it, and a relay too old to name it still says so
in words, so this works against production as it stands today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine has always carried compartments and LabelChip already rendered
them, falling back to 'Area 3' for a bit it has no name for. No screen let
anybody set one, so a label was half-settable and the public page promised
both halves.
The bits travel with the file; the names are this device's. Nothing is named
for you, and forgetting a name does not free the bit: material already filed
under it still carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three faults, all found on a physical device.
PushKit was started inside the sign-in path. iOS launches the app in the
background to deliver a VoIP push and wants a call reported almost at once;
the registry did not exist yet, so the handler was never called, nothing was
reported, and iOS killed the app. The call appeared only when somebody opened
it by hand. It now starts at launch.
A token issued before the upload closure was wired was dropped, with no
second chance. Both registrars now hold it and send it when there is somewhere
to send it, and keep it pending if the upload fails.
Saving the Files passphrase ran on the main actor, which is where the prompt
it blocks on has to be drawn. The read was fixed and the write was not, which
is why enrolling still did nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build 1 of 0.12.0 is on TestFlight.
Bumped when the previous build goes up rather than when the next one is
archived: a duplicate build number is rejected at the END of an archive, so
the whole round trip is spent learning it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build numbering starts fresh at 1: build numbers are unique within a
marketing version and 0.12.0 has never been uploaded, so this is not a step
backwards from 0.11.0's build 2.
The comment no longer claims to know whether 0.11.0 build 2 was uploaded.
It is not knowable from this repository, and the file should not assert it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec §16, ported from the JavaScript engine and checked against pinned bytes
rather than against that engine's agreement.
Three things the JavaScript would not have surfaced:
WebCrypto appends the GCM tag to the ciphertext and CryptoKit keeps them
apart, so the two engines disagree on ct while each is individually correct
unless they are concatenated here.
The AAD is built by interpolation, not by an encoder. JSONEncoder is the
obvious choice and would silently disagree on key order, and the AAD is what
stops a file being edited into deriving a cheaper key.
The payload crosses the API as bytes, not as a model. Round-tripping through
a Swift type would reorder JSON keys and stop matching what another engine
sealed.
No UI yet: §16.2 requires the passphrase-strength warning and §16.1 requires
saying that peers will see a new device, and both are wording rather than
plumbing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of the four repository READMEs linked to flamenetmessenger.com. The
only occurrence of the domain was inside a security contact address, which
is not a link and is not what somebody arriving at the repository needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fresh install has no contact, no story, no wallet and no vault, so the
plain tab set photographs empty screens. This signs up, builds each state
through the real screens, and captures.
Four things the existing tooling hid:
- Save Password? is a different panel from Use Strong Password?, and arrives
after the form is submitted. Left up it covered every shot after sign-in.
- A safety number comes from the session, so a contact who has just been
added has none. The profile renders a gap where the digits go.
- Wallet sits behind the same vault as Files. WalletUITests never meets that,
because it launches with a preview flag that skips the lock.
- A filter matching no test class exits 0 with TEST SUCCEEDED. Both scripts
now assert on the log that a case actually ran.
No literal passphrase anywhere: the suite skips without the variable and the
script generates one, because these repositories are public and the account
is created on the live relay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kotlin documented no-read-up and no-write-down and pinned the gap in a test.
Swift had only permits, undocumented, so the platform that is the source of
truth was the one saying nothing.
Swift now has mayRead and mayWrite with the same wording, and ClearanceTests
mirrors LabelTest, so enforcing it on one side fails the other and forces
both into the same commit.
The label picker gains the boundary in the place somebody is choosing a
label: it decides who can open a file, not where the file can be put.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat had six of its own, the only place with a thumbs-up and the only
place without a flame. The stories feed and both Android screens already
agreed on five; the chat now agrees with them.
Five also fits the menu row, which the sixth did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two simulators, two accounts, a scripted exchange with each side waiting for
the other's line. One install talking to itself shares a keychain, a device
identity and a ratchet, so it cannot stand in for two people.
Also gives the thread a way to put the keyboard away. It had none: from the
first tap on the composer the keyboard covered half the messages until the
chat was left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lock screen asked for Face ID from a plain .task guarded on the scene
being active. Both a cold launch and a return from the background render it
while the scene is still inactive, so the guard took its early return and
nothing ever asked again: a lock screen showing a Face ID glyph and doing
nothing until tapped.
Reading the Files and wallet passphrase back blocks in SecItemCopyMatching
until the person answers the prompt, and it ran on the main actor, which is
where that prompt has to be drawn. The simulator has no Secure Enclave and
returns at once, so this only ever failed on a phone.
Six reactions in a ControlGroup wrap after five, putting the sixth alone on
a second line.
Also raises the relay health check timeout the UI suites gate on. Three
seconds is not enough on a loaded machine, and six suites skip while
reporting the relay as down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments and docs kept dating themselves: when a constraint was set,
when a pair was verified, what a limit was before some day, which day a
subtree was split out. A reader wants the current state, and the date
goes stale faster than the sentence around it.
Kept, because they are data rather than narration: ISO timestamps in
test fixtures, the certificate expiry in project.yml, which an operator
acts on, and docs/BUGS-2026-09-01.md, whose name is the record.
⚠️ The first pass collapsed indentation. A whitespace rule meant for
prose matched YAML list items and markdown continuation lines, and
project.yml stopped parsing: "did not find expected key: - path:
Sources". The tests still passed, because they ran against the project
generated before the break, which is exactly how this would reach
somebody else. Reverted and redone without touching leading whitespace,
then xcodegen was run to prove the file parses.
764 XCTest and 75 Swift Testing pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing shots are components on a bare ground: bubbles, a story
card, the bar by itself. Good for looking at a control, wrong for a
store listing, where the bar is what makes a picture look like an app
rather than a mockup.
Five screens under the real PillTabBar with the right tab lit:
Messages, Stories, Files, Wallet, Account.
The items are built inside the helper rather than stored on the test.
`Item` is nested in a generic, so `PillTabBar<A>.Item` and
`PillTabBar<B>.Item` are different types and one property cannot be
handed to both.
Files uses the real LabelChip, so the classification on that row is the
component the app draws rather than a drawing of it.
764 XCTest and 80 Swift Testing pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Scripts/screenshots-ios.sh` rendered at 1125x2778... 1125x2436, the
375x812 design canvas at 3x. App Store Connect takes 1242x2688,
2688x1242, 1284x2778 or 2778x1284 and nothing else, so every shot was
rejected on size. The aspect ratios are close enough that rescaling
looks fine and is still not one of those numbers.
`FNMSG_SHOT_SIZE=1284x2778` now asks for a pixel size. Points are
derived from it at a pinned 3x, so the layout gets the room a device
that size really has rather than a scaled-up picture of a smaller one,
and the same request gives the same pixels on any simulator. Unset, the
design canvas is unchanged, which is what it is for.
⚠️ Getting the value into the test took three attempts, recorded in the
script so the next person skips them: `FOO=bar` on an xcodebuild line is
a build setting the test never sees, and the TEST_RUNNER_ prefix only
reaches a UI test's runner, not unit tests hosted in the app. Both
arrived unset. `simctl spawn launchctl setenv` works, and the script
unsets it when no size is asked for, or a previous sized run would
silently apply to the next one.
STORE-LISTING.md carries the rest of the App Store tab: promotional
text 149/170, description 2358/4000, keywords 97/100, support and
marketing URLs, copyright, and why the routing coverage file stays
empty. Every URL in it returns 200. It names no competitor, and says
plainly that the app has not been audited.
764 XCTest and 75 Swift Testing pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ios#73 asked for a test on at least one platform. iOS already had the
behaviour: both call paths name the trust gate, the failure says "Their
security code changed", and the ended-call screen offers "Review keys",
which opens the conversation where the compare and accept banner lives.
None of it was covered.
Five tests, including one that pins the deliberate omission: accepting
keys is NOT a one-tap action on the call screen. It routes to the banner,
which words the decision to the standard the issue asks for; a second
accept button phrased on a call screen in a hurry is what that
requirement exists to prevent.
⚠️ The first version of that test asserted the screen contained no
`label: "Accept"`, which fails on the answer-the-call button. Tightened
to the key-accepting names.
764 XCTest and 80 Swift Testing pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My emoji sweep emptied the iOS and Android columns of the "both
platforms, all four relays" table, so it recorded nothing. Restored as
words, including the one cell that was not a tick: relay3 was never
exercised from iOS and now says n/a rather than being blank like the
rest.
The other files that lost marks are unaffected in meaning: in
SCOPING-v1.md, TESTFLIGHT.md and ROADMAP.md every mark was followed by
its own sentence, so only the glyph is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setting existed and worked, but only inside the story composer: the
audience sheet and the text composer each offered it while posting. So
there was no way to set a default, and no way to find out what the
current one was without starting a story and abandoning it.
It is now in Settings > Chat, reading through StoryLifetime rather than
the raw key, because an unset preference is 0 and 0 is not a lifetime.
The composer still overrides it for one story.
The footer says the expiry travels inside the story itself, which is
what makes it the poster's choice rather than a rule the relay is asked
to keep.
Android has the same gap and is not fixed here.
4 new tests, one of which fails if Settings and the two composers stop
sharing a single list of choices. 764 XCTest and 75 Swift Testing pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflicting files take main's version and the pipeline is re-run on
them. project.yml keeps main's 0.11.0 (2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every comment is reduced to its first sentence plus Parameter, Returns
and Throws tags. Comment lines: 20670 to 7626. Apple's swift-format
applied to every Swift file outside the Obfs4 submodule with a
committed .swift-format: four-space indentation, 100 columns, imports
ordered.
Three test expectations compared source text and depended on line
wrapping; they now compare whitespace-collapsed text.
Comments and formatting only. 764 XCTest and 55 Swift Testing pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The skill's detector scored the README, DESIGN.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 3 out of 100 across the repo.
One sweep rule mangled two comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.
Comments and strings only. 764 XCTest and 55 Swift Testing pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, project.yml and
the markdown docs. Counted before and after:
emoji markers 2539 -> 1 (the emoji picker's own list, which is the feature)
em-dashes 2834 -> 15 (table placeholders and fenced code)
Nothing about behaviour changed. The diff over Sources/ is comments and
string literals only, checked by filtering the diff for anything else,
and the unit suite passes before and after: 764 XCTest and 55 Swift
Testing.
CONTRIBUTING gets a four-line rule so it does not grow back.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A user reported a photo auto-rotating. The suspicion was this app's own
doing: every image sent is re-encoded to strip EXIF — right, because
that block carries the GPS coordinates the shot was taken at — and the
same block carries the Orientation tag. A phone camera rarely writes
upright pixels; it writes landscape pixels plus "turn this 90°". Strip
the tag without baking the rotation in and every portrait photo lands on
its side.
That is not what is happening here. Both re-encode paths draw through
UIKit, which applies imageOrientation, so the output is upright pixels
with no tag needed. The reporter has since confirmed iOS is fine.
Tests anyway, because the failure is invisible until somebody sends a
photo and only reproducible with a real camera file:
🔴 The fixture is built with ImageIO, not UIImage.jpegData(). That was
the first attempt and it made the suite vacuous — jpegData() BAKES the
rotation and writes Orientation = 1, so the bytes under test were
already upright and every assertion passed without exercising anything.
The tag has to be written deliberately. One test now asserts the fixture
itself carries Orientation = 6 over landscape pixels, so the suite fails
loudly if it ever stops testing what it claims to.
Also covers the mirror-image bug: an upright photo must not be rotated
BY the fix.
4 new tests; 764 XCTest + 65 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported by a user: the passphrase could be set to the username.
🔴 This is not the usual weak-password argument, and the usual
reasoning does not reach it. For a relay-local account the KDF salt is
HKDF(lowercased(username)) — a pure function of a name that is public by
design, because it is how people find each other and because a new
device has to derive it offline before it can talk to anything. The
account key is PBKDF2(passphrase, salt), and the address is that key's
public half.
So when the passphrase IS the username, every input is public. Deriving
the identity is one PBKDF2 run rather than a search: the 1.3 million
iterations buy about a second, once, against a guess that is already
correct. Whoever runs it can claim the address and sign as that person
— and the thing they take is the one this product says no server can
take away.
Refused at sign-up, through the same list that already explains why the
button is disabled, so the refusal cannot be silent. It gives the real
reason rather than generic password advice, because the real reason is
what makes it worth retyping.
⚠️ Near misses are refused too. "sunshine1" against the username
"sunshine" is ten guesses; a search space small enough to run over lunch
is not meaningfully different from none. It stops at 8 added characters,
so a memorable phrase that happens to contain the username is still
allowed — pushing people off passphrases toward worse ones would cost
more than it saves.
⚠️ Normalisation matches the KDF's exactly. The salt lowercases and
trims the handle, so "Sunshine" derives the same key as "sunshine"; a
check that disagreed would refuse the safe spelling and permit the
unsafe one.
🔴 KNOWN GAP, not fixed here: this is a client-side check and therefore
bypassable, and it does nothing for accounts that already exist. The
authoritative gate belongs in the signup handler, which is not in any
local checkout — flamenet-api has no messenger routes and
flamenet-accounts-hosting is deploy-only here. Android needs the same
rule. Both are follow-ups.
6 new tests; 764 XCTest + 61 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a physical iPhone: scanning a QR code offered "Open
conversation", opened a cross-relay screen, and returned 403.
Nothing in the app ever compared the scanned relay to the one the device
is signed in to. So every scanned card took the federated path — which
the relay refuses unless federation is open — including an address on
the same relay as the scanner, which is the likeliest thing anybody
scans while testing with two accounts.
Three outcomes now, decided in this order:
- your own address says so, compared through canonical() so a
difference in case or spacing is still yours;
- an address on your own relay says they are on your relay and that
people here are added by username, because refusing with nothing to
try is what the 403 already did;
- anything else takes the cross-relay path as before.
The host comparison is case-insensitive, as hostnames are — an exact
string compare would have sent RELAY.FLAMENET.IO abroad.
This does not open federation. A genuinely foreign address still needs
FN_FEDERATION, which is a policy decision; it stops a local one being
sent somewhere it never had to go.
6 new tests; 764 XCTest + 61 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repo said 1.0 while TestFlight had 0.11.0, and 0.11.0 has never
existed in this file — so the shipped version was typed into Xcode's
UI. The .xcodeproj is gitignored and regenerated by Scripts/generate.sh,
so that setting survived exactly until the next generate. The version
that shipped was not reproducible from the repo.
project.yml is now the only place it is set, and it says 0.11.0 to
match Android's versionName "0.11" (versionCode 13) — the same release,
so the same number. Build 2, because build 1 of 0.11.0 is already up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first version of the picker made the parent folder's level a floor.
That is the ★-property reading and defensible on its own — but Android
has shipped the opposite since its level picker landed, and says so on
the screen in as many words: "A folder's level doesn't limit what you
put inside it — each file keeps its own."
Shipping the floor on iOS only would have left the two apps enforcing
different security rules while drawing the same chip, which is worse
than either rule. Whether the ★-property should be enforced at all is
ios#65 / android#15, open against both, and it should be answered for
both at once rather than by whichever screen was edited last.
Also matches Android's default of level 1 rather than 0. Starting every
item at Unclassified is how everything ended up Unclassified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported from a physical iPhone.
**Everything was Unclassified and nothing could change it.** The vault
has taken a Label since it was written — add, reference and
createFolder all require one — but every call site passed
SecurityLabel(classification: 0). So the browser drew a classification
chip on every row that could only ever read the same word, and the
thing the whole system exists for could not be set.
There is now a picker, used when a folder is made and before files are
imported. It is asked BEFORE the importer opens: there is no way to ask
during a fileImporter, and asking afterwards would mean sealing at one
label and re-sealing at another.
🔴 The parent folder's level is a floor. A Confidential folder holding
an Unclassified file is not untidy filing — it is the label failing to
mean anything, because a reader refused the folder would be granted the
file inside it. Levels below the parent are not offered, and the footer
says why rather than silently dropping choices.
Relabelling an existing item is deliberately NOT here. The label is
bound into the sealed bytes as AAD, so changing it means re-encrypting,
and lowering one is a declassification — a decision with a policy
behind it, not a control on a sheet.
**"Use Face ID" appeared to do nothing, and it had every reason to
look that way.** Writing a keychain item guarded by biometry does not
prompt for a face — authentication happens on the read, next time. So
enrolment ran to success with no scan, no confirmation, and no visible
change, because the vault was already open and even the unlock button
it enables was not on screen. It now confirms, and says the passphrase
still works.
6 new tests; 764 XCTest + 51 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported from a physical iPhone.
**"User #124 calling" instead of Sunshine.** When the relay serves an
empty display_name the client fell back to the account number — while
the username sat unread in the same row. That string is not a harmless
list placeholder: it is what CallKit announces. The fallback is now
display name, then username, then the number as a last resort, and
whitespace no longer counts as a name.
**Names reached the inbox only from Home's .onAppear.** So a phone woken
by a call before Home had ever been drawn — a cold start, a locked
phone, a fresh install, which is most of the cases a call arrives in —
had an empty name table and announced "Buddy 124". Publishing now
belongs to the roster load, both paths, and an empty roster no longer
wipes names already known.
**A scan from the Messages tab did nothing at all.** It set the address
and switched to Home, but Home observed it only through .onChange — and
a tab SwiftUI has not built yet cannot observe a change that already
happened. On the first scan of a launch the address sat in the
environment and nothing appeared. It reads exactly like a scanner that
failed. Home now also takes it on appear, and clears it as it consumes
it so returning later does not re-fill the box.
4 new tests; 764 XCTest + 49 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from TestFlight on a physical iPhone: the button would not
press and there was nothing on screen saying why. Five separate
conditions disabled it and none of them were named — the only signal
was the button's colour.
The one actually hit was a mistyped passphrase. Both fields are dots,
so there is nothing to compare by eye: the person could not have
diagnosed it from anything on the screen.
- canSubmit is now defined AS the list of what is outstanding being
empty, so the two cannot drift. A sixth condition added later is
named automatically rather than silently greying the button.
- The list sits under the button, where somebody who just tried to
press it is already looking, phrased as what is still needed rather
than what is wrong — nobody has made a mistake, they are part-way
through a form.
- A mismatch is also flagged at the confirm field itself, once the
second field has something in it so it does not accuse somebody
mid-type.
- Too short, not typed again, and not matching are told apart. Saying
"they don't match" when one is simply too short sends somebody to
retype a field that was fine.
The year picker was the other trap, and worse than it looked: it is
the one required field that does not look empty when it is. It read
"Choose…", which parses as a value that has been chosen; it sat last
in a scrolling form where the keyboard covers it; and it moved further
off-screen when the passphrase note landed above it yesterday. It now
reads "Not chosen yet", is marked amber while unset, and comes before
the password fields instead of after them.
7 new tests; 764 XCTest + 45 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes#42.
The app's promises about keys and history at rest rest on iOS keeping
other apps out of its container. On a jailbroken phone that separation
is not guaranteed, and somebody deserves to know that before deciding
what to say here.
Three checks: jailbreak artifacts on disk (including /var/jb — a list
that only knows about Cydia has not been looked at since 2019), a
sandbox probe that tries to write outside the container, and whether a
package manager's URL scheme answers.
🔴 It is a notice, never a gate. Nothing branches on it — no refusal,
no degraded mode, no message withheld — and a test enforces that by
walking Sources/ and failing if anything other than the screen that
prints it so much as mentions DeviceIntegrity. Detection is defeatable
by anyone who wanted to defeat it, so the moment it became load-bearing
the app would be trusting a signal that lies to exactly the person it
was meant to stop.
The copy says what actually changes and no more: software on this phone
may be able to read the keys and history, messages are still encrypted
in transit, the people you talk to are unaffected, nothing is being
blocked, and this check can be wrong.
Two things the tests caught rather than my assumptions:
- LSApplicationQueriesSchemes was hand-added to Resources/Info.plist,
which xcodegen regenerates — so it vanished at the next build. Without
it canOpenURL answers false whatever is installed, and the check would
report a clean phone it never examined. It now lives in project.yml.
- The doc comment claimed the simulator trips every check. Measured: it
trips exactly one (/usr/sbin/sshd exists in the runtime root), the
sandbox genuinely holds there, and no package manager answers. One
false positive still justifies the simulator guard — it would put the
warning on every development run — but the comment now says what was
measured, and a test asserts the guard is still load-bearing so it
gets removed deliberately rather than kept out of habit.
7 new tests; 764 XCTest + 28 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReadyForHandoverUITests is a remote control for a paired two-device
session, not a check of this build: the thread it looks for exists
because the OTHER device sent something through a running relay. With
no relay up there is nothing to drive, and it reported that as a
failing test.
So the iOS suite has been ending red on every local run for weeks —
two failures beside 51 honest skips — which is how a suite stops being
read. It now skips with the reason, like the 51 others, and the run
ends green when the app is actually fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes#46.
`E2EHistoryStore.save()` wrote with `try?`. A write that could not
happen was discarded and the app carried on: the message stayed on
screen, everything looked normal, and every message since the last
successful save was gone at the next launch — with nobody told while
they could still have freed space.
The store now catches the write, distinguishes out-of-space
(NSFileWriteOutOfSpaceError, and POSIX ENOSPC for when the lower layer
is the one that noticed) from anything else, and publishes it through
E2EInbox.historyWriteFailure. The callback is edge-triggered, so it
fires when saving starts failing and again when it recovers, rather
than once per write — three failing saves a second must not raise
three alarms.
A bar appears at the top of Home and Messages saying that messages
still arrive but cannot be saved and will be gone at the next launch,
and how to fix it when it is space. The same condition is repeated in
Settings › Data & Storage, which is where somebody goes after seeing
the bar. It renders nothing when nothing is wrong.
The corrupt-store outcome the issue feared was already prevented by
`.atomic` — a failed write leaves the previous snapshot intact. There
is now a test pinning that, so nobody drops the option.
Tests drive the real write(to:) against an unwritable directory rather
than a mocked filesystem: a stub would have agreed with the old code,
since the bug was that the real call's failure was thrown away.
10 new tests; 764 XCTest + 31 Swift Testing green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign-up stated the rule -- "at least 8 characters" -- and never the stakes.
This passphrase is not a password: the address is DERIVED from it, so it is
the identity rather than a way into an account, and with the encrypted
backup (ios#17) it is also the only key to that file.
⚠️ Not a strength meter. A bar that turns green teaches people to satisfy
the bar. Two lines instead: what it is for, and what a better one looks
like -- "a few unrelated words", the same words the backup sheet uses, with
a test that fails if the two screens ever drift apart. Somebody should not
read one story at sign-up and a different one when it matters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⚠️ The feature already existed. `E2EHistoryExport` seals the history snapshot
under a passphrase-derived key -- PBKDF2, random 16-byte salt, AES-256-GCM,
versioned envelope -- with import that merges rather than replaces, and it is
reachable from Settings > Data & Storage. The issue's premise that this
needed building was stale.
What was missing was the trade the issue actually names: "a weak passphrase
becomes a weak backup, and that must be said plainly rather than buried."
The sheet warned about FORGETTING the passphrase and said nothing about
somebody else guessing it. That is the wrong half to say alone: the file is
MEANT to leave the phone -- a drive, a cloud folder -- so whoever ends up
with it can guess at it offline, unlimited and unobserved, with nothing
watching and no rate limit. It now says so.
⚠️ The 6-character floor stays. Raising it would block somebody mid-typing
for a rule they cannot see. But "allowed" and "strong" are different things
and only one was being said, so anything under 16 now gets a plain note --
and names the shape of a better passphrase rather than scolding, because
"use a strong password" tells nobody anything they can act on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App lock guards the app. It does not guard the screens inside it, and the
app stays open -- so somebody handed an unlocked phone for a moment can read
a safety number or remove a device.
`SensitiveScreen` covers a screen until the phone's owner proves it, and is
applied to exactly two places: the safety number (what somebody compares to
decide they are really talking to who they think) and the device list (the
one screen here with an action another person could take against the owner).
⚠️ Friction belongs on screens people rarely visit. A test enumerates every
use of the gate and fails on anything outside that allowlist -- if this ever
wraps a conversation, the friction is in the wrong place and people will
turn the whole idea off.
🔴 `.deviceOwnerAuthentication`, never `...WithBiometrics`: a phone with a
passcode and no Face ID must still open these screens. And a device that can
prove nothing at all is NOT gated -- gating it would hide somebody's own
device list from them for ever, and the phone is the weak link either way.
Same for a passcode removed after the fact: reveal rather than strand.
⚠️ Cancelling is a choice, not a failure. Telling somebody who tapped Cancel
that authentication FAILED reads as the app being broken.
The tests assert the policy, not the system prompt -- `LAContext` cannot be
driven from a unit test, and a test that pretended to would be testing a
mock of Apple's code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client has always known this and only ever told the log. Diagnosing
2026-09-01 meant reading device logs over a shell, and yesterday a send
refused by the identity guard reached the person sending it as "something
went wrong reaching that server" -- which was neither true nor actionable.
**ios#22, the give-up rule.** `flushOutbox` retried everything for ever. That
is right for a contact who is merely offline or has not registered a device:
they get the message whenever they return, and timing out would mean
deleting something somebody wrote to tidy up a label. It is wrong for a
refusal waiting cannot resolve -- a changed identity is refused identically
on every retry until a person accepts the new keys, while the bubble goes on
promising "will send". `E2EError.waitingCanFix` draws that line; those stop,
mark the bubble `.failed`, and record why.
**ios#21, drop reasons.** The seventeen `dropped(...)` sites already carry a
reason; they now also count it against the conversation it came from.
**ios#23, repairs.** `requestSessionRepair` was invisible when it worked and
invisible when it did not. Counted per peer, with three or more flagged --
a client that repeatedly repairs is a symptom, not a cure.
🔴 **Shape only, and the tests enforce it.** Several drop reasons interpolate
an id ("device claims 41 but is bound to 12"). Storing those raw would put
identifiers into a reading meant to be safe to screenshot, and would give the
map unbounded cardinality. They are normalised first -- and the first attempt
missed short hex ids like `9f0e`, which the test caught.
⚠️ In memory, not persisted. This describes the session you are in; keeping
it would be a growing record of who you talked to and when.
⚠️ `Scripts/generate.sh` had to run: this project has no sync groups, so a
new file is not in the build until the project is regenerated. The first
BUILD SUCCEEDED here proved nothing, because the file was not compiled.
764 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner's call after two days of iterating on it. Reverts the ten commits
that took the app from the blue light/dark palette to a purple-into-black
dark-only one: the purple ground, the darkening passes, the single dark
theme, the three-stop gradient, white text boxes, white icons and the white
header buttons.
The palette is `primary #2298FF` again, `Color(light:dark:)` throughout, and
`.preferredColorScheme(.dark)` is gone -- so the app follows the system
again instead of being locked dark.
Two things deliberately NOT reverted, because they rode in on theme commits
without being theme work:
* `UITests/AppStoreShotsUITests.swift` -- the App Store screenshot
harness, removed by reverting ed6fdd6 and restored from main. Without
it the UITest target does not even build.
* `PillTabBar`'s ground backstop and its safe-area inset. Painting the
ground once, centrally, is what stopped Files and Wallet being born
without one, and the inset is what stopped Account's Sign out sitting
under the pill with no way to reach it. Kept, pointed at the flat
`Theme.background` now that `backgroundGradient` is gone.
764 tests pass. Rendered and looked at: the profile screen is light with a
blue accent again, which is the state before any of this started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`FlamenetMessengerApp` already carries the reason, written out in full --
the accent draws a purple glyph on a dark blue bar, the least legible mark
on the screen, and it reads as already disabled. But the `.tint(Theme.onDark)`
that comment explains was applied only to the Files *preview* branch; the
real app tints with `Theme.primary`.
Per-button rather than on the subtree, so `.disabled` still dims the call
buttons while a call is in progress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A photo sent through the picker had its EXIF removed. The same photo saved into
Files and sent from the vault kept it — GPS, capture time, device serial.
Somebody who had learned "Flamenet strips location from my photos" had learned
something true of one button and false of the one beside it, and the safer one
looked like the shortcut.
🔴 **Sniffed by magic bytes, not by name.** The vault stores no MIME type, and a
file name is a guess in both directions: `holiday.txt` can be a JPEG and
`photo.jpg` can be text. JPEG, PNG, HEIC, TIFF, WebP and GIF are recognised from
their first bytes; anything else is not touched.
⚠️ **Only images.** "Send this exact file" is what a file send means, and
silently rewriting somebody's PDF or spreadsheet would be its own surprise. A
scrubber that mangles a document is worse than the leak it was fixing.
⚠️ **Re-encoded, not filtered.** Removing the EXIF block by hand leaves XMP,
IPTC, MakerNote and embedded thumbnails, several of which carry location
independently. Drawing into a fresh context and writing out again is the only
version with no list to keep up to date.
⚠️ **Undecodable bytes pass through rather than vanish.** A file that will not
decode is not necessarily a file with location in it, and losing somebody's data
to the scrubber is a worse failure than the one being fixed.
And it says so: sending a scrubbed image reports "the JPEG had its location and
camera details removed first", because the issue's real complaint was that the
two paths differed invisibly.
Five tests. 🔴 The first asserts the FIXTURE carries GPS before anything else
runs — a scrub test whose input never had location in it passes for the wrong
reason, and would have passed against the unfixed code. Falsified: making
`scrub` a passthrough turns the GPS test red on both assertions.
⚠️ The bug was found by an audit, not by a test, because no test went down the
file path at all. The picker path was checked and correct throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **The accent was being used the wrong way round.** #7A3FF2 is a mid purple
chosen to be readable as a FILL with white text on top of it. Used as the INK
for a glyph on a dark ground it is a light purple mark on a dark blue field —
the least legible thing on the screen, and it reads as a disabled control
rather than a button.
`Theme.onDark` is white, and it is what a glyph or a set of initials drawn on a
dark surface uses. Twelve avatar circles, and the app's `.tint` — which is what
SwiftUI hands to toolbar buttons and bar items, so the + and the QR scanner in
the top-right pill came with it.
⚠️ The accent still FILLS: primary buttons, the selected tab, badges. The
distinction is ink versus fill, not "purple is gone".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## Text fields are white
An input is the one surface a person types into and reads back character by
character, and a dark field on a dark ground gave it the least contrast on the
screen at the moment it mattered most. `Theme.fieldFill` is white; typed text is
#0E1230 at 18.3:1; the placeholder is #6B7590 at 4.6:1, because a placeholder is
still text.
⚠️ **Its own colour, not `surfaceAlt`.** That fill is shared with incoming
bubbles and the tab pill, which stay dark — a white message bubble on this
ground would read as somebody else's app.
⚠️ **A white field needs light-mode system chrome.** The app is forced to dark,
so SwiftUI drew the placeholder and caret for a dark background: on white they
were invisible. `.environment(\.colorScheme, .light)` on the field fixes the
parts the app does not draw itself. Twelve fields.
Files and Wallet also dropped `.textFieldStyle(.roundedBorder)`, which draws the
system's own field — a dark box, for the same reason.
## The black band under the tab pill
🔴 Fixed by making the bar paint its own band. The `safeAreaInset` was
`Color.clear`, so whatever the screen put behind itself showed through — and a
`List` whose content is taller than the screen draws its own opaque background
right there. That is why Account had a black strip and Wallet did not.
⚠️ **Three wrong guesses before measuring.** A frame on the List, then the
ground outside the NavigationStack, then the ground behind the whole tab
container — none of them moved it. What settled it was sampling the pixel: pure
(0,0,0) against the gradient's darkest stop of (7,10,26). Nothing in the palette
is black, so it was never the palette, and a red-background probe then proved
the band did not belong to the view at all.
⚠️ And the first version of the fix painted the WHOLE gradient into a ~100pt
strip, which put the top of the sweep where the bottom belongs — a light purple
stripe across the base of every screen. It is `Theme.groundEnd`, the last stop
alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three screens ended up without it: Files and Wallet had none at all, and
Account's `List` left a black strip under the pill — a scroll view's background
stops at its own bounds, and the bar sits in a safe-area inset below them.
The ground goes behind the whole tab container now, so a new tab cannot be born
without one. Each screen keeping its own `pageBackground()` is harmless and
still right when one is presented on its own; this is the backstop.
⚠️ Honest note: this fixed Files and Wallet and did NOT fix the strip under the
pill on Account. Third attempt at that one specific band. It is a `List` with
`.scrollContentBackground(.hidden)` that still paints its own area below the
content, and the next thing to try is replacing the List with a ScrollView —
which is a bigger change than a background fix and not one to make while
iterating on colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gradient has a middle now: #3A1E9E purple, #0B3080 blue, #070A1A black,
top down. Less purple overall than the two-stop version it replaces, because
the purple is one third of the sweep rather than half of it.
🔴 **One card colour against three stops has to lift off all three.** #0E1745
looked right against the purple and the blue and measured 1.15:1 against the
black end — a card that quietly disappears at the bottom of a long screen.
#141C52 lifts 1.41 / 1.33 / 1.23. The test checks every stop now; a three-stop
gradient checked only at its ends is two thirds measured.
⚠️ **Files and Wallet had no ground of their own**, so forcing the app to dark
left them on the system's black while every other tab carried the gradient.
`scrollContentBackground(.hidden)` is the half that is easy to miss: a `List`
paints its own background over anything behind it. And `pageBackground()` now
ignores the safe area, or a scroll view taller than its content shows black
below it — which is what the bottom third of the Account screen was.
## AppStoreShotsUITests
The screenshot set is a test now, not a person tapping through a simulator. It
signs in, walks the tabs, and attaches each screen, so a new set is one command
after any change — which matters when the palette has moved five times in an
afternoon and every set went stale with it.
🔴 **The variables need the `TEST_RUNNER_` prefix.** `xcodebuild` does not hand
its environment to the test runner; it forwards `TEST_RUNNER_<NAME>` and strips
the prefix. Without it the test skips — silently, and reported as a pass. The
first run did exactly that: green, and captured nothing.
TEST_RUNNER_FNMSG_SHOT_HANDLE=… TEST_RUNNER_FNMSG_SHOT_PASSPHRASE=… \
xcodebuild test -only-testing:FlamenetMessengerUITests/AppStoreShotsUITests …
xcrun xcresulttool export attachments --path <result> --output-path <dir>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dark theme was readable and muddy: #241650 to #0B2140 sits at 0.72/0.83
saturation, which is a grey that happens to be purple. Now #3A0CA3 to #062C8C —
0.93/0.96 saturation, a real violet into a real blue, and no lighter: ink still
reads 10.4:1 at the top and 10.5:1 at the bottom.
🔴 **Cards go DARKER than the ground, not lighter, and that is the whole trick.**
The usual dark-theme move is to raise a panel by lightening it. That does not
work against a vivid ground: a card lighter than a saturated violet has to be
lighter still than that, and secondary text then falls below AA on it — every
candidate I measured did. Recessing inverts the problem. The gradient is the
bright thing; content sits in dark wells where text has all the contrast it
wants.
ground #3A0CA3 -> #062C8C sat 0.93 / 0.96
card #190B4E 1.47:1 against the ground, ink 15.3:1
field/bubble #2A1875 1.24:1 above the card
secondary #B4BAD4 6.2 ground · 9.1 card · 7.4 field
links #B99BFF 5.2 ground · 7.7 card
danger #FF819F was #FF6B92, which measured 4.45:1 on the ground —
under the line, found by checking rather than looking
Also on this pass: the section band. It was a 16pt full-bleed fill of
`surfaceVariant`, which on the light theme was a barely-there grey seam and on
the dark theme became a light purple slab drawing more attention than the
sections it divided. It is a gap now — the panels either side of it are the
separator, and a gap needs no colour of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ground kept being asked to go darker, and `secondaryText` had to darken
with it at every step to keep clearing AA — until it was #1B1822 against `ink`
at #090710. Contrast passing, hierarchy gone: primary and secondary text had
become the same colour, and no ratio can see that.
A dark ground fixes it by putting secondary back in the middle where it belongs.
ground #241650 purple -> #0B2140 blue, top down
card #302F5C lifts off the ground at 1.29:1
field/bubble #3B3A6E lifts off a card at 1.20:1
ink #EDEFF9 14.0 on the ground, 10.9 on a card
secondary #A8AEC9 7.3 on the ground, 5.7 card, 4.7 field
links #B99BFF 7.0 on the ground
Semantic colours were re-picked rather than carried over: #3BD5B2, #B8860B and
#ED497C were chosen against white. Now #4BE3C0, #E9B44C, #FF6B92.
🔴 The Light / Dark / Match-the-phone picker is gone from both apps. There is
one set of colours, so it changed nothing a person could see except the Material
and SwiftUI chrome, which would then fight everything the palette draws. A
setting that is still there and no longer works is worse than one that was
removed — somebody sets it, nothing happens, and they cannot tell which of the
two is broken.
⚠️ The test caught one on the way in: secondary text on the avatar fill was
4.48:1, under the line by two hundredths. #3E3C7A became #3B3974. That is the
kind of miss that only ever surfaces as somebody saying the text looks faint.
The palette test now covers every surface text is read on — ground, card, field,
avatar — not only the page, and asserts that cards and fields visibly lift off
what is behind them. A gradient that passes and a bubble that does not is still
an unreadable app, and the bubble is where the messages are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ink 9.3:1 at the top and 5.7:1 at the bottom, secondary text 8.1:1 and 5.0:1,
white cards lifting off the bottom at 3.5:1.
🔴 **This is close to the floor for a light theme, and the next step down is not
another shade — it is a different design.** At a bottom stop of #477BC2, `ink`
itself measures 4.65:1 and secondary text cannot clear 4.5:1 even at pure
near-black. Darker than that and dark text on this ground fails outright. The
answer at that point is light text on a dark ground, which is a theme flip
rather than a colour change, and worth deciding deliberately rather than
arriving at by increments.
⚠️ **A second cost is already being paid, and no test catches it.**
`secondaryText` has had to darken at every step to keep clearing AA and is now
#1B1822 — very close to `ink` at #090710. Contrast is satisfied; hierarchy is
not. Primary and secondary text are nearly the same colour, which is a real
regression in readability that a contrast ratio cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Another step darker, and top-down into blue: #D6C2F4 to #82ABE0.
⚠️ **The blue end is not decoration.** The mark is a blue globe with an orange
flame, and a purple-only app left the icon as the odd one out — the one thing on
the screen from a different palette. Shading into blue puts the icon's own
colour on the page instead of beside it, which is a cheaper answer than
redrawing the logo.
🔴 **Every darkening step drags secondaryText with it**, and this is the third
time in a row. #413A4D read 4.6:1 on the new bottom stop, which is inside the
margin rather than clear of it; #332E3D reads 5.5:1. The bottom stop is always
the one that binds, and it is where most of the explanatory copy on these
screens sits.
Measured: ink 12.3:1 at the top and 8.4:1 at the bottom, secondary 8.1:1 and
5.5:1, white cards lifting off the bottom at 2.4:1 — which is what keeps a card
looking like a card rather than like a lighter patch of background.
Dark mode shades the same way, purple into deep blue: #1C1036 to #071426.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all from looking at it on a device.
**Darker.** The page gradient goes #E9DDFB → #C6AFF2, a clear step down from the
whisper it was. 🔴 That forced a second change: secondary text at #505C69 read
5.0:1 on the old bottom stop and only **3.5:1** on the new one — below AA, and
most of the explanatory copy on these screens is set in it. It is #413A4D now,
8.4:1 at the top of the gradient and 5.6:1 at the bottom. Every step the ground
takes downward this has to take too, which is the sort of thing that gets
forgotten when a colour is chosen by eye.
**The mark was cropped, twice over.** `.aspectRatio(.fill)` in a square frame
crops a mark that is wider than it is tall, and the rounded clip then took
another bite out of the flame arc. It is `.fit` now, and the PNG carries its
own inset — the artwork is 78% of the tile, so nothing has to be cropped at all.
**The app icon had a 6% margin**, which is no margin: an icon grid expects
about 10%, and the squircle was clipping the arc. The mark is 80% of the icon's
width now. Measured rather than eyeballed — compositing the foreground under
both a circle and a squircle mask clips **0 artwork pixels**.
**Sign-up wears the mark too.** It had an SF Symbol in a tinted circle. The
second screen anybody sees is not where the app should stop looking like itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app was blue on near-white. It is purple now, on a gradient, with the
primary action carrying the same sweep.
**Every value was measured rather than picked.** "Still readable" is the whole
constraint, so the numbers are in the comments beside the colours:
- Button gradient `#7A3FF2 → #5B27C7`. White label is **5.50:1** at the light
end and **8.20:1** at the dark end. 🔴 The LIGHT end is the one that decides —
a gradient button is only as readable as its lightest stop, and checking the
mean or the dark end is how a pretty button ships unreadable.
- Page gradient `#F8F5FF → #E4D8FB`. Ink **18.6:1** to **14.8:1**, secondary
text **8.7:1** to **6.9:1**. Deliberately a whisper: cards are white and have
to lift off it, and every line of body copy sits on it. A saturated purple
would look bolder in a screenshot and fight both.
- Links and small text move with the ground — `#5B27C7` on light (8.2:1),
`#B99BFF` on dark (8.3:1). One mid purple is unreadable on one of the two.
`Theme.background` stays as the gradient's mid-tone, because a gradient cannot
go where a single `Color` is required — a bar tint, a UIColor bridge — and two
surfaces a shade apart read as a seam.
⚠️ The sign-in screen had no ground of its own and kept the system's white. It
is the first screen anybody sees, which made it the one place the app looked
like a different app.
⚠️ `avatarFill` was a blue wash. On a purple ground that reads as a bug rather
than as a second accent; it was the last surface giving the old palette away.
⚠️ **The icon is still blue with an orange flame.** The app now matches the
website, which was already purple — the icon is the outlier, and whether it
follows is a brand decision rather than a CSS one.
Android carries the same values in the same commit, and there is finally a test
that checks it: `PaletteMatchesIOSTest` reads this file and compares the hex.
This file has opened with "one palette, two apps" since it was written, and its
own comment records that the claim was once quietly false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a first submission needs.
**Version.** `MARKETING_VERSION` was `0.1.0`. App Store Connect refuses a
version string that disagrees with the uploaded binary, so submitting as 1.0
means the project says 1.0. `CURRENT_PROJECT_VERSION` stays at 1 — it has to be
unique per version string and only ever go up, and 1.0 is a new string, so
build 1 is available again. Bump the build, not the version, if an upload is
rejected and re-sent.
**The mark.** The sign-in header was a blue tile with a bold "F" in it — a
placeholder from before there was a logo, and the first thing anybody sees.
It is the emblem now, from the same artwork as the app icon.
⚠️ Clipped to a rounded rectangle, and that is not decoration. The artwork's
background is painted rather than transparent — the space behind the globe is
part of the picture — so an unclipped mark is a hard-edged dark rectangle
sitting on a white screen. Rounded, it reads as the app's mark and matches the
icon on the home screen the person just tapped. Checked on the simulator both
ways rather than assumed.
⚠️ Two copies of one artwork now exist: `docs/marketing/` here and `brand/` in
the relay repo, which is where the generator reads from. They are byte-identical
(`9eb778eb…`) and the relay's copy is the one to change if the artwork ever does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new-call picker drew one `phone` glyph per row and started an audio call on
tap. Video existed — as a swipe action, with nothing on screen suggesting a
swipe was available. A row showing a single glyph looks like a row that offers
one thing, so it was reported as video calling being missing, which is a fair
reading of a feature whose only entrance is a hidden gesture.
Two visible buttons now, each 44×44 so they are not two things people miss
between, each with its own accessibility label and identifier so the XCUITest
suite can drive either. The name takes the row's tap for the audio call — a
`Button` wrapping the whole row swallows the two inside it.
⚠️ The swipe action stays. It is a shortcut for people who know it; what it
must not be is the only way in.
One `place(_:video:)` behind all four entrances, so the row tap, the two
buttons and the swipe cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The business card PDF, its two previews, and the transparent logo were sitting
untracked in the working tree. Nothing about them is generated and nothing
would recreate them, so an untracked copy is one disk failure from gone.
⚠️ `flamenet-messenger-logo-transparent.png` is byte-identical to
`brand/flamenet-messenger-logo.png` in the relay repo, which is where the icon
generator reads from. Two copies of one file is a thing that drifts; this one
is kept because the card was laid out against it, and the relay's copy is the
one to change if the artwork ever does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generated from the one artwork by the relay repo's
`Scripts/make-brand-assets.py`, so the iPhone icon, the Android icon and the
site's favicon cannot drift apart. Do not edit the PNG in place — the script
overwrites it.
🔴 **No alpha channel.** App Store Connect rejects an icon that has one, and
the rejection arrives after the upload rather than during the build, so the
generator flattens onto the panel navy (#121A2E) rather than leaving the
artwork transparent.
⚠️ The wordmark is not in it. "FlameNet MESSENGER" is unreadable at 60pt and
worse at 40, and the emblem — globe, two speech bubbles, flame arc — is the
half that survives being small.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MessengerViewModel.reportStory` was called by no screen — Android could
not report a story at all, which Apple's Guideline 1.2 and Play's UGC
policy both treat as not having reporting. The unwired check did not flag
it. I found it by hand while working ios#12, which is the check failing
at its one job.
**Miss one: comments count as references.** Two doc comments mention
`reportStory` by name, so it looked used. A reference check that reads
prose can be talked out of its finding.
**Miss two, introduced while fixing miss one:** I stripped comments with
a `/* … */` regex over the concatenated sources. It ate 35% of the code —
an unbalanced marker anywhere swallows everything to the next one —
including `onClick = model::clearAvatar`, and the scrub then reported
three properly wired methods as unwired. A check that cries wolf gets
skimmed, which is the failure it exists to prevent, so that version was
worse than the bug.
Line-based now: a line is dropped only if it plainly begins a comment.
That errs toward keeping code, which errs toward fewer false alarms.
Verified against the tree that still HAD the bug rather than against a
fixed one, because a check that passes after the fix proves nothing about
whether it would have caught it. On the unfixed Android tree it reports
exactly three: `filesGoUp` and `refreshGroups`, both already verified
benign, and `reportStory`, the real one. No false positives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by the parity scrub, from the other end: Android was missing these,
so I went to read what they do before copying them across.
"Read receipts" was the worst of the three. This app sends no read
receipts to anybody and never has — there is no such content kind and no
such envelope field. The toggle governs whether opening an alert in the
inbox marks it read on the relay. A real preference, and not remotely the
one its name promised. Somebody could have turned it OFF believing they
had stopped telling people when they read their messages, having never
told anyone anything.
"Show last seen" and "Show online status" hide other people's status from
THIS phone. They publish nothing differently: turn both off and the relay
still tells your contacts you are online, exactly as before. Every other
messenger uses those words for the reciprocal thing, and this is the
Privacy & Security screen, which makes the wrong reading the natural one.
Renamed rather than reimplemented, and the screen now says plainly that
these change only what you see, that your contacts are still told when
you are online whichever way they are set, and that hiding yourself is
not something this app can do yet. Making them mean what they said is a
feature worth building; leaving the labels up until then is a claim.
⚠️ Tests that assert on COPY, which is usually not worth doing. It is
here because the failure is silent by construction — a label that lies
compiles, renders and is wrong only to the person reading it. One of them
also asserts the underlying fact, so it fails the day somebody implements
read receipts, at which point the old label can come back and the test
should be rewritten rather than deleted.
758 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner asked for a recurring pass over both apps and the relay: same
features on both sides, nothing broken. This is the repeatable half plus
the notes for the half that cannot be automated.
The script checks only things whose failure mode is SILENT, because
everything else is visible the moment somebody opens the screen:
- an envelope kind one app sends and the other cannot parse, which is
swallowed as unknown control and reported to nobody. Bridge sharing
had that shape for a few hours today, between the two halves landing.
- a view-model method no screen calls. `shareBridge` shipped that way
this morning: you could receive a bridge and had no way to send one.
⚠️ The first version of the unwired check reported 26 findings, of which
almost all were wired perfectly well — Kotlin passes these as method
references (`run = model::hangUp`) and the pattern only matched
`model.hangUp()`. A scrub that cries wolf is worse than none, so the
check was widened until what it reports is worth reading.
Both surviving findings were verified by hand and neither is a break:
`filesGoUp()` is redundant because the breadcrumb navigates up, and
`refreshGroups()` is redundant because `createGroup` re-reads the list
inline. Dead code, reported as such.
SCRUB.md carries the manual half, because the most significant finding of
this run is one no source check here would have raised: eight preferences
exist on iOS with no Android counterpart — read receipts, last seen,
online status, send-on-return, timestamps, larger type, appearance, and
the whole notification group. Every one of them is wired to real
behaviour on iOS, so this is Android being behind rather than iOS
carrying decoration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`testCreatingASealedGroupSendsNoMemberIDsToTheRelay` searched the whole
request body for "2421" as text. `gid` is a hex digest of random
material, so it contains any given four digits about once in a thousand
runs. CI caught exactly that today, in
"gid":"058b96d9986cd1eaf7b93fe9bc0bc41772f8afcffbf62421208cac1e2edb25c9"
^^^^
and reported "a member id went to the relay" about a request that
contained no member id at all.
That failure mode is worse than most, because of what this test guards.
It exists because the relay used to store `gid -> user_id`, one readable
row per membership, seven of them on production saying exactly who was in
which group. A test making that claim must not be the one people learn to
retry past.
The replacement walks the parsed JSON and refuses an id appearing as a
number or as a string anywhere in it, and pins the top-level keys. That
is STRONGER than the substring version, not weaker: a future field
smuggling an id now fails on the allowlist even if its value is encoded
in a shape the walk does not recognise. Opaque blobs are exempt from the
text search, because searching ciphertext for a decimal id is what
produced the false positive and cannot produce a true one. The group name
is not random material, so it is still searched as text.
Checked by simulating the regression rather than trusting the rewrite:
adding `member_ids` to the create request reddens it twice over — once on
the allowlist, once on the value walk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other half of the Android change. Without it an Android user sharing
a bridge with an iPhone gets silence — the envelope is swallowed as an
unknown control kind, correctly and uselessly, with nothing shown at
either end.
🔴 **An offer, never a setting.** A bridge line tells this phone to route
every relay connection through a machine somebody else chose. Applying
one on arrival would let a contact — or whoever has taken their account —
move another person's traffic onto a host they control. They could not
read it, because the ratchet does not care what carries it, but they
would learn who is online and when, and could cut them off. Two tests
hold that line: recording an offer must leave the configured bridge nil,
and must not disturb one already set.
A cross-engine test caught a real divergence, and it is the reason that
test exists rather than a comparison of the two builders by eye — which
would have shown nothing, because they look identical.
`JSONSerialization` escapes `/` as `\/`; `org.json` does not. Both are
valid JSON, both decode to the same string, and nothing would have
broken. But a bridge `cert=` is base64, base64 is full of `/`, and the
two engines would therefore have put different bytes on the wire for
every real bridge line ever shared. iOS now quotes the way `org.json`
does — Android is in the field, so its output is the wire — including
that library's one oddity of escaping `/` only after `<`, which exists to
keep `</script>` out of a page.
The test that found it holds the Android engine's ACTUAL bytes, captured
by running it, not a reconstruction of what it ought to emit. This app
has been caught by that distinction before: text envelopes where iOS
wrapped and Android sent bare, which broke iOS→iOS invisibly.
753 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same correction as the Android side. I pinned the vector clone earlier
today on flamenet-relay#56's evidence and stopped a step short.
flamenet-relay did not: it pins AND keeps `conformance-head` /
`doc-pages-head` running against HEAD with `allow_failure: true`, and its
`.e2e-ref.md` gives the reason in a sentence I had read without acting on
— "a relay tested against a pinned old harness is tested against a pinned
old spec, and that argument still holds."
A pin alone is worse than tip for exactly that reason. Tip couples this
board to another repository's merges; a bare pin means the vectors can
move and `ProtocolVectorTests` goes on checking last month's protocol and
reports green. That is the failure that suite was written to prevent,
reintroduced one level up — and it is the quieter of the two, which in a
conformance suite is the worse one.
So `.e2e-ref` holds the commit, the blocking check uses it, and
`vectors-head` diffs `Tests/Vectors` against HEAD and is allowed to fail.
⚠️ The advisory deliberately does not build the app. The question it
answers — did the published vectors move? — needs a clone and a diff and
nothing else, and a job that took twenty minutes to say "you are two
commits behind" would be switched off within a week.
Checked both directions rather than assuming: with the vectors current it
reports clean, and with one byte changed it fires. Pin and HEAD are the
same commit today, so the passing run alone would have proved nothing.
`.e2e-ref.md` records the procedure, including that Android pins the same
commit and should move in step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`- echo "GATE: protocol vectors match ..."` is not a string to YAML. The
plain scalar contains `: `, so the list item parses as a MAPPING and the
whole file becomes invalid.
What that looks like from outside is worth writing down, because nothing
about it says "malformed CI file": the pipeline exists, reports `failed`,
lists ZERO jobs, and carries `yaml_errors: null` on the pipeline object.
The only thing that named the actual problem was POSTing the file to the
project's `ci/lint` endpoint, which said it in one line.
Folded into the literal block above it, where a colon is just a colon.
Both this file and the Android one now lint clean — the Android one was
already fine, but only because its GATE lines happen to live inside
blocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`FlamenetAccountKDF` carries a comment saying its derivation is "pinned in
engine/test/vectors/account-kdf-v1.json and checked against the JavaScript
rather than merely reviewed". That test exists only in the Android engine.
The comment describes a guarantee this repository does not have, and its
own text says what the absence costs: a disagreement shows up as "handle
or passphrase is incorrect" for a passphrase that is perfectly correct.
The wider version of the same gap is that everything in this suite checks
both sides of a handshake against each other. That is a real test of
internal consistency and no test at all of conformance, because two
identically-wrong implementations agree perfectly.
Demonstrated rather than asserted. Swapping the order of the first two
Diffie-Hellman outputs in BOTH `X3DH.initiate` and `X3DH.respond`:
X3DHTests 18 tests, 0 failures
ProtocolVectorTests 1 failure
Under that change every iPhone talks to every other iPhone perfectly and
no Android device can decrypt a word, and the entire existing suite calls
it correct.
Ten tests now check this app against values it did not choose: the
account KDF on all five published cases, the safety number both ways
round, the fnid encoding and claim bytes, the key log's leaf encoding,
hash, tree shape and an inclusion proof at index 2 of 4, the ML-DSA-65
identity key, and the whole PQX3DH responder handshake down to `sk` and
`ad`.
Each was checked by breaking the implementation and watching it redden.
The tree-shape one is worth recording: substituting `n / 2` for the
unbalanced split still passes at sizes 1, 2 and 4 and fails at 3 and 5,
which is why that test walks every size rather than checking a final
root. A suite that happened to stop at four leaves would have called the
wrong tree correct — and I had written a comment claiming a halving split
"matches nobody past size 2", which the experiment corrected.
The vectors are copied into Tests/Vectors rather than resolved from a
sibling checkout, because a sibling checkout is exactly how Android lost
twenty conformance tests: its engine looks for ../../../flamenet-e2e/...,
which exists on a developer's machine and never in a build directory, so
its suite failed in CI from the day it was written until 2026-09-06
without anyone seeing it. A copy in the test bundle cannot be absent.
What a copy can do is go stale, so CI diffs it against flamenet-e2e at a
pinned commit — pinned and not tip, for the reason flamenet-relay#56
documents.
Also gives this gate a count floor. Twice now a green pipeline here has
had to be checked by hand against main's count, because the log was
truncated and showed no sign of the tests that had just been added. A
suite that silently dropped from 741 to 12 passed the old check and
looked identical in the log.
741 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The obfs4 wiring shipped with no way to configure it, and the gap was
worse than "no UI": a malformed line parsed to nil, the transport reads
nil as "no bridge", and the app connected DIRECTLY. Somebody who pasted a
bridge line precisely because direct does not work would have been put
back on the exact path they were escaping, with their line still sitting
in settings looking configured.
So the line is validated before it is stored, and refused with a reason
if it cannot be used:
- `Obfs4Settings.Problem` names which part is wrong. A boolean "valid"
would have reintroduced the same failure one step later: telling
somebody their line is broken without telling them what to fix.
- A truncated cert is reported as truncated rather than as unreadable.
That is the likeliest real mistake by a distance — bridge lines wrap
in every chat app and terminal, and copying only the first line is
the ordinary error. Swept across cut positions, because whether a cut
reads as "short" or as "unreadable" depends on where it landed
relative to base64's four-character groups.
- `iat-mode` 1 and 2 are a caveat, NOT a rejection. They ask the client
to space its writes out and the bridge does not enforce them, so such
a line connects and works. Refusing it would take a working bridge
away from somebody who has no other one.
- No error message says obfs4, base64, or node id. Whoever reaches this
screen was handed a line and told to paste it; the protocol's
vocabulary is not theirs, and an error in it reads as "you are not
qualified to fix this". There is a test for that.
Two bugs found while building it:
- `bridge` memoised its parse, so storing a SECOND line kept returning
the first until the app was force-quit. The screen would have shown
the new bridge while traffic went to the old one. Setting the line
now clears the cache and stops the running transport; removing either
reddens 9 of the 14 tests, which is how they were checked.
- `Obfs4Proxy` had no live state, so a screen could only report the
stored preference. A stored bridge whose loopback listener failed to
open is carrying nothing, and the preference cannot say so. `state`
is what the screen reads, and the fallback to a direct connection is
now visible rather than silent.
Saving proves the line is complete; it does not prove the bridge exists.
Those are different questions and people conflate them, so "Check it
works" opens a real connection. It refuses to guess: a bridge that
cannot verify a handshake stays silent, and so does a bridge that is not
there, so the result says both possibilities rather than picking one.
Filed under Network, not Privacy & Security. A bridge is about REACHING
the relay on a hostile network, not hiding from it — the relay sees
exactly what it saw before, and the screen says so twice.
731 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pipeline failed at package resolution: "the package manifest at
Packages/Obfs4/Package.swift cannot be accessed". The runner skips
submodules by default, so the directory was empty -- which reads as a
missing file rather than a missing clone.
GIT_SUBMODULE_STRATEGY brings it in. GIT_SUBMODULE_FORCE_HTTPS rewrites
the URL to use CI_JOB_TOKEN, which is the only credential the job has and
is needed because flamenet-elligator2 is private.
That also required allowing this project in flamenet-elligator2's CI
job-token allowlist -- a setting on the OTHER project, so a failure here
has no local cause and nothing in this repo would point at it. Noted in
the file for whoever hits it next.
This is the cost of the submodule, and it is worth paying: a vendored
copy would let the code this app ships drift from the code that carries
the interoperability tests, and nothing would report that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three URLSession sites -- MessengerClient, E2EClient, MailboxHoming --
now call Obfs4Proxy.shared.apply(to:) unconditionally. That is a no-op
unless a bridge is configured, and none ships by default, so nothing
changes for anyone who does not ask for it. Unconditional on purpose:
adding a fourth session later is one line rather than a decision someone
has to remember to make.
One transport for the whole app, not one per session. Each instance would
open its own loopback port and its own connection to the bridge,
multiplying the handshakes a censor sees and the sockets the bridge holds
for no benefit.
The obfs4 package is a SUBMODULE, not vendored source. That repo carries
the vector suite and the live interoperability tests against a real
lyrebird bridge in both directions; a vendored copy would let the code
this app ships drift from the code that is proven to interoperate, with
nothing reporting it. Only the pure-Swift modules are linked -- Obfs4NIO
would drag SwiftNIO into the app, so the SOCKS shim is Foundation and
POSIX.
The end-to-end test needed three attempts and the first two were wrong in
ways that LOOKED right.
It began as a URLSession fetch of a plain-HTTP upstream and passed -- but
the URL pointed at that upstream, so a completely bypassed transport
returned the same body. Adding a completed-handshake counter at the
bridge showed the request had been going DIRECT the whole time. The same
flaw existed in the Android version and is fixed there too; removing the
socket factory now fails that test where it previously passed.
The cause was App Transport Security, which refuses cleartext BEFORE any
proxy is consulted. http://127.0.0.1 passed only because loopback is
ATS-exempt, which is precisely why it went direct; relay.invalid and
192.0.2.1 were both refused before the proxy. A diagnostic run showed the
proxy's accept loop never ran at all.
So the path is no longer joined in one test, because ATS stands between
its two halves. URLSession to the SOCKS proxy is proven over https by
SocksProxySupportTests; the proxy to the bridge to the upstream is proven
here by a raw SOCKS5 client with no URLSession involved. In production
the relay is https, so the joined path runs for real.
Two behaviours are pinned rather than left implicit. Off-by-default has
its own test, because every session calls apply unconditionally and a
default of true would silently move all relay traffic. And a malformed
bridge line yields nil, which falls back to a DIRECT connection -- right
for reachability, wrong for someone who pasted a bridge line precisely
because direct does not work, so the settings screen must validate on
entry. That requirement is written where it will be seen.
obfs4 hides traffic from the NETWORK, not from the relay operator. The
bridge proxies onward and the relay sees what it always saw. Said plainly
in the code so no user-facing copy claims otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iOS URLSession DOES honour a SOCKS proxy set through
connectionProxyDictionary. Verified on the Simulator, iOS 27: a SOCKS5
greeting arrived at a listener whose port number appears nowhere except
that dictionary, so nothing else could have sent it.
This decides the whole shape of the obfs4 integration. Because it holds,
obfs4 can be an in-process SOCKS5 shim that the three existing
URLSession clients point at -- a contained change. Had it come back
negative, every relay call would have had to move off URLSession onto a
transport we own, which is a far larger change to an app in review.
Published sources disagree on this, so it is settled here rather than
assumed.
Now asserted rather than printed: a future iOS release quietly dropping
SOCKS support must break a test, not a shipped app.
The first run said the opposite and was wrong. App Transport Security
refused the cleartext request before any proxy was consulted, so both the
test and its control reported "the proxy setting was ignored" -- which
reads as a clean negative answer. Only the control failing alongside it
showed the run was inconclusive. The URL is https now, which still proves
the point: through an HTTP proxy URLSession sends a plaintext CONNECT
first, and through SOCKS the SOCKS greeting, so the listener sees bytes
before TLS is attempted. An explicit ATS check now reports INCONCLUSIVE
rather than a verdict it did not earn.
The control is kept but is no longer load-bearing, and says so. It
existed to disambiguate a negative; a positive result validates itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 1:1 composer has carried `chat.composer` since it was written. The
group screen is a different view and had neither that nor a name for its
send button — so nothing could address the field that sends a group
message. The two-simulator group phase asked for the 1:1 name, found
nothing, and reported "no composer in the group", which reads as a broken
screen and was nothing of the kind.
🔴 **Fourth instance today of the same root cause: something on screen
that nothing can address.** `home.add` was on two different buttons,
`Stories` matched a heading as well as the tab, group rows were keyed
only by a name drawn in three places, and this composer had no name at
all. Every one produced an error that blamed the wrong thing, and two of
them were originally diagnosed as machine load.
⚠️ Each is also a VoiceOver defect. An unlabelled text field between two
unlabelled buttons is announced as "text field" — so the send button gets
an `accessibilityLabel` here, not just an identifier.
With this, the two-simulator suite passes end to end on both simulators:
a message crosses in both directions, and a group created on one phone
arrives on the other and can be sent into. The member's half is the one
worth having — a group it was never told about has to arrive entirely out
of the creator's key distribution.
The only handle on a group row was the group's NAME — which is also drawn
in the header of the group's own screen and in the sheet that creates it.
Three places, one string.
🔴 The two-simulator group phase tapped "the text matching QA_Crew",
landed on one of the other two, navigated nowhere, and the next assertion
reported "no composer in the group" — which reads as a broken group
screen and is nothing of the kind.
⚠️ Same shape as `home.add` being on two different buttons: one name,
several things, and a result that depends on which one the query happens
to resolve. VoiceOver has the same problem with the same cause.
Rows are `group.row.<name>` now.
**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 — and Apple's guideline 1.2 wants a report mechanism for
user-generated content, which messages plainly are.
🔴 **One sheet, not two.** `StoryReportSheet` becomes `ReportSheet` with
a `Subject`. The bargain, the disclosure and the endpoint are identical
for both; two sheets would be two places for the consent wording to drift
apart, and the consent is the part that matters.
⚠️ **Renaming a Swift file means editing `project.pbxproj`.** This
project lists every file explicitly — no synchronised groups — so the
build failed with "Build input file cannot be found", which reads like a
missing script phase. Six references needed updating by hand.
The per-message action satisfies the rule. **The stranger banner is where
it helps**: somebody just written to by a stranger is looking at exactly
that banner, and until now could only keep them or block them — and
blocking tells whoever runs the server nothing.
⚠️ Report is third and quietest there, below Keep and Block. Most first
messages are somebody you gave your address to, and leading with Report
would frame every one of them as a threat.
⚠️ Absent rather than disabled on your own messages. Reporting yourself
is not something anybody means to do, and offering it makes the menu read
as a moderation panel instead of a way to get help.
⚠️ One message travels, never the thread around it (§6.25). Block sits
beside the report and is never merged into it: one takes effect now and
is yours, the other is a request that may come to nothing.
**ios#67 — there was no way to sign out.** Sign out is the last row of
`ProfileView`'s list and the floating pill sits on top of it, with
nothing below to scroll into view.
🔴 **Fixed at the root rather than on the reported screen.** `PillTabBar`
replaced `TabView` and its own header names the safe-area inset as one of
the things that costs — "the inset and the badges are below". The badges
were there. The inset was nowhere in `Sources/`. Four screens each paid
for their own clearance by hand and three did not: Account, Wallet and
Files — the two newest tabs among them. Clearance was opt-in, so every
tab added since was born broken. One `safeAreaInset` in the bar now, and
the four hand-rolled spacers are gone so nothing double-pads.
**ios#6 — a transient 502 on call accept killed the call outright.** The
offer arrived and decrypted; the accept POST hit the few seconds of 502
Traefik serves while the relay restarts, and that was the call. The
caller was still ringing. 502/503/504 now get one retry after 1.2s.
⚠️ Those three only, and only once. They mean "nothing handled this", so
repeating is safe; a 4xx is a real refusal — a changed identity, an
expired session — and retrying one turns a clear answer into a slow one.
`armConnectTimeout()` is already running, so the wait cannot hang.
**ios#69 — a photo sent as a FILE keeps its GPS.** The camera button
redraws through `ImagePrep` and no metadata container survives;
`sendVaultFile` hands the vault's bytes over untouched.
⚠️ Said rather than silently re-encoded. Rewriting somebody's document is
its own surprise and a vault is where people keep originals. What could
not stay is the difference being invisible — the safer path looked like
the shortcut. The send sheet now names it at the moment of choosing.
Also: `GroupsSection`'s doc comment said it lives on the Home list. It
has been on Messages for a while, and that stale sentence is what sent
the two-simulator group phase looking on the wrong screen.
`AppLock` is `@MainActor`, which isolates its statics too, so the
nonisolated `delay(afterFailures:)` could not read `attemptsBeforeDelay`
or `maximumDelay`. Both constants are `nonisolated` now.
⚠️ The constants rather than the function, deliberately. `delay` is
arithmetic over its argument and callers work out the wait BEFORE they
have anything on screen — making it main-actor would push a hop into the
one path that should not need one.
`FilesView` and `WalletScreen` both store
`FilesPassphraseStore.availability()`, whose tuple carries an
`LABiometryType`. A type used in a property declaration has to be
imported by the file DECLARING the property, not merely by the one that
vends it.
Also a stray `var mask` that is never mutated, in the Files engine.
Three defects, each of which made the suite report something other than
what happened.
🔴 **Sign-up created accounts under a password nobody chose.** iOS
autofills the password AND the confirm field together; our text appended
to both, so they still matched, the form submitted happily, and the
account existed with the wrong secret. Proven by API: the handle the test
had just made returned 409 on signup and 401 on signin. Both fields are
now checked by bullet count before the form is submitted.
🔴 **Sign-in sent autofill's suggestion with the passphrase glued on.**
The clear-before-type that was supposed to prevent this had been failing
silently for three runs, because nothing read the field back. It does now,
and says what it found when it cannot get the credentials in.
🔴 **`app.buttons["Stories"]` matched a heading, not the tab.** The tap
navigated nowhere and the failure surfaced one line later as "no way to
start a story" while the screen dump plainly said Home. Both tabs are
addressed by identifier now.
⚠️ The story step MOVED to the paired tier. A story is SENT to contacts,
so it needs a recipient with a published device — an account made over
HTTP is not one, and a fresh account has nobody at all. The solo tier now
says stories are not covered rather than pretending.
For the two-simulator pair: the group phase looks on the MESSAGES tab,
where `GroupsSection` actually lives, and the lookup retries by retyping
so a miss can be asked again.
Reported plainly: "i will always go to signin/signup/messages/calls/stories
and every time they all break." That was accurate, and nothing watched those
five together. `DemoRoundUITests` covers sign-in, receive, reply and groups —
no calls, no stories. Everything else guards a piece. So a whole day could go
green while the journey was broken end to end, and today it did.
🔴 **Two tiers, and it always says which one ran.** Solo proves what one
device can prove; paired needs somebody on the other end. The paired steps
FAIL when no peer is named rather than skipping — a gate that quietly
downgrades and reports success is the exact failure this file exists to stop.
`FN_JOURNEY_SOLO=1` says solo was the intent.
⚠️ Every send accepts a key change first, because that is the trap this was
written after (android#20): a peer that reinstalled makes the send path
refuse, and on a call that refusal looked like "it just doesn't connect".
⚠️ The call step asserts `call.end`, which exists only in ACTIVE, which is
entered only from WebRTC's ICE CONNECTED callback. There is no weaker thing
to assert that means anything.
It earned its keep on the first three runs:
1. Sign out reported MISSING. It is the last row of a long List and SwiftUI
does not put an off-screen row in the accessibility tree at all, so
`waitForExistence` waited twenty seconds and then said the control was
gone rather than below the fold. Now `scrollUntilExists`.
2. `completeSignIn` never dismissed the strong-password panel, while
`completeSignUp` twelve lines above dismisses it three times. Focusing a
secure field is exactly when iOS offers one, and it swallows the typing.
Fixed — though see below, it was not the cause here.
3. 🔴 **Sign-up → sign-out → sign-in fails in the app.** The account and its
device exist on the relay and hold exactly ONE app credential — the one
minted at sign-up — so the sign-in never succeeded server-side. The same
handle and passphrase sign in against that relay in a single request,
HTTP 200 with a fresh credential. Filed; not yet separated into app bug
versus harness typing, and this commit does not claim to know which.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android half of any cross-platform check has to be driven by something,
and `android-signup.sh` only signs UP — which on the live relay leaves a real
account behind and spends the account service's three-per-hour-per-IP budget.
🔴 **Three traps, all of which cost an hour today, all silent.**
1. `input text` drops characters on longer strings. A 21-character
passphrase arrived as 11. Nothing reports it; the sign-in just fails as
though the password were wrong. So: chunks, and read the count back.
2. The soft keyboard covers the button. A tap at the button's coordinates
lands on a KEY — which types into the field you just filled, so the
passphrase grew by one character every time I "pressed" sign in.
3. Dismissing the keyboard MOVES the button, and on a slow emulator the
layout is still settling when the tap goes out. ⚠️ This is not
theoretical: it put the driver on the SIGN-UP form, one field from
creating an account on production, with every coordinate correct at the
moment it was read.
Trap 3 is why `tap_label` reads the bounds twice a second apart and refuses
to tap a control that is still moving — and why the sign-in button is found
by LABEL, never by index. An index picked "Create an account".
⚠️ It does not get all the way in yet. Filed separately: the app reaches the
sign-in screen with correct credentials, opens sockets, and then sits there
for four minutes showing nothing — no spinner that resolves, no error. TCP to
accounts.flamenet.io:443 and relay.flamenet.io:443 both succeed from inside
the emulator, and the same credentials sign in fine on iOS, so this is not
the network and not the credentials.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LiveCallUITests` is the only thing that can answer "do calls connect" — the
assertion is sound (`call.end` exists only in `.active`, entered only from
WebRTC's ICE CONNECTED callback) — and it could only ever be run by creating
a fresh account.
🔴 **That made it single-use.** On the live relay each run leaves a real
account behind, and it spends the account service's three-per-hour-per-IP
budget, so the second attempt in an hour fails with a 429 that reads like a
broken sign-up screen rather than a spent quota. A harness you can run once
an hour is a harness nobody runs.
`FN_CALL_EXISTING=1` signs in instead, so the two review accounts can drive
it repeatedly and leave nothing behind.
⚠️ Found while trying to answer whether calls work between two simulators.
They cannot be answered there — VoIP push has no token on a simulator — but
that is about the PUSH, not the call: two foregrounded apps need no push, and
this harness is exactly that shape. It could not be run because of account
creation, not because of anything to do with calling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous message said "FN_TEST_RELAY steers CrossPlatformUITests and
nothing else." That is false, and I found it while about to act on it.
Measured properly: **eight suites honour the variable** and pass it through
as `-FNMSGServer`; `AccountFunctionsUITests` is one of them. **Six ignore it
and sign up** on the server the app ships pointed at.
So there were two different mechanisms, not one:
- `rt88604662` — `SignupSigninRoundTripUITests` ignores the variable. That is
the case the first message described, and it was right about that one.
- `uitest5841078` / `uitest5892792` — nobody's bug. I aimed a signing-up
suite at the live relay and it did exactly what I asked.
⚠️ Correcting this because the wrong version made the gate look like a
workaround for one broken suite. It is not. A rule about variables would have
caught three of the six ignoring suites and none of the second case; a gate at
the call site catches all of them, and does not care which mechanism brought
you there.
No behaviour change — the guard already worked. Only the explanation was
wrong, and the explanation is what somebody reads before deciding whether to
delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two accounts appeared on the PRODUCTION relay today from runs that believed
they were pointed at localhost. `rt88604662` and, after I thought I had
understood the first one, `uitest5841078` and `uitest5892792`.
🔴 **`FN_TEST_RELAY` steers `CrossPlatformUITests` and nothing else.** Every
other suite launches the app on the server it ships pointed at, which is
`relay.flamenet.io`. Setting the variable, reading the word "relay" in it,
and concluding the run is local is the obvious wrong inference, and I made it
twice.
⚠️ **The audit meant to prevent the second one was itself wrong.** I ran
`grep "signUp("`, which cannot match `completeSignUp(` — the S is capitalised.
It reported five suites that sign up. The real number is **thirteen of
twenty-one**. A guard that under-reports is worse than no guard, because it
gets believed; I believed mine and said so out loud.
So the gate goes at the one place all thirteen funnel through, rather than in
a list somebody has to keep correct. `completeSignUp` now requires
`TEST_RUNNER_FN_ALLOW_SIGNUP=1`.
⚠️ **It fails rather than skips.** A skip would be the same silence in a
different colour — the suite reports green having exercised nothing, which is
the exact failure `UITestSafetyTests` exists to prevent one file over.
Proven by running `AccountFunctionsUITests` against production with the
variable unset:
tests that create an account → failed, naming the variable
tests that do not → passed
accounts created → zero
⚠️ It also corrected a diagnosis. `testForgotPasswordOpensAndAsksForTheHandle`
failed in the unguarded production run and PASSES here, so that failure was a
knock-on from earlier sign-ups in the same run rather than a bug in password
reset. I was about to go looking for one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AppSurfaceUITests` drives the message menu by label, so renaming the button
turned both of these red. Caught by running the suite against a local relay,
not by review — I added a guard test for the label and did not grep the UI
tests for the label I was removing.
⚠️ **The second test's NAME was part of the problem.**
`testUnsendTakesTheMessageOutOfTheThread` quietly agreed with the button's
claim — that the message is gone — while the comment three lines down
already said it is gone from this device only. The title and the comment
disagreed, and the title is what shows up in a report. Now
`testDeleteForMeTakesTheMessageOutOfThisDevicesThread`.
Both still assert the same behaviour. Only the words changed, in the place
where the words were wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ChatView` and `GroupChatView` both offered "Unsend" on your own messages,
and both called a function that removes the message from THIS device. The
recipient's copy stayed exactly where it was. Somebody who sent to the wrong
person tapped it, watched the bubble vanish, and had no way to find out.
🔴 **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.
Both branches now say "Delete for me" — `GroupChatView` already had that as
the second half of its ternary, and it was always the accurate half.
⚠️ **The icon was half the promise.** `arrow.uturn.backward` is the
take-it-back glyph. Renaming the label and leaving the arrow would have kept
the claim in the picture, where it is read faster than the word. Now a trash
can, and a test guards that too.
`unsendLocal` and `deleteLocal` stay as two identical functions rather than
collapsing into one: the day a real retract exists, `unsendLocal` is the one
that grows an envelope and the other stays local. Collapsing now would make
that change look bigger than it is and invite doing it to both.
Both guards proved by breaking them, not by watching them pass:
group label reverted to "Unsend" → testNoButtonSaysUnsend FAILED,
naming GroupChatView.swift line ~167
label restored, icon reverted → testTheDeleteAffordance… FAILED,
naming ChatView.swift line ~605
both restored → TEST SUCCEEDED
⚠️ Android has the identical mislabel at ChatScreens.kt:1956 over a
`vault.deleteLine` that sends no envelope. Same fix, separate change.
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 flake itself was already fixed** — `setUp` clears the devices
before each test, and these tests clear exactly what they use, so a
colliding namespace stopped mattering. This is hardening on top, and the
commit says so rather than claiming a fix twice.
What changed:
- The namespace is unique per run instead of random out of 8,000. A random
id is a birthday problem, not isolation: the simulator Keychain persists,
so collisions start rare and become reliable once the suite has run
enough times. Removing the collision means the guarantee no longer rests
on the cleanup list staying complete.
- The device names live in one array and the tests reference it, so the
next device added cannot quietly go uncleaned.
- ⚠️ Cleanup still matters, for the opposite reason to before: a unique
namespace per run would otherwise leave another set of entries behind
every time.
🔴 **A claim I made and then disproved, kept in the comments because the
next reader will think the same thing.** The cleanup lists `dev-A` and
`dev-B` while the suite also names `dev-C`, which looks exactly like a
leak. It is not: `dev-C` appears only in the test that binds user 0, that
binding is refused, and nothing is ever written for it. Checked by pinning
the namespace and running twice with the old cleanup — both passed, so the
"one device short of fixed" story was wrong and the comment now says which
way round it is.
Verified: 8 consecutive runs of the suite, 0 failures; 707 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ios#62 is fixed — I verified every suite: four reach
`UITestPrecondition.demoPassphrase()`, which throws `XCTSkip` when the
variable is unset, and two (`FilesVaultUITests`, `WalletUITests`) launch
into preview modes that render a local stub and never sign in.
⚠️ **But the guard that exists would not have caught a new one.** It is
scoped to suites calling `completeSignUp(` or `completeSignIn(` — right
for what it catches, and blind to a suite that simply launches the app on
a simulator that is already signed in and lets the poll loop talk to
`relay.flamenet.io`. No password typed, production reached anyway.
So this asserts the broader property: every suite that launches the app
either can skip, or is in a preview mode.
⚠️ Preview counts as safe only because those modes really are local —
`-FNMSGFilesPreview` renders `FilesBrowserPreview`, `-FNMSGWalletPreview`
renders a wallet against a stub node rather than Solana. If either gains a
network path it stops belonging on that list, and the comment above the
check is where that is written down.
Proved rather than assumed: a probe suite that launches, cannot skip and is
not in preview was dropped into `UITests/` and the guard failed with
exactly the right message and the right filename. ⚠️ The probe was not
added to the project — the scan reads the directory as text, so it needs no
compilation, which turns a ten-minute target rebuild into a ten-second
check.
708 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**ios#66 — `descendants(of:)` returned 10,000 duplicate ids on a cyclic
index.** It terminated by counting steps, and each pass appended a child,
so an A→B→A cycle produced ten thousand copies of two ids. The screen
calling it is the one offering "delete this folder and everything in it",
so a corrupt index turned that into an offer to delete 10,000 things.
Bounded by what has already been visited instead — which terminates AND
returns the right answer, and needs no magic number. A cycle cannot be
built through this API; the index is a file on disk, so the input is not
only ever our own writes.
**ios#68 — a file inside a folder could not be sent from a conversation.**
The picker listed `list(nil)`, the root only. Nothing said so: the file
was simply absent, which reads as "it isn't there", and the obvious next
move is to keep a second copy at the top level. `allFiles()` walks the
whole tree.
⚠️ It drops unreadable and missing rows rather than showing them disabled,
and that is the ONE place this class hides a row it cannot open —
everywhere else it insists on showing them, because hiding one leaves
somebody unable to see anything is there. A picker exists only for
choosing something to send, and a row that can never be chosen is
furniture.
Five tests, both fixes proved by reverting them: the step counter fails
the cycle test, the root-only filter fails the depth test.
⚠️ Two editing traps worth recording. `open(_:)` with an argument resolves
to the C library's `open()`, not the test helper. And this file ends with
a free function AFTER the class, so appending at the end of the file
splices tests into that function — braces balance, scope does not, and the
error reads as "cannot find 'self' in scope".
707 app tests, 88 engine, 35 client — all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **I bumped WebRTC to M152 to unblock the build. That was the wrong
call, and the right one already existed** — on the 31 open MRs, written
2026-08-31: `Packages/WebRTCBinary`, a local wrapper that declares the
binary target's URL and checksum itself.
The wrapper is strictly better than what I did:
- It keeps the **exact M151 build the app was tested against**, so calls
are not sitting on an unexercised media stack.
- It points at the `151.0.1` ASSET, which downloads fine — the thing that
404s is only the URL inside upstream's manifest. A wrapper obeys neither
their manifest nor their tags.
- The checksum was verified independently and SwiftPM re-checks it on
every fetch, so a substituted binary fails to build rather than shipping.
- And it removes the whole class of failure: a build a third party can
break by deleting a release is not a build.
⚠️ I did not find it because I diagnosed the 404 and fixed it before
reading what the open branches contained. The stack's own `project.yml`
says *"see Packages/WebRTCBinary/Package.swift for why a version pin
stopped being a pin"* — the answer was one file away.
Reverting so the stack lands its version rather than fighting mine over
`project.yml` and `Scripts/Package.resolved`, which is the only thing all
31 MRs conflicted on.
⚠️ Main does not build between this commit and !86. That window is
deliberate and short: main did not build for days before today either, and
the alternative is thirty-one conflicts resolved by hand.
Verified on the stack tip with the wrapper in place: BUILD SUCCEEDED and
707 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **`xcodebuild` failed before compiling a line, on every branch, for
everybody.** Upstream deleted the `151.0.0` release of stasel/WebRTC. A
binary target downloads its asset from the release URL, so resolution died
with `badResponseStatusCode(404)`. This is not a regression in any of our
work — the repository simply stopped building when a third party removed
a file.
⚠️ **`151.0.1` looks like the repair and is not.** Upstream re-tagged and
published a working `WebRTC-M151.xcframework.zip` under it — but the
`Package.swift` AT that tag still points at the deleted `151.0.0` URL, so
SwiftPM follows the manifest and 404s exactly as before. I tried it first;
the asset downloading by hand is not the same as the package resolving.
That left the self-consistent releases either side, so this is M152 — one
Chromium milestone forward. **A forced bump, not a chosen one.** The
alternative was vendoring a 43 MB binary into this repository, which is a
bigger and far less reversible decision than a milestone.
🔴 **Calls are what this library is for, and a compile is not a call.**
The bump has to be exercised device-to-device before any release; the note
in `project.yml` says so at the pin rather than in a commit nobody will
re-read.
⚠️ A stale precompiled module survives the bump and reports as
`RTCPeerConnection.h has been modified since the module file was built` —
DerivedData has to go, or the failure looks like a source error.
Verified: BUILD SUCCEEDED, and 342 unit tests pass with 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 A signature alone does not stop a rollback, and both clients checked only
the signature.
An older home record is GENUINELY SIGNED — the account holder published it,
before they added a second relay. So a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first,
that record verifies perfectly, and the sender delivers to one relay believing
it is delivering to all of them. That is precisely the attack multi-homing
exists to survive, and the serial is what was added to stop it.
The relay refuses a lower serial on WRITE. Nothing refused one on READ, on
either client — `verifiedRelays` on Android and `verifyHomes` here both checked
the signature and stopped. Found while closing the Android parity gap; fixed on
both, because it is the same hole.
The highest serial seen per address is now remembered and a lower one is
refused. Equal is fine — re-reading the same record is the ordinary case and
refusing it would break every second lookup; only strictly lower is a rollback.
🔴 The floor rises only for a record that VERIFIED. Otherwise a hostile relay
could hand over an unsigned record carrying `Int.max`, have the client raise
its floor to it, and lock out every later genuine record — turning the rollback
defence into a denial of service against the account holder. Tested.
⚠️ The refusal does not claim an attack. A relay that has genuinely fallen
behind serves an old record for honest reasons, so the message says both: that
this app has seen a newer list, and that this is also what it looks like when a
relay is trying to be your only mailbox. Nothing in the client can tell those
apart.
⚠️ Keyed by a hash of the address rather than the address itself — which
addresses this device has looked up is metadata worth not leaving in a defaults
plist — and each address has its own floor, so a busy contact's serial cannot
lock out a quiet one's record.
10 tests. 707 iOS unit tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`xcodebuild test -scheme FlamenetMessenger` runs every UI suite, and two of
them were reaching production on any machine without credentials.
🔴 The worse one had not been noticed. `RelayTypesUITests` did not sign in —
it **signed up**, on relay.flamenet.io, relay2 and relay1, on every plain run.
Three real accounts on the live service, created with the literal string
`set-FN_DEMO_PASSPHRASE` as the password, because that was the fallback when
the environment variable was missing. This repository is public, so every
account it ever made has a password anybody can read. It also spent the
account service's three-signups-per-hour-per-IP quota — the quota the sibling
provisioning suite already warns about locking a real person out of.
It is now opt-in on FN_RELAY_TYPES=1 AND requires a real passphrase, with no
fallback. Both gates: the opt-in says this is a provisioning tool rather than
a correctness check, and the passphrase requirement means it cannot proceed
with a value that is written down.
🔴 And a guard, because this bug class has now bitten twice — six unguarded
suites the first round, this one the second. Tests/UITestSafetyTests.swift
runs in the unit target (no simulator, no cost) and fails the build if a
credential falls back to a literal, or if a suite that signs in or signs up
has no way to skip.
The guard immediately found a third instance: LiveCallUITests fell back to
`?? "a-good-password-1"`. It is gated on a handle and a peer, so it only ran
deliberately — but somebody who set those and forgot the passphrase would have
created an account on the live relay with a published password. All three are
required together now.
⚠️ And it found a false positive in its own first version, which is fixed
rather than tolerated: it flagged an EMAIL default in UITestSupport, because
the word "password" appeared in a trailing comment on that line. Trailing
comments are stripped now. A guard that cries wolf gets switched off, and an
email default to a reserved non-resolvable TLD is fine.
Verified: both account-creating suites now skip in under 0.2s, the whole run
5.7s. 697 unit tests pass, both test targets compile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **TLS answers "is this host who DNS says it is". It does not answer
"is this the same relay I have been talking to".** A mis-issued
certificate, a hijacked DNS record, or somebody who obtains the domain
each produce a server with a perfectly valid certificate for the right
name and a different identity behind it.
The relay already publishes an issuer key, and it was already pinned —
but only inside the key-log path, and only when a signed head arrived.
A relay that serves none was never pinned at all, which is exactly the
deployment worth covering. The pin now happens on its own, once per
session, without waiting for anything.
⚠️ **A relay that publishes no key is a weaker deployment, not an
attack.** Somebody self-hosting an older build has nothing to pin, and
refusing to talk to them would be a worse failure than the one this
prevents. Unparseable is the same case: a relay answering with rubbish
is broken, and treating broken as hostile raises an alarm nobody can act
on.
⚠️ A change is recorded, never resolved. Nothing here can tell a
deliberate rotation from a substitution, so the warning names both and
claims neither — an alarm that asserts an attack when a rotation is
equally likely is one people learn to dismiss. Accepting re-pins to what
the relay presents NOW, not to what it presented when the alarm fired,
or the alarm returns immediately.
⚠️ Stored separately from `keyLogIssuerPin` even though both hold the
same key today. Folding them together would let the weaker path — the
one that only runs on a signed head — decide when pinning happens.
693 unit tests green, 7 new. No UI surface, so no UI run.
🔴 **This narrows a window; it does not remove the material.** What it
buys is that a derived key stops sitting in the process for the lifetime
of the app after the one moment it was needed — which matters for a
memory dump, a crash report, or a debugger attached later. It buys
nothing against somebody reading memory while the secret is in use, and
claiming otherwise would spend the whole value of the feature on a lie.
Applied where it is worth most: the master key from the account KDF. It
costs 650,000 iterations of PBKDF2 to produce, is needed for exactly one
HKDF call, and was then left in memory. It is wiped on the way out,
including out of a throw — a derivation that failed half-way is exactly
where material gets forgotten.
The limits are stated in the type and asserted in the tests, because a
wipe that is believed to do more than it does is worse than no wipe: it
gets spent as an argument for holding secrets more loosely.
⚠️ A Swift `String` cannot be zeroed. Strings are immutable, copy on
assignment, and small ones live inline. A passphrase from a `SecureField`
is beyond reach, and that is a limit of the language rather than an
oversight.
⚠️ `Data` is copy-on-write, so a wipe reaches one copy. There is a test
that proves a copy taken beforehand survives, and it says in as many
words that if it ever starts passing the other way the comments are
wrong.
⚠️ `memset_s`, not `memset` — the compiler may delete a write whose
result is never read, and this one by definition never is.
⚠️ iOS does not page application memory to disk, so there is no swap
file in play. That is a property of the platform, not of this code.
686 unit tests green, 11 new. No UI surface, so no UI run.
Nothing in this app read the server's `Date` header, so it had no way to
know its own clock was wrong — and almost everything time-dependent here
is a promise: a story disappears after an hour, a message clears itself
after a day.
🔴 **Those promises are made by the SENDER and enforced on the READER's
device.** So a reader whose clock runs behind keeps a message the sender
was told would be gone, and neither of them can tell. That asymmetry
decides the design: being wrong in that direction breaks somebody else's
expectation silently, while being wrong the other way costs the reader
something they were about to lose anyway.
So for expiry the rule is **the later of the device's clock and the
relay's**, never either alone. A device set back cannot extend a
message's life, and neither can a relay that lies about the time —
they would both have to be wrong in the same direction. The accepted
cost is that a clock set forward expires things early.
⚠️ The relay is a hint, not an authority. A hostile one would like to
move time backwards so disappearing messages linger; taking the later
of the two is exactly what makes that useless to it.
⚠️ The skew is learned from a header every response already carries, so
it costs no extra request. It is not persisted and it ages out after an
hour: a skew learned yesterday says nothing about today, because the
clock may have been corrected or the phone rebooted.
The warning names the symptom before the cause. Somebody whose stories
keep vanishing does not think "my clock is wrong" — they think the app
is broken — so it says what they are seeing, then how to fix it.
675 unit tests green, 14 new. Full UI suite: 48 passed, 19 skipped, 0
failed.
Nothing for the first three failures — a mistyped passcode or a Face ID
that did not read is ordinary, and making the first slip cost a wait
punishes the owner far more often than anybody else. Then 30 seconds,
doubling, stopping at five minutes.
🔴 **The ceiling is not a nicety.** An unbounded back-off locks somebody
out of their own messages permanently: there is no reset, no support
desk, and the data is on their own phone. That is a worse outcome than
the one this defends against.
🔴 Persisted, because the obvious bypass for a back-off held in memory
is to force-quit the app. And it refuses BEFORE showing the system
prompt — letting the sheet appear and discarding the answer would teach
somebody that attempts are free.
Only a genuine wrong answer counts. A cancel is not an attempt, and
counting one would let the owner back themselves into a wait by changing
their mind twice. Switching the lock off clears it: that is done from
inside an unlocked app, so they have already proved who they are.
⚠️ Said rather than hidden: this is wall-clock, so somebody who can
change the device clock skips the wait. It slows a person holding the
phone. What protects the messages is the Keychain and file protection,
exactly as AppLock's own note has always said.
---
Two test defects fixed alongside, neither of them regressions.
**A flake I finally tripped.** `SenderBindingTests` isolated itself with
a random user id out of 8,000, but the simulator Keychain persists
between runs — so after dozens of runs today a namespace collided with
an old one's leftovers and `dev-A` was already bound to 42. It passed
alone and failed in the suite. It now clears the devices it uses rather
than hoping nobody has been there.
**Six UI tests reported red on any clean machine.** They need
FN_DEMO_PASSPHRASE or a session already signed in on this simulator;
without either they ran with the literal string "set-FN_DEMO_PASSPHRASE"
as a password and called that a failure of the demo accounts. They skip
now, loudly, naming the variable or the state — the mirror of the
Android bug fixed in 8c8f2c1, where a skipped live sign-up reported as
passed. A suite with six permanent reds is a suite whose colour nobody
reads, which is exactly how a real break survives a run.
661 unit tests green. Full UI suite: 48 passed, 19 skipped, 0 failed.
The wallet could hold and receive; it can now spend. Everything here is
shaped by one fact: a Solana address carries no checksum, so a mistyped
one is very often still a perfectly valid address belonging to nobody,
and there is no reversal and no support desk.
So the screen leads with paste, shows the address back in blocks of four
on a second screen, and signs nothing until that screen has been read.
Review stays disabled while anything is wrong, and says which thing.
The serialiser is written out by hand rather than reflected over, and
the tests decode the message back with an independent reader rather than
comparing it to a string the same file wrote — a serialiser that agrees
with its own expectations and disagrees with the chain is the failure
worth guarding against. Account order, the header's category counts, the
four-byte discriminant, the length-prefixed payload and the signature's
coverage each have a test, and the signature is also checked NOT to
verify against a different amount.
Money never touches a `Double`. "0.1" parsed as one and multiplied gives
99,999,999 lamports, and there is a test for exactly that figure. More
decimal places than the chain has is refused rather than rounded.
Two reservations are named on screen rather than silently kept back: the
fee, and the rent-exempt minimum below which the runtime deletes the
account outright — so "send everything" means everything above that, and
sending to zero would close the wallet rather than empty it.
🔴 A failed send is never retried. A blockhash is good for about two
minutes, so a submission that timed out may still be accepted; the error
says so and points at the transaction list instead of offering a button
that would pay twice. `skipPreflight` stays false for the same reason —
simulation refuses the obvious failures before a fee is taken.
⚠️ **Not yet proven against a live chain.** The structure follows the
documented format and the tests pin every part of it, but no
transaction built by this code has been accepted by a validator. It
needs a devnet run with a real airdropped balance before it goes near
mainnet, which is what `isTestNetwork` detection was kept for.
653 unit tests and 8 wallet UI tests green, including two that assert
the refusals.
Takes the parts of ios!55 my earlier rebuild in this session did not
have. The picker and the carrying of ids were already back; these were
not, and the second one is a bug fix rather than a feature.
🔴 A group could not be muted at all. Muting was keyed on the AUTHOR of
an arriving message, so silencing a group meant muting every member one
at a time — and anyone who joined later went straight back to buzzing. A
federated thread was worse: every remote correspondent shares
`fromBuddyID: 0`, so muting one silenced all of them. Muting is per
conversation now. The 1:1 key is deliberately unchanged, because it is
what every existing mute on every phone is stored under.
🔴 It also corrects who decides what a mention may do. My rebuild had a
mention always override a mute, which hands every member of a group a
way to interrupt somebody whenever they like. The sender decides a
message is addressed to you; you decide whether being addressed is worth
a notification. Defaults to breaking the mute, with a way off.
`Mentions.resolve` reads the roster rather than the text, so typing
"@anyone" means nothing unless somebody by that name is in the group,
and a message cannot be crafted to address a person it should not reach.
`shouldNotify` is a free function so the rule can be tested at all.
⚠️ Two deliberate divergences from the original commit. The wire key
stays `men`, not `ment` — that is what ios!86 and the Android engine
already speak and were verified interoperating on, and switching it here
would break Android silently. And `maximumMentions` is applied at parse
rather than on the envelope shape, for the same reason it existed: a
hostile sender can name ten thousand ids and the cost lands on the
recipient.
644 tests green.
Clients decided "is this device live" from the directory, which the relay
controls freely. The key log records every departure permanently and
cannot be quietly rewritten — but nothing client-side was reading it for
that. So a relay could keep a revoked device reachable in the directory
while showing the revocation to anyone auditing the log, and both answers
would look consistent to their respective audiences.
The send path now filters the directory through what the log has said.
🔴 A revocation is a one-way latch, and that is the answer to the attack
the ticket names: a relay that stops serving the log it used to serve is
a downgrade that looks like an outage. Latching makes that not matter for
anything already seen. The relay can go quiet, but it cannot un-say what
it already said — silence stops new revocations being learned, it cannot
resurrect an old one.
⚠️ The set only ever grows, in both the fold and the store. A device
absent from a later answer has not been un-revoked; it has not been
mentioned, which a relay can arrange for any device at any time. Removing
on absence would hand back exactly the power this removes.
⚠️ Persisted rather than kept in memory. A latch that reset on launch
would give a quiet relay every revoked device back on the next start,
which is the same downgrade by a slower route.
⚠️ `expire` latches as well as `revoke`, matching the relay's own
`isRetired`. A device that aged out is as gone as one removed by hand,
and enforcing only explicit revocations would leave the commoner case
open.
⚠️ Filtering preserves the directory's order, which is the send order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reinstates the archive, and with it the story mute's storage.
The mute is the part worth explaining. My rebuild of it earlier in this
session put it in `UserDefaults`; the reinstated code puts it in the
sealed snapshot, and its comment says why: a plist in the app container
is readable by anything that reaches the file, and who somebody has
muted is a fact about their social graph, not a display preference. That
argument is right, so the two implementations are unified on the sealed
one rather than left to disagree. `storiesMutedKey` and the
UserDefaults accessors are gone; `areStoriesMuted`/`setStoriesMuted`
stay as the names the views already call and forward to the snapshot.
The mute tests moved with the storage. They asserted on the plist key,
which no longer exists, so they now test `visibleStoryAuthors` — the
pure rule behind the behaviour, without needing an inbox, the same shape
`StoryArchiveTests` uses. That also let one more rule get a test it did
not have: your own stories are never hidden from you, even by a muted id
that reached the set some other way, because hiding them would read as
your posts having failed.
607 tests green.
Accepting a changed safety number was one tap that left nothing behind,
so a later change could not be weighed against a history.
🔴 The record survives the key change that invalidates it, and that is
the whole feature. `recordTrust` cleared the verified flag on a change,
which destroyed the history at the exact moment it becomes most useful:
"you compared this in person in March, and their keys changed today" is a
far stronger thing to show somebody than "not verified" — which is also
what a stranger looks like. The flag still clears; the record is marked
broken instead of deleted, and the profile shows it in both states.
⚠️ The first break wins. A second change later must not overwrite the
date of the one that actually invalidated the check, which is the date
worth weighing against.
⚠️ Un-ticking the toggle removes the record rather than marking it
broken. That is the user saying the comparison did not happen or did not
match — a retraction, not a history.
The method is asked for rather than assumed. The ways of comparing are
not equally strong — a number read over a call that was itself
intercepted is worth less than one read off a screen in a room — and a
record that guessed would be a record of the wrong thing.
⚠️ Every sentence says "on this device". Verification is somebody
comparing two numbers; there is nowhere else it could live, and a record
shown without that caveat invites the reasonable assumption that a new
phone will remember. It lives in the suite, which `wipe()` clears
wholesale, so it leaves with the account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blocking existed in a conversation and nowhere near a story, which is the
surface a stranger is most likely to reach somebody through. Reporting
did not exist at all — not here, not in the relay.
🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
That is a defensible bargain and an invisible one, so the sheet states it
in those words before anything is sent: what leaves the device, who can
read it, and that it cannot be undone. Declining to include a copy is
offered, and says plainly that there may then be nothing to act on.
⚠️ `reportStory` sends only what it was passed. It does not reach into
the thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include, and a helpful "we also attached the
conversation" would be exactly the wrong kind of help.
⚠️ Blocking and reporting are offered together and never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else and nothing may come of it. The copy says which is
which, because a combined control would make one of the two invisible.
The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three faults, none of which produced a conflict marker.
`postTextStory` lost its closing brace at the seam where git joined it
to `postStorySet`, swallowing everything below into it — the same damage
as the quote-jump reinstatement, and again only the build caught it.
`E2EStory.set` needed the treatment `gid` needed before it: a property,
an init parameter, and a coding key. The compiler asks for the first
two. Nothing asks for the third, and without it a sequence comes back
from a relaunch having forgotten it was one.
`setExpiry` mapped `\.ref.expires` over every item, which stopped
compiling once a story could be words with no attachment at all. It
compacts now — a text story has no blob expiry to contribute, and
letting one drag a set's shared expiry to zero would expire the whole
sequence immediately.
The tests built their refs through `item(...).ref`, so they were
unwrapping something they had just created. They now build the ref and
hand it over.
585 tests green.
Each item is an ordinary story envelope carrying a `set` that names the
sequence, its position and its size (spec §6.23).
🔴 One envelope per item rather than one carrying an array. The array is
the obvious encoding and degrades badly: a client predating §6.23
requires a single `att`, fails that guard, and renders the whole set as a
message in the conversation. Separate envelopes degrade to exactly what
they are — several stories from one person in order, which every existing
build already draws correctly.
⚠️ The expiry is computed once, before the loop, and shared by every
item. Computing it per item would stagger the set by however long the
uploads took. `setExpiry` then takes the EARLIEST of what actually
arrived: items are required to carry the same stamp, 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. That decay is both
confusing to look at and a disclosure — the gaps reveal the order the
author added things.
⚠️ `grouped` renders what arrived. `n` is the sender's claim and any item
can fail to arrive, so a set is never held back or marked incomplete: a
missing item is indistinguishable from one never sent. Stories with no
set are keyed so two of them cannot collide into one group.
Only the first item carries the caption — a sequence has one thing to
say, and repeating it under every picture reads as a stutter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The same fault as 50a84c8, reproduced exactly: git's auto-merge placed
`lifetimeSeconds` inside `E2EPin` instead of `E2EMessage`, at a struct
boundary it had no way to reason about. There was no conflict marker on
it. The file parsed perfectly with the property on the wrong type.
`E2EPin` is back to the three fields it is documented as having — an id,
a stamp and who pinned it, deliberately never a copy of the message,
because storing the text would let a pin keep a message readable after
its own timer removed it.
Worth keeping the note from the original fix: a syntax check cannot
catch this, and neither can a conflict-marker sweep. Only building it
does. That is the argument for verifying each reinstatement against the
tree it lands in rather than the batch at the end.
575 tests green.
🔴 The shorter timer always wins. A per-message timer may only take life
away, never add it — otherwise a sender defeats the recipient's
conversation timer by putting a long one on every message, turning a
setting the recipient chose on their own device into something the other
party overrules.
A duration, never a deadline. An absolute expiry would be the sender's
clock: they could backdate a message so it vanished on arrival, or
forward-date one so it never expired. Counted from local receipt, the
rule §6.11 already follows.
🔴 pruneDisappeared skipped any thread whose conversation timer was off,
so a timed message in an untimed conversation would have been kept for
ever. It prunes per message now.
The one-off timer disarms on send. A timer that stayed set would quietly
apply to everything typed after it, and the person would find out when a
message they meant to keep was gone.
⚠️ The backlog filed this with the trade "the relay learns a message is
timed". It does not — the content envelope is inside the ratchet, the
same place §6.11's announcement rides, and the relay stores only an
opaque payload. Recorded in the spec.
Closes#29
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 A pin is an id and a stamp, never a copy of the message. Storing the
text would keep a pinned message readable after its own timer had
removed it — pinning would become a way to defeat disappearing messages,
silently, from either side of a conversation. E2EPin has three fields and
none of them is text, so it cannot be added back by forgetting a line.
The pin resolves through the thread every time it is drawn, and shows
nothing when the message is gone.
Monotonic, like an edit, and so is an unpin: a relay orders envelopes by
receipt rather than authorship, so two people pinning at once can arrive
in either order. Unlike a message, a pin is shared state both people are
looking at, so an inconsistency here does not resolve itself — the two
phones would disagree for ever.
One pin, not a list. A list needs an ordering, a cap, and a rule for two
people adding the last one at once — three more pieces of shared state
across devices that cannot talk to each other directly.
⚠️ One-to-one only for now. A group pin is carried correctly on the wire
but cannot resolve until a group thread records a mid (#58, in review);
shipping one that could never resolve would be a permanently empty
banner.
Closes#50
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stripping is the feature, not a side effect. Copying by hand loses
the original sender and the thread it came from, which is what people
already expect a forward to do — so it is the default rather than a
setting to find. There is no "include sender" checkbox and the spec
deliberately has no field for one: a field would become a default, and a
forward that carries its author by default is not a forward.
🔴 A fresh mid, never the original's. A mid is unique within a
conversation, so the same one in two conversations is a handle the RELAY
can use to tie them together — the exact link a forward breaks.
Forwardable is a small type naming what travels, so a field added to
E2EMessage later is excluded by default rather than by somebody
remembering.
🔴 A photo is re-uploaded, never re-referenced. Sending the original blob
id and key would work and be far cheaper, and would let the relay watch
one blob fetched from two conversations. Costs a download and an upload,
and means forwarding a photo can fail where text cannot — reported
rather than papered over with a caption and no picture.
Shows "Forwarded" and never from whom. Second-hand words presented as
the sender's own is a small dishonesty; naming the author would be the
provenance this strips. Those are different and only the first is shown.
⚠️ isForwarded was written as a plain Bool first, which would have made
every existing snapshot fail to decode and wiped everyone's history —
synthesised decoding throws on a missing key rather than using the
default. Optional now, with the test that catches it.
Closes#28
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cherry-picking the quote-jump onto a tree that now has edit-a-message
produced a file git was happy with and the compiler was not:
`commitEdit` lost its closing brace, so everything below it — the poll
loop, the append helper — became local declarations inside it, and two
doc comments were fused so `react`'s description ended up on top of
`sendSticker`.
This is the same damage described in ios!73, and worth naming: git
resolved it silently. There were no conflict markers in this file. The
only thing that caught it was building.
Also reorders the `MessageBubble` call to match the declaration —
onQuoteTap and isFlashing are declared before onSaveFile, and a
memberwise init takes its arguments in declaration order however
defaulted they are. That is one of the three faults ios!85 lists by
name.
536 tests green.
The quote is tappable only when the message it names is actually on this
device, and inert otherwise — including to VoiceOver, which must not
offer "Activate" on something that will do nothing. A reply carries its
own preview precisely because the original may never have arrived here
(§6.9), so "nowhere to go" is the ordinary case, not a failure.
🔴 The target is matched on mid and never on the preview. §6.9 says the
preview is the replier's assertion and that they can lie about it, so
matching on text would let a crafted reply send the reader to a message
of the sender's choosing — invisibly, since the quote would look right.
Spec clarified to say so: flamenet-e2e!8.
The tap clears any active search first. The list is visibleMessages,
which search filters, and scrollTo an id that is not in the list does
nothing — a tap that appeared to work and did not, which is the exact
failure the tappable-only-when-resolvable rule exists to avoid.
Resolution is a free function on Message rather than a method on the
view model, for the same reason applyReaction is one: a ChatViewModel
needs a live inbox, which needs a network client and a Keychain.
⚠️ iOS only. Android's transcript stores no reply reference at all, so a
reply's quote is not rendered there and there is nothing to tap — see
paulhitt/flamenet-messenger-android#10, updated with that finding.
Closes#48
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A timer promises the message goes away; a screenshot breaks that promise
silently and the sender never learns. It cannot be prevented, so it is
reported (spec §6.27).
🔴 Most of the care here is in refusing to let the absence of a notice
mean anything. The device that took the screenshot is the device that
reports it, so a modified build, a second phone photographing the screen,
or the platform's own recorder all produce nothing at all. A notice means
one was probably taken; no notice means NOTHING. So the notices are not
counted anywhere — a running total would be read as a measurement, and
its zero would be read as "nobody screenshotted", which is precisely the
inference the section forbids. The disclaimer names the ways detection
fails rather than merely admitting that it can: "sometimes this doesn't
work" tells nobody what to worry about.
⚠️ Only where a timer is set. Everywhere else this would be a general
surveillance feature rather than the narrow one it is — the promise a
screenshot breaks is the one §6.11 made, and where no promise was made
there is nothing to report against. The timer check comes before the
rate-limit check, so a suppression window cannot make an untimed
conversation start reporting.
⚠️ Bound to the thread being on screen, not to the app. A screenshot
taken on the settings screen is not a screenshot of their message.
⚠️ One notice per conversation per five seconds: one screenshot often
produces several platform events, and a person who took one should not
appear to have taken four.
⚠️ The last-notice times are session-only. Persisting them would be
storing who screenshotted whom — a record of exactly the thing this
reports — on disk, to enforce a window that is seconds long.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 A device that had registered once erased its local history and wiped
its Keychain whenever the relay answered a poll with 410. Nothing about
that was authenticated. The threat model says the relay is untrusted with
content — and it is, it still cannot read anything — but it could
DESTROY everything, for every user, irreversibly, by returning one status
code. That was never the intent (ios#60).
The unsigned path now signs out and stops polling, and leaves stored
messages exactly where they are. The device really is removed
server-side, so stopping is right; erasing is not something an unsigned
mechanism gets to ask for.
Erasing now requires a wipe instruction signed by the account key
(§6.26). The relay cannot produce one: the fnid this device saved at
sign-in IS the account public key, so an invented instruction fails the
signature. `deviceRevoked` still means "erase" and now has a signature
behind it; `deviceDeregistered` is the relay's unsigned word and takes
nothing with it.
⚠️ The device id is checked as well as the signature. A genuine
instruction for another device carries a genuine signature — verifying
that alone would let one device's wipe erase every device that saw it.
⚠️ The signature covers the device id and the timestamp, so a captured
instruction cannot be re-pointed or re-dated. The signed bytes are pinned
in a test: three implementations have to agree byte for byte, and a
format that drifts is a wipe that silently stops working.
⚠️ The account key is parsed from the address saved at sign-in, never
from anything the relay says now. A relay able to substitute the key it
is checked against could forge whatever it liked.
⚠️ Nothing here claims the data is gone. A device that is off or offline
never sees the instruction.
This is the honouring half of ios#38. Issuing one still needs a relay
route to carry it and a passphrase re-entry to sign it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ticket says adding a device is silent. It is not — it tripped
`.changed`, which is the loudest state there is and blocks sending. The
real defect was that the two were indistinguishable, and the doc comment
on `.changed` admitted it: "either they reinstalled / added a device, or
someone is trying to read the thread."
That was wrong in both directions at once. Adding a device — the more
realistic attack, because it reads as somebody buying a phone — was
invisible as a distinct event, and it also blocked sending on every
person who legitimately owns a tablet. Loud in the wrong way and silent
in the right one.
🔴 The split is on which key set moved. An attacker who substitutes a key
cannot produce the old one, so the old one disappears: a missing pinned
key is the substitution shape and still stops the send. Every pinned key
still present with more alongside is an addition — nothing breaks, which
is exactly why it needs saying out loud rather than blocking.
⚠️ Missing beats added when both are true. A set that dropped a key and
gained one is a substitution wearing an addition's clothes, and the safer
reading is the one that stops the send.
⚠️ An addition is pinned so the notice fires once. Left unpinned, every
bundle fetch would re-report the same new device for ever, and a warning
that never stops is one people learn to scroll past.
🔴 Verification does not survive an addition. What was compared in person
was compared against the keys they had then; a device added since was
never part of that, and carrying the tick over would let it inherit a
check nobody performed on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sealing hides which account sent a message from the relay. When the
peer's delivery key or capability is missing, the send still goes —
end-to-end encrypted as always — but unsealed, and the relay learns the
sender. That degradation was silent: `lastSendWasSealed` held only the
most recent outcome and nothing accumulated, so nobody could answer "how
often does this happen to me".
Now every send's outcome is counted, and the privacy screen says what
share went unsealed. Zero sends reports as "nothing sent yet" rather than
a reassuring 0%, which is what a plain ratio would have rendered on a
fresh install.
🔴 The copy distinguishes sender from content, and there are tests
asserting it does. Somebody who reads "sealed" as "encrypted" makes the
wrong decision in both directions — relaxed when it is off, and alarmed
when it degrades on a message the server could never read anyway.
⚠️ Counted in 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, kept instead on
the device.
Refusing rather than degrading is offered and is OFF by default. That is
the honest default rather than the lazy one: refusing turns a metadata
leak into an undelivered message, which for most people is the worse
outcome. The refusal has its own error case so it can explain itself — a
bare "couldn't send" reads as the app being broken, and the next thing
somebody does is turn the setting off without knowing that is what they
are doing.
⚠️ The gate is checked before the capability is spent. Refusing after
taking a token would burn one per attempt and drain the contact's batch
while nothing was delivered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reinstated group story added `gid` to `E2EStory`, but text stories
had since given that type an explicit init and an explicit `CodingKeys`
— so the property existed and nothing could set it or save it.
Two separate faults, and the second is the quieter one. Adding `gid` to
the init fixes the compile error. Adding it to `CodingKeys` fixes
something the compiler would never have mentioned: a key left out of
that list is dropped on the way to storage, so a group story would come
back from a relaunch having forgotten its audience. That reads as the
feature not working rather than as a storage bug.
483 tests green.
An ordinary story envelope carrying `gid`, fanned out pairwise to the
members exactly as a group message is, and rendered in the story surface
(spec §6.24).
🔴 The group's roster is the audience and `storyAudience` does not apply.
Those are two different lists making two different statements — who may
see this account's stories, and who is in a conversation — and they will
disagree. The roster wins, because a story addressed to a group that
silently dropped some members would be a group message part of the group
cannot see, which is worse than the failure being avoided.
That makes the disclosure load-bearing rather than polite, so
`groupStoryReach` returns the overlap rather than a warning: which
members are excluded by this account's story audience, and which are not
contacts at all. The two are counted separately — a non-contact is not
"excluded from your stories", they were never on that list.
🔴 The inbound branch catches a story BEFORE the group-message path.
Falling through would render it as a message in the group's conversation:
a story that is a message, and one that never expires out of the thread.
⚠️ `exp` is the ordinary story lifetime. Group membership does not extend
it, and it is not kept merely because it is also addressed to a
conversation that keeps history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reinstated save applied without a textual conflict and did not
compile: it was written when every story had a blob, and text stories
have since made `E2EStory.ref` optional.
The fix is the one the original author wrote into the comment beside it
— `isSaveable` has taken a `hasBlob` parameter since the day it was
written, and the caller passed `true` because at the time that was
always so. It now asks the real question, so the button is absent on a
story of words rather than present and failing.
Worth noting what caught it: nothing textual. A clean cherry-pick, a
type error at build. This is the class of thing the 2 September batch
merge could not have caught, because it verified once at the end.
475 tests green.
A story disappears from the author's own device too, so there was no way
to keep something you made. Saving your own before expiry is ordinary
everywhere else.
🔴 Only ever your own, and the rule is `E2EInbox.isSaveable` rather than
a condition on the button. 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 view is one refactor away
from being lost. It is tested from both directions.
⚠️ The confirmation says the copy has left the disappearing model, and
where it went. "Saved" alone would be true and useless: the point is that
this one copy now outlives the story, and somebody who is not told that
may reasonably assume the expiry still covers it.
⚠️ `.addOnly` authorisation rather than full access. Saving needs
permission to add a photo and nothing else; asking for read access as
well would let this app enumerate somebody's whole library in order to
perform a write. The narrower prompt is also the honest one.
⚠️ `NSPhotoLibraryAddUsageDescription` had to be added. Without it iOS
does not warn — it terminates the app the first time somebody saves.
The helper already takes a `hasBlob` argument the text stories of §6.21
will need; on this branch every story has one, so only the call site
moves when that branch lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 The wire always carried one and the model never stored it.
encodeGroupText has taken a mid: since §6.9 shipped and the parser reads
it back — E2EGroupMessage was where it stopped. So nothing in a group
could name an earlier message: no reply to jump to, no reaction to fold,
no edit to apply. Every one of those was "codec done" and none worked.
It also had a symptom visible all along. markGroupDelivered matched a
bubble by isMine && date == at, because there was no id to match on, so
two messages sent in the same millisecond restated the wrong one —
cheap to hit when a queued burst goes out at once. The rule now matches
on mid, and moved onto the model as a static so it could be tested at
all: it went months without a test because reaching it needed a network
client and a Keychain.
The timestamp path stays, and only for messages with no id. Those were
written before group ids existed, and dropping it would leave every one
of their pending bubbles saying "will send" for ever.
The group bubble's projection now carries mid and replyTo, so the quote
block the bubble already knew how to draw finally gets something to draw.
Closes#58
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An envelope naming an earlier mid replaces its text. Two things keep
that from being a forgery primitive, and both are enforced and tested
rather than assumed.
Only the author may edit. In a 1:1, isMine is the whole of authorship,
so an inbound edit may only touch a message the buddy wrote and a local
edit only one we wrote. Without it a single envelope naming any mid
rewrites what YOU said in your own transcript. re.author is never
consulted — anyone can put any id there — so authorship is decided from
the copy of the message we already hold.
The reader is always told. "Edited" shows on both sides and is not
behind the timestamp setting or any other, because an edit nobody can
detect puts new words in a bubble they already trusted.
Also: monotonic (relays order by receipt, not authorship, so two edits
can arrive backwards), never a bubble of its own, never a notification,
and it may not empty a message — retracting is a different operation
with different honesty requirements (ios#57).
Send routes through one submit() on the view model rather than the three
places in the view that call it, so the one that forgets an edit is in
progress cannot send a duplicate instead of a correction.
Closes#26
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay gained two refusals alongside look-alike handle checking, and
`RoutesAccount` says why mapping them here is not optional: *"These codes
are not arbitrary: the shipped client switches on them to pick its own
error copy, and anything else falls through to a generic failure."* A
refusal somebody could act on, rendered as "something went wrong", is the
worst version of it — they retry the same thing and are refused again.
⚠️ **Both messages say what to DO.** "Too similar" on its own leaves
somebody retyping the same name with a different digit and being refused
again; the fix is to change a letter, so the copy says that. The
disposable-address one explains the consequence — that address is how
they get back in — rather than sounding like a judgement about their
email provider.
**`SignupErrorCopyTests` is new, and error copy had no coverage at all.**
It pins every sign-up refusal to its own words, fails if two share a
message, and fails if a code renders as itself — which is what falling
through the switch looks like from the outside.
⚠️ It also pins the sign-in refusal: `bad_credentials` must not reveal
which half was wrong. The relay answers identically on purpose, and a
message here that told them apart would undo that at the last step.
347 tests pass, up from 342.
Pairs with flamenet-relay!11.
The relay can charge a few seconds of arithmetic before an account
exists — cheap for one person, expensive for a farm, and unlike a
CAPTCHA it hands nothing to a third party. This is the client half:
ask what work is wanted, solve it off the main actor, send the answer
beside the sign-up fields.
Every failure path ends in "sign up anyway". An older relay, a
WordPress-backed site, a request that times out — none of those are
reasons somebody cannot create an account, so the fetch returns nil for
all of them and the fields are simply absent.
Two refusals get their own sentences: an app too old to answer, and an
answer that did not hold up (nearly always a challenge that expired
while somebody was typing). Sharing one message would send half these
people to the App Store for nothing.
The bit counting is pinned to fixed vectors, not checked against our own
arithmetic. A byte-counting implementation still SATISFIES a 9-bit
difficulty — it overshoots to 16 bits and does 230x the work — so an
assertion about "at least 9 leading zeros" cannot see the bug. The
first draft of these tests had exactly that hole and caught nothing.
Refs flamenet-relay#39
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.
🔴 **The remedy cannot live inside the app.** An unverified account
cannot get a relay token — that is what unverified means — so it never
reaches the app at all. A verification screen behind the door is one this
person can never open. `VerifyEmailSheet` is presented from the SIGN-IN
screen, and the sign-in error offers a button straight to it.
That needed the refusal's code kept, not just its sentence. `lastErrorCode`
now sits beside `lastError`: matching on the words would break the first
time the copy was edited, and that breakage locks somebody out silently
rather than loudly.
⚠️ **The copy must not read as a credential problem.** Somebody told
their sign-in failed will change a passphrase that was never wrong — and
here the passphrase is the identity, so that is not a harmless retry. A
test asserts "password", "passphrase", "incorrect" and "wrong" never
appear in it.
⚠️ The code field turns off autocapitalisation and autocorrect and trims
whitespace. A code pasted out of a mail client arrives with a newline
more often than not, and refusing it for that looks exactly like a wrong
code.
Four tests, 346 pass.
Sealed in the history file, not a plist — 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. UserDefaults would write it unencrypted, leave it readable
while the device is locked, and carry it into every backup. In the
sealed snapshot it inherits the Keychain key, the file-protection class,
and purge() on sign-out.
All three composers: 1:1, group, and federated. The federated one needs
its own key because remoteThreads has no local id — without it every
remote correspondent would share one, and a draft written to somebody on
another relay would appear in the composer for the next person.
Drafts ride the existing 1.5s history debounce rather than getting their
own writer: they change on every keystroke, and a separate one would
seal the whole file per character typed.
Whitespace is not a draft — restoring " " gives a composer that looks
empty and reports itself as having content, which here means an enabled
Send button that sends nothing. But text is stored as typed, not
trimmed: somebody who left a trailing space was mid-word.
Closes#47
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list only ever searched NAMES, so a word somebody said found
nothing.
🔴 There is no index, and that is a decision rather than an omission. An
index derived from the decrypted history is plaintext by another name:
it would have to be sealed as carefully as the history, purged with it,
and kept consistent with it — a second copy of everything anybody ever
wrote, with its own ways to go wrong. It is not needed, because the
inbox already restores the whole history into memory at launch. A scan
over it creates nothing new at rest, so there is nothing new to protect.
MessageSearch names the condition under which that stops being true: if
history is ever paged out of memory or outgrows a linear scan, an index
becomes necessary — and must then be sealed with the history key and
purged by E2EHistoryStore.purge(), or the app will have quietly acquired
an unencrypted transcript.
⚠️ The query lives in view state and nowhere else — not beside drafts,
not in UserDefaults, not in the snapshot. What somebody searched for is
often more revealing than what they found.
An empty query finds nothing, never everything: dumping the whole
history onto one screen the moment the search box is focused is the
worst possible default in the place most likely to be read over a
shoulder.
Accent-insensitive, because somebody typing "cafe" means "café" and a
search that knows the difference quietly fails in every language with
accents. Plain substring, never a regex — a stray bracket typed into a
search box is a crash or a hang.
Closes#25
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wallet had a Balance card that appeared only when there was a figure
to put in it and an Activity card that appeared only when there was
history — so the state almost everybody sees first, a wallet with
neither, showed neither, and read as a screen that had failed to load.
Zero is an answer. Both sections are always there now: "0.00 SOL" with
"nothing has arrived at this address", and "No transactions yet" saying
what will show up there, including the attempts that failed.
An em dash, not a zero, when we genuinely do not know — still asking, or
could not ask. Writing 0.00 there would be stating a balance we have not
been told.
The endpoint defaults to Solana's public mainnet node. It used to start
blank so the wallet contacted nobody until asked, which is the more
careful default and produced a balance that said it was not pointed
anywhere plus a setup step before the screen did anything. The trade is
stated on the screen instead: the node is named next to the balance, and
the line about what it learns stays. Nothing is contacted until there is
a wallet and an address to ask about. `http://` is refused and falls
back rather than leaving the wallet pointed nowhere.
A wallet that exists now opens showing its first address and that
address's balance, rather than a button that fetches one.
The recovery phrase moved ABOVE the new sections. Adding them pushed it
off the bottom of the screen, and a phrase somebody has to go looking
for is a phrase that does not get written down. That also fixes the UI
test that had started reporting "the phrase is not 24 words" about a
phrase that was perfectly correct.
`WalletView` takes its chain client as a parameter so the preview and
its UI tests run against a stub. Asserting "No transactions yet" against
Solana would be a test of somebody else's uptime.
399 unit tests and all 6 wallet UI tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 **The two-section Files screen was my mistake.** "On this phone" and
"Encrypted" read as two places, and the first one read as a browser of the
phone's own files — so an empty list looked broken rather than empty,
which is exactly how it was reported. Wrong shape: whether a file is
encrypted is a PROPERTY of that file, not a destination.
One list now. Locked files are marked on their own row, and the choice
between adding a reference and adding a locked copy lives in the + menu,
at the moment it is actually a decision — rather than as two tabs somebody
has to interpret before they have added anything at all. The capability is
unchanged; the concept is gone from the interface.
The wallet's node field was the same failure in miniature: a blank box and
"point it at a node" is not an actionable instruction. It now suggests
Solana's public mainnet endpoint — verified to answer `getHealth` and to
return a balance in the shape ChainRPC parses — alongside "use my own",
and each suggestion says who runs it, because "pick a node" is not a
decision anybody can make without that.
⚠️ Test networks are no longer suggested but are still RECOGNISED. Anybody
pointing their own node at devnet needs the balance labelled, or play
money shown like real money invites them to believe they have been paid.
397 unit tests green; the Files UI tests are rewritten for one list and
assert the old section control is gone rather than merely unused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two destinations, because they are two different things a person comes to
the app to do. The wallet was reachable only through Files, on the grounds
that they share an unlock — true, and an implementation detail nobody
outside this code should have to know. What it produced was somebody
pressing "wallet" and arriving at a file list. The shared key stays
shared: unlocking either still unlocks the other.
⚠️ The wallet route out of Files is deleted rather than left in place. It
had no caller, and an unreachable navigation destination is something the
next reader has to disprove.
The paperclip is a menu now. It went straight to the camera roll, so
sending a document meant leaving the conversation, opening Files, finding
the file, swiping, and choosing the person you were already talking to.
The file was sendable; it was just not sendable from where anybody would
look.
⚠️ Picking a file needs Files unlocked, and the sheet SAYS so rather than
asking for a passphrase. A chat that prompts for one is a chat that has
started collecting it, and Files goes out of its way not to hold it.
391 unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Balance and activity, from a node you choose.
🔴 The endpoint is a privacy decision, not a setting, and the screen says
so. A fresh address per receive stops anyone reading the ledger from
grouping your receipts — but ask one node about all of them from one
connection and that node can group them anyway, because it learns those
addresses belong to one person even though the chain does not. Nothing
about the chain fixes that. The endpoint can, so the recommendation on
screen is to point it at a node you run.
⚠️ Nothing is contacted until somebody says where. A wallet that quietly
started talking to a stranger's node the first time it opened would be
doing the one thing this product does not do anywhere else. https only:
the request carries WHICH addresses you are asking about, which is the
part worth protecting — the balances themselves are already public.
🔴 A failed transaction still appears in an address's history, and is
marked failed. Showing it like any other row would have somebody believe a
payment went through when it did not.
⚠️ A JSON-RPC error arrives with HTTP 200. Reading only the status code
takes a refusal for an answer and throws away the node's own explanation,
which is the only account of why it refused.
⚠️ Balances are formatted with integer arithmetic, never a Double. Binary
floating point cannot hold 0.1, and a wallet that renders money through
one shows people a figure that is very slightly not theirs.
Tests run against canned node answers rather than a network: a wallet test
that depends on somebody else's node fails for unrelated reasons and gets
switched off, and this is the layer where being wrong costs money.
Sending is not built. That is a bigger and more dangerous piece of work
and wants its own change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last two of the five features the 2 September revert took out.
A REPLY goes to the author and lands in your ordinary conversation with
them, because that is the only place a reply to a story can go — a story
is not a place, and there is no shared thread under it. Which is also why
it is private: the other people who saw the story have nowhere to read it.
🔴 A short piece of the story travels WITH the reply. Stories expire, so
by the time somebody reads "replied to your story" the story itself may be
gone — and a quote of something that no longer exists is a message that
cannot be understood. The bubble labels it too, or an unlabelled quote
reads as a reply to a message the reader cannot find.
MUTING somebody's stories is deliberately NOT muting their messages, and
uses its own key. Wanting somebody's news to stop is not wanting to stop
hearing from them, and folding the two together would make "I don't need
their holiday photos" cost a missed message. It is also not blocking:
nothing is told to the other person and nothing is refused.
⚠️ Muting happens on the story, where somebody decides they have had
enough — but a mute you can only turn ON is a trap, so the way back is a
toggle on their profile that also says what muting is not.
Two compiler surprises worth recording, both "unable to type-check this
expression in reasonable time": the chat presenter and the story feed card
each tipped over on ONE more argument. Both are extracted now. Neither had
anything wrong with it, which is what makes that failure mode expensive.
384 unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more of the five features the 2 September revert took out, done one at
a time rather than as a batch — which is the whole lesson of that revert.
MENTIONS travel as a user ID, never the name in the text. Matching on a
display name would mean anybody could be reached by typing their name, and
somebody who renamed themselves could never be reached at all. The id is
recorded when picked from the @ list and filtered at send time against
what is still in the message, so picking somebody and then deleting their
name does not ring them about a message that no longer mentions them.
🔴 A mention overrides a mute. That is the entire point: muting a group
means "do not tell me about the chatter", never "do not tell me when
somebody needs me". It deliberately does NOT override the global push
switch or a conversation already on screen — those are a different kind of
off, and neither wants to be shouted at.
STORY REACTIONS go to the author alone, as an ordinary sealed message. A
story is sent to each viewer rather than hosted, so there is no shared
object to attach a reaction to and nobody else to tell — which is a
privacy property rather than a limitation: other viewers never learn who
reacted, because there is nowhere for them to look.
⚠️ Both a message reaction and a story reaction are `kind: "react"`; only
an `st` flag separates them. Without it a story reaction would try to fold
its emoji onto a message with that id — and if one ever matched, onto the
wrong thing entirely. There is a test for each direction.
⚠️ Older builds: a story reaction reads as an ordinary one, finds no
message with that id, and does nothing. Asserted rather than assumed,
because "silently does nothing" is only correct when it is deliberate.
The quick row is five emoji, not a picker. A story is glanced at and
scrolled past; anything needing two taps to say "nice" will not be used.
376 unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Not a missing button. `E2EStory.ref` was non-optional and every path —
model, storage, feed card, viewer, dedupe, seen-tracking — assumed a
picture, so there was nowhere for a text story to be carried, kept or
drawn. And the way in was a `PhotosPicker`, so pressing "new story" opened
the camera roll immediately with no way out to anything else.
The attachment is optional now, stories carry their own lifetime and their
own identity key, and the button is a menu: photo or video, or just words.
🔴 The identity key matters more than it looks. A story arrives once per
device the author sent from, and the blob token was what made those copies
one story — it was also what "seen" was keyed on. A text story has no
blob, so it carries an id the sender minted. Without it, one text story
would appear as many times as the sender has phones.
⚠️ `expires` and `key` are optional IN STORAGE, falling back to the
attachment. A Codable default does not make a key optional — the same
mistake made in the files vault this morning — and here it would have
meant every story already on a phone failing to decode, which presents as
the feed losing everything. There is a test that strips both fields from
an encoded story and decodes it.
⚠️ Older builds are unaffected and still legible: a story envelope with no
attachment is exactly what they treat as unsupported, and the words are in
the caption, which is what they show.
362 unit tests green, including one asserting a picture story parses
exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mini wallet in Home's bar switched to the Files tab, because the key
that opens a wallet is the Files identity key. True, and an implementation
detail nobody outside this code should have to know — what it produced was
somebody pressing "wallet" and arriving at a file list, which reads as a
bug even when it is about to work.
The wallet now asks for the passphrase itself, with the same identity and
the same Face ID opt-in Files uses, and opens straight through when Files
is already unlocked. Unlocking either one now unlocks the other, which is
what sharing a key was supposed to buy in the first place.
Also: call-signalling interop tests, written while chasing calls that
turned out to be broken by a missing database grant rather than by
anything here. They earn their place anyway — they rule out the file
fields having disturbed call envelopes, and they pin a constraint that
until now lived only in a comment:
🔴 `fnc` must be the FIRST key in an envelope. Detection is a prefix test,
so any other order is not an envelope at all — it becomes plain text, and
what somebody sees is raw JSON in their chat or, for a call, nothing
whatsoever. That is why both engines hand-assemble these strings rather
than encoding a dictionary: JSONEncoder and JSONObject are both free to
reorder keys, and the day one of them does, calls stop arriving with no
error anywhere. The test asserts a reordered envelope is NOT recognised,
so the constraint fails somewhere rather than only being described.
⚠️ My first version of that test asserted the opposite — that order did
not matter — and it was the test that was wrong, not the parser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createShare/openShare minted a /s/<id>#<secret> URL. The secret rode in
the fragment, which browsers never transmit, so the server genuinely could
not open what it served — cryptographically that was fine. What was not
fine is what the link *is*: a bearer token for a classified document,
pasteable into any chat, mailable to anybody, forwardable for ever, with
nothing on the far end that knows or cares what the file was labelled.
Sharing is in the apps now — a document goes to a named contact through
the ratchet, carrying its label, and the sender is told who will be able
to open it. Worse for handing something to a stranger; much better for a
system built on knowing who has what.
⚠️ Nothing can mint a new link any more. Existing links stay the server's
business until that route is retired, which is a deployment step and not
this commit.
Also corrected a comment that had gone stale within the hour: it claimed
FilesSession was the only WalletKeys implementation that ships. The app
uses LocalIdentityStore now. The conformance stays, because the enclave
client is still the library the retired Files apps were built on and
dropping it is a separate decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SLIP-0010 ed25519 derivation, asserted against every step of both
published vectors — 12 in all, checked into
Tests/FilesEngineTests/vectors/. A tree that gets the master key right and
one child wrong derives a wallet nobody else can reach, and the phrase
would still look valid; the only way to notice is somebody else's numbers.
🔴 Nothing identifying is an input. The seed is fresh entropy and the only
thing that varies a key, so no address can be computed from a Flamenet
identity and no identity from an address. There is a test that says so,
which is what should stop anybody later threading a username through here.
🔴 A fresh address per receive, as the ordinary action rather than a
setting. Reusing one address hands anybody reading a public ledger the
whole history of what somebody received — and unlike almost everything
else about a public chain, that is a linkability the wallet can prevent.
Older addresses keep working; they all come from the same phrase, so this
costs no extra backup material.
⚠️ The screen says, in those words, that there is no network connection
and nothing can show a balance or send anything. An address next to a
"0.00" would be claiming a chain client that does not exist.
Hardened derivation only, and an unhardened path is REFUSED rather than
silently hardened: SLIP-0010 defines no unhardened derivation for this
curve, and quietly returning a different key than every other wallet gives
from `m/44` is worse than an error. secp256k1 chains need curve arithmetic
CryptoKit does not provide — a dependency decision, not something to
approximate.
Base58 caught its own classic bug in test: seeded with a zero digit, a
single 0x00 byte encoded as "11", so every address with a leading zero
came out a character too long.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files gains a Send action; conversations gain a document bubble with
"Save to Files". A referenced file becomes ciphertext for the first time
at this moment, sealed under its label by the vault before the transport
seals it again for the relay.
🔴 The send sheet IS the enforcement, and is written as such. It names the
recipient and says, in those words, that they will be able to open it and
that the classification does not stop them. A picker that only listed
names would let somebody hand over a Restricted document without ever
seeing either fact — and once the envelope goes, the content key goes with
it and no server will refuse on the way.
A received document saves under the SENDER's classification, never
Unclassified: dropping the label on arrival would quietly declassify
everything anybody sent, which is the failure the marking exists to
prevent. It saves as an encrypted copy, not a reference — there is no file
on this phone to point at.
The unlocked vault is published on AppEnvironment so a conversation can
save without ever asking for a passphrase, and is cleared on sign-out —
left behind, the next account's chat could write into the previous
account's files. While Files is locked the button is absent rather than
present-and-failing.
⚠️ Caught before shipping: `saveNotice` was set on both the success and
failure paths and rendered nowhere, so saving a file — or failing to —
happened in complete silence. The alert now hangs off the body that owns
the state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sending a document rides the ordinary encrypted attachment path — same
blob upload, same key inside the ratchet, same relay endpoint. No server
changes anywhere, which is the whole reason to do it this way.
What is new is in the plaintext envelope: the document's name, and the
label it was filed under.
🔴 The label travels as a MARKING, not a permission. The content key is in
the same envelope, so whoever can read the message can read the file — the
label does not gate that and the interface must never imply it does. What
it does is tell the recipient's Files what to file it as, and tell the
sender what they are about to hand over. The enforcement is the decision
to send, which is what "keys are the enforcement" means in practice.
Compatibility with the shipped Android build, asserted rather than
assumed: `fn`/`cls`/`cmp` are additive keys an older parser ignores, a
photo envelope still goes out without them, and a `file` envelope ALWAYS
carries a caption naming the document — because builds that predate this
kind show the caption and drop the rest, so a captionless one would arrive
as "[unsupported attachment]" with no clue what was sent.
⚠️ The filename comes from a peer. It is display text and a name inside
the vault, never a path; there is a test saying so, because the vault
addresses blobs by UUID and nobody should later join this onto a
directory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Local-first. The Files screen no longer talks to a server: files are held
on the device, the identity that opens them is generated and protected
with the passphrase in the Keychain, and there is nothing to sign in to.
⚠️ What that changed, stated where it matters: read-down used to be a
Postgres predicate — session_dominates() would not return a row above your
clearance whatever the client asked. There is no database now. Labels
still gate decryption completely, but nothing refuses to hand a file over,
so the interface stopped claiming a ceiling it cannot enforce.
Two ways to hold a file, because they are two different promises:
On this phone a bookmark; nothing copied, nothing encrypted by us,
encrypted at the moment it is sent. The row says so, and
says "Moved or deleted on your phone" when the file goes.
Encrypted a copy under the label key. A stolen container yields
ciphertext and the classification is a control.
"Encrypt a copy" promotes one to the other, and drops the bookmark when it
does — keeping it would leave a vaulted row still able to read through to
the plaintext, which is the entire thing being avoided. The confirm says
out loud that this is the second copy, because it is.
Deleting works singly (swipe) and in bulk (select). A folder whose
contents were not all selected is KEPT and reported: a bulk gesture that
silently took rows nobody reviewed would take ones filed above what this
device can read. Deleting a referenced row never touches the person's own
file.
🔴 Two bugs the UI tests caught that unit tests structurally could not:
- "Encrypt a copy" did nothing at all, silently. SwiftUI clears a
confirmationDialog's isPresented binding BEFORE the button action runs,
so the state the action read back was already nil. Now uses presenting:.
- The clearance banner announced "you can read up to Unclassified" over a
list of Confidential rows — arithmetic wrong, and the concept circular
on a vault whose every label key it derives itself. Replaced with the
count of rows this phone holds no key for, and hidden when that is none.
And one the vault tests caught: a Codable default does not make a key
optional, so `storage: Storage = .vault` made every index written before
referencing fail to decode — a vault that has lost everything.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
testStoriesAndGroupsAppearOnceThereIsABuddy checked that Home carried a
Groups section and a stories strip. The Home/Messages redesign (bc6f9a5)
moved GroupsSection to the Messages tab and replaced the strip with
StoryCardsList, and the test went on failing across every commit since —
which is worse than not having it, because a suite with a permanently red
case is a suite nobody reads, and the two real failures it could have
caught in that window would have looked exactly like the noise.
It now asserts where each thing actually is: the roster circles and the
stories section on Home, Groups on the Messages tab.
⚠️ StoriesStrip has no call sites anywhere. Left alone here rather than
deleted in a test commit, but it is dead.
The other red case in the same run — the voice-note recorder — was the
machine, not the app: "Failed to send signal 19 to process", an unsaveable
result bundle, and a load average near 10 from running suites in parallel.
It passes on its own.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Files browser was a list of names. In a system whose entire point is a
Bell-LaPadula lattice, the classification is the thing you need before you
decide to open, move or send a row — so every row now carries its level
and its areas, in the deployment's own vocabulary rather than a guessed-at
government scale, and the list is headed by the ceiling this session can
actually read to. Colour is a second channel and never the only one.
Two rows in the preview are unreadable for different reasons — one is in
an area this account is not in, one is above its ceiling — because a
preview where everything opens never shows the state that matters.
Face ID / Touch ID, off by default and per device. 🔴 It stores the
passphrase, which is the opposite of what this screen has always said, so
the prompt says that in those words rather than offering "faster unlock".
The item is .biometryCurrentSet and WhenPasscodeSetThisDeviceOnly: no
passcode fallback (a shoulder-surfed four digits should not open files), no
backup, and it is destroyed the moment a face or finger is enrolled. The
offer comes only after a passphrase has been shown to work, and there is
an off switch on the screen itself rather than buried in Settings.
The mini wallet sits in Home's bar. It shows whether a wallet exists on
this phone and nothing else — there is no chain client, and a figure there
would be invented. It routes through the Files unlock, because that is
where the identity key that opens the wallet comes from.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BIP-39 ported to Swift and asserted against the published vectors, so a
phrase written down on one platform opens the same wallet on the other —
the JavaScript's all-zero seed, c55257c3…e7463b04, byte for byte. A
record wrapped by the browser and checked in here opens too, which is the
assertion that catches a divergence in the AAD context or the wrap
version before somebody finds it by restoring to an empty account.
The seed is not derived from the passphrase (§8.1): a passphrase can be
changed, and addresses that stop being derivable are coins that are gone.
The wrapping key comes off the Files identity key instead, which survives
a passphrase change — so the record never needs re-wrapping, and the
recovery code opens the wallet too. Rekeying does change it, hence
Wallet.rewrap.
The screen takes a WalletKeys rather than a FilesSession, so it can be
driven without an account, a passphrase or a network — and it now is, by
three UI tests that create, restore, and refuse a mistyped phrase. Files
also gains the NavigationStack its title and toolbar were already written
against.
WebRTC is wrapped locally. Upstream deleted the 151.0.0 release, moved
its tag, and shipped a 151.0.1 whose manifest points at the deleted URL,
so no version of that package resolves today. The replacement binary was
downloaded and hashed here and matches upstream's declared checksum; the
URL and that checksum now live in Packages/WebRTCBinary.
⚠️ The wallet record is on the device only. There is no server endpoint
for it yet, so a new phone restores from the phrase — which the screen
says out loud rather than implying a sync that is not happening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The screen, on top of the engine that came across in the previous commit.
Lists, uploads and downloads at the session's own label — the vertical slice,
not the whole Files app. Sharing, folders, labels and regrade are still only
in the app being retired.
🔴 It asks for the passphrase, and that is the design rather than a gap.
Messenger deliberately does not keep it — its own comment says so, "the fnid
comes off the passphrase, and the passphrase is not stored" — and a Files
tab that opened silently would mean Messenger had started holding the one
secret it goes out of its way not to.
⚠️ A row whose name will not decrypt is shown as "Unreadable at your
clearance" rather than hidden or blank. The engine already made that choice
— `Entry.name` is optional precisely so a row visible to the database but
not to this session stays visible — and tapping one says why instead of
downloading bytes that would not open either.
Clearance is read from the token rather than asked for, which is the same
reasoning the server side gives: the token is the entire interface, so what a
client needs about its own permissions is already in it.
Messenger's 342 tests unchanged.
First step of retiring the separate Files apps: the crypto and wire layer now
live here, under Packages/, and the app target builds against them.
🔴 Copied, not referenced. Pointing at flamenet-files-ios would leave the
repository that is going away as a build dependency of the one that is not.
The package split is kept exactly as it was — FilesEngine with no
dependencies, FilesClient on top of it — because it was right for the reason
its own manifest gives: the engine is a package rather than app code so
`swift test` can run the crypto from a command line. Requiring Xcode to run
those tests would mean they get run rarely, which is how the bugs that matter
in a key hierarchy survive.
Verified both halves: 19 Files engine tests pass inside this repository, and
Messenger's own 342 are untouched. Nothing is wired to a screen yet — this is
the foundation, committed on its own so the thing that follows is reviewable
as UI rather than as UI plus a package move.
Restores Sources, Tests and UITests to bd7b7c9 — this morning's tree, before
tonight's merges. 342 tests, the original baseline, green.
Tonight's twenty-eight merges left sign-in, messages, calls and stories
broken. 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 all run through E2EAttachment and E2EInbox. Every one of those MRs
was written against an older main and had never existed alongside the
others until they were merged in one evening.
The resulting conflicts were resolved mechanically, and that produces code
which compiles and passes tests while being wrong — lifetimeSeconds landed
on E2EPin instead of E2EMessage, mentions on an extension instead of the
group message, arguments in an order the memberwise init would not take.
Those three were caught by the compiler. Nothing caught the ones it cannot
see, and a wrong term in a kind check is exactly that: it compiles, it
passes, and a call stops connecting.
The unit suite went from 342 to 622 green while the product broke, because
it tests units. "encodeText produces this JSON" is a unit test. "Android
displays what iOS sent" is not, and nothing ran it.
Nothing is lost — every MR is a merged commit with its branch still on the
remote. They re-land in small batches once a test exists that signs in,
sends a message between two devices, connects a call and posts a story.
Picking a photo closed the picker and nothing came back: the composer never
appeared, and the strip just sat there.
The text composer (§6.21) hung its own `.sheet` off `addButton` — the view
that HOLDS the PhotosPicker, and a descendant of the view presenting the
photo composer. Two sheets on one presentation branch is a race, and
dismissing the picker lost the parent's sheet on the way through. My merge
put it there; it belongs beside the others at the top level, and now is.
Also: a picture that failed to decode did a bare `return`. No composer, no
message, nothing — indistinguishable from a dead tap, on both the strip and
the Stories tab button. Both now say so.
622 tests, 0 failures.
The lighter half of §6.20's reply: a sealed direct message to the author
carrying an emoji and the story it answers (spec §6.22).
🔴 Decoded before §6.9's reaction path, which requires `re`. 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 bubble. Tested both ways round,
including that an ordinary thread reaction is untouched.
⚠️ Its own disclosure, and its own settings key. A reaction reveals that
you watched exactly as a reply does; what differs is the deliberation
behind it. 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 about the careless one.
⚠️ The row sits above the reply field and well inside the frame. The tap
zones that advance and dismiss a story cover the rest of the screen, and
an acknowledgement delivered by a mis-tap is a disclosure the person did
not make.
Branched from feat/story-replies: this reuses §6.20's `sre`, so it is
stacked rather than duplicating that plumbing. Targeted at that branch so
it re-targets to main when it merges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stories have no comment thread and no viewer list, and this deliberately
does not give them one. A reply is an ordinary direct message to the
author carrying `sre.sid` — the story's id — and it lands in the existing
conversation like anything else (spec §6.20).
🔴 The reference carries the id and nothing else: no caption, no
thumbnail, no blob. This is where §6.20 diverges from §6.9's `re`, and
the reason is worth keeping in view. A quoted message may be one the
recipient never had, so §6.9 sends a preview. A story's author had the
story by definition — 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, which is the trap §6.19 documents for
pins arriving through a different door. A test asserts the encoded JSON
has no key but `sid`.
The disclosure is the other half. Replying tells the author you watched,
which stories otherwise never reveal, and there is no way to answer
somebody without their knowing — so it is allowed, but it has to be a
decision. The alert is shown before the FIRST reply the account sends,
names what is disclosed, and says where the reply goes. Nothing
automatic is ever sent when a story is merely opened.
`storyReplyID` is optional on E2EMessage for the usual reason: Swift's
synthesised decoding throws on a missing key rather than applying a
default, so a non-optional field would make every existing snapshot fail
to decode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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: it is
off unless switched on, it is labelled as what it is rather than as a
feature, and it only ever holds stories this account posted. An archive
of other people's expiring stories is the exact opposite of what they
were promised, and "it is private to you" does not repair that.
`E2EInbox.archive` refuses anything not the author's own. The caller
filters as well; this is the guard that survives the caller being
rewritten, and it is tested from both directions.
Archiving happens on the restore path, which is the only moment it can:
a story past its expiry is never loaded into `stories`, so nothing
downstream ever sees it. That path runs on every launch, so the fold is
idempotent — otherwise a story that expired weeks ago would be appended
again each time.
⚠️ The debounced history writer carries the archive though it does not
trigger on it. Omitting it there would write nil on the next ordinary
message and empty somebody's archive silently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clients decided "is this device live" from the directory, which the relay
controls freely. The key log records every departure permanently and
cannot be quietly rewritten — but nothing client-side was reading it for
that. So a relay could keep a revoked device reachable in the directory
while showing the revocation to anyone auditing the log, and both answers
would look consistent to their respective audiences.
The send path now filters the directory through what the log has said.
🔴 A revocation is a one-way latch, and that is the answer to the attack
the ticket names: a relay that stops serving the log it used to serve is
a downgrade that looks like an outage. Latching makes that not matter for
anything already seen. The relay can go quiet, but it cannot un-say what
it already said — silence stops new revocations being learned, it cannot
resurrect an old one.
⚠️ The set only ever grows, in both the fold and the store. A device
absent from a later answer has not been un-revoked; it has not been
mentioned, which a relay can arrange for any device at any time. Removing
on absence would hand back exactly the power this removes.
⚠️ Persisted rather than kept in memory. A latch that reset on launch
would give a quiet relay every revoked device back on the next start,
which is the same downgrade by a slower route.
⚠️ `expire` latches as well as `revoke`, matching the relay's own
`isRetired`. A device that aged out is as gone as one removed by hand,
and enforcing only explicit revocations would leave the commoner case
open.
⚠️ Filtering preserves the directory's order, which is the send order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accepting a changed safety number was one tap that left nothing behind,
so a later change could not be weighed against a history.
🔴 The record survives the key change that invalidates it, and that is
the whole feature. `recordTrust` cleared the verified flag on a change,
which destroyed the history at the exact moment it becomes most useful:
"you compared this in person in March, and their keys changed today" is a
far stronger thing to show somebody than "not verified" — which is also
what a stranger looks like. The flag still clears; the record is marked
broken instead of deleted, and the profile shows it in both states.
⚠️ The first break wins. A second change later must not overwrite the
date of the one that actually invalidated the check, which is the date
worth weighing against.
⚠️ Un-ticking the toggle removes the record rather than marking it
broken. That is the user saying the comparison did not happen or did not
match — a retraction, not a history.
The method is asked for rather than assumed. The ways of comparing are
not equally strong — a number read over a call that was itself
intercepted is worth less than one read off a screen in a room — and a
record that guessed would be a record of the wrong thing.
⚠️ Every sentence says "on this device". Verification is somebody
comparing two numbers; there is nowhere else it could live, and a record
shown without that caveat invites the reasonable assumption that a new
phone will remember. It lives in the suite, which `wipe()` clears
wholesale, so it leaves with the account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A story required a photo or a video. A sentence is often the whole
thought, so `att` becomes optional and the caption becomes the story
(spec §6.21). A link story is not a new kind: it is a text story
carrying a §6.12 preview, which the SENDER fetches — drawing the card
receiver-side would tell the site the address of every viewer, and for a
story that is the whole audience rather than one correspondent.
🔴 The decode for this sits BEFORE the attachment guard, and that
placement is the feature. The guard requires `att` for a story; a text
story failing it falls through to the ordinary-message path and appears
as a chat bubble in the conversation that never expires — both of the
things §6.6 says a story must not be. A test asserts a text story is
never an ordinary message.
`E2EStory.ref` is now optional rather than carrying a sentinel, so
nothing can read a blob id off a story that has no blob. That forced a
`key` for seen-marks and dedup: with no blob id, a text story is named by
what cannot repeat for one author. `loadImage` on the card became
optional for the same reason — "nothing to load" is not a failure, and a
broken-image state on a story that never had one looks like a bug to the
person who posted it.
⚠️ A text story has no blob, so nothing dies at the server's retention
purge. Client-enforced expiry is the whole mechanism rather than a
backstop to one, and the composer's footnote says so instead of implying
more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The options were see everything or block the person. Muting one
contact's stories while the conversation carries on is the ordinary
middle, and it needs no protocol: nothing is sent, and the author is
never told.
Their stories still arrive and are still stored. Discarding them on
receipt would be cheaper, but then unmuting would show an empty strip
until their next post — so muting would quietly mean something other
than what the button says.
The list lives in the sealed snapshot rather than UserDefaults. Who
somebody has muted is a fact about their social graph, and a plist in
the app container is readable by anything that reaches the file, which
is the threat the sealed store exists for. It also means the choice
survives a restore, like the audience list beside it.
Two traps. The field is optional, because Swift's synthesised decoding
throws on a missing key rather than applying a default — a non-optional
addition would make every existing snapshot fail to decode and take the
user's history with it. And the debounced history writer had to carry
the list even though it does not trigger on it: omitting it there would
have written nil on the next ordinary message and silently unmuted
everybody.
Your own stories are never hidden, even if your id reaches the muted set
from a restored backup. Hiding somebody's own posts from them reads as
the posts having failed to send.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stories have no comment thread and no viewer list, and this deliberately
does not give them one. A reply is an ordinary direct message to the
author carrying `sre.sid` — the story's id — and it lands in the existing
conversation like anything else (spec §6.20).
🔴 The reference carries the id and nothing else: no caption, no
thumbnail, no blob. This is where §6.20 diverges from §6.9's `re`, and
the reason is worth keeping in view. A quoted message may be one the
recipient never had, so §6.9 sends a preview. A story's author had the
story by definition — 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, which is the trap §6.19 documents for
pins arriving through a different door. A test asserts the encoded JSON
has no key but `sid`.
The disclosure is the other half. Replying tells the author you watched,
which stories otherwise never reveal, and there is no way to answer
somebody without their knowing — so it is allowed, but it has to be a
decision. The alert is shown before the FIRST reply the account sends,
names what is disclosed, and says where the reply goes. Nothing
automatic is ever sent when a story is merely opened.
`storyReplyID` is optional on E2EMessage for the usual reason: Swift's
synthesised decoding throws on a missing key rather than applying a
default, so a non-optional field would make every existing snapshot fail
to decode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blocking existed in a conversation and nowhere near a story, which is the
surface a stranger is most likely to reach somebody through. Reporting
did not exist at all — not here, not in the relay.
🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
That is a defensible bargain and an invisible one, so the sheet states it
in those words before anything is sent: what leaves the device, who can
read it, and that it cannot be undone. Declining to include a copy is
offered, and says plainly that there may then be nothing to act on.
⚠️ `reportStory` sends only what it was passed. It does not reach into
the thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include, and a helpful "we also attached the
conversation" would be exactly the wrong kind of help.
⚠️ Blocking and reporting are offered together and never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else and nothing may come of it. The copy says which is
which, because a combined control would make one of the two invisible.
The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each item is an ordinary story envelope carrying a `set` that names the
sequence, its position and its size (spec §6.23).
🔴 One envelope per item rather than one carrying an array. The array is
the obvious encoding and degrades badly: a client predating §6.23
requires a single `att`, fails that guard, and renders the whole set as a
message in the conversation. Separate envelopes degrade to exactly what
they are — several stories from one person in order, which every existing
build already draws correctly.
⚠️ The expiry is computed once, before the loop, and shared by every
item. Computing it per item would stagger the set by however long the
uploads took. `setExpiry` then takes the EARLIEST of what actually
arrived: items are required to carry the same stamp, 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. That decay is both
confusing to look at and a disclosure — the gaps reveal the order the
author added things.
⚠️ `grouped` renders what arrived. `n` is the sender's claim and any item
can fail to arrive, so a set is never held back or marked incomplete: a
missing item is indistinguishable from one never sent. Stories with no
set are keyed so two of them cannot collide into one group.
Only the first item carries the caption — a sequence has one thing to
say, and repeating it under every picture reads as a stutter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 The rule that makes mentions safe to have is what they may do to a
mute. Always overriding hands every member of a group a way to interrupt
somebody whenever they like — how mentions become the thing people leave
a group over. Never overriding makes muting a busy group mean missing
the one message that was for you.
So the sender decides a message is addressed to somebody, and the person
receiving it decides whether being addressed is worth a notification.
Nothing in an envelope changes that half. Defaults to breaking the mute,
with a way off in the group's menu.
🔴 Which turned up that a group could not be muted AT ALL. Muting was
keyed on the AUTHOR of an arriving message, so silencing a group meant
muting every member one at a time, and anyone who joined later went
straight back to buzzing. A federated thread was worse: every remote
correspondent shares fromBuddyID 0, so muting one silenced all of them.
Muting is per conversation now. The 1:1 key is deliberately unchanged —
it is what every existing mute on every phone is stored under, and a new
scheme would quietly unmute everything somebody had silenced.
Mentions resolve against the roster, never the text alone: typing
"@anyone" means nothing unless somebody by that name is in the group.
You cannot name yourself. The list is bounded and deduplicated on
receipt, because a sender can name ten thousand ids and the cost lands
on the recipient.
shouldNotify moved onto the type as a testable function — it was four
clauses inline in a method needing UIApplication, which is how a
notification rule that now decides mute overrides ends up untested.
Closes#49
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §6.17 merge landed `lifetimeSeconds` inside `E2EPin` instead of
`E2EMessage` — a conflict resolved at the wrong struct boundary, which
reads fine and parses fine and is simply wrong. main did not compile:
'value of type E2EMessage has no member lifetimeSeconds'.
Worth naming the gap: a syntax check cannot catch this. The file parsed
perfectly with the property on the wrong type. Only building it does, which
is why the build stays the gate for every merge rather than every few.
E2EPin is back to the three fields it is documented as having — an id, a
stamp and who pinned it, deliberately never a copy of the message.
523 tests, 0 failures.
🔴 The shorter timer always wins. A per-message timer may only take life
away, never add it — otherwise a sender defeats the recipient's
conversation timer by putting a long one on every message, turning a
setting the recipient chose on their own device into something the other
party overrules.
A duration, never a deadline. An absolute expiry would be the sender's
clock: they could backdate a message so it vanished on arrival, or
forward-date one so it never expired. Counted from local receipt, the
rule §6.11 already follows.
🔴 pruneDisappeared skipped any thread whose conversation timer was off,
so a timed message in an untimed conversation would have been kept for
ever. It prunes per message now.
The one-off timer disarms on send. A timer that stayed set would quietly
apply to everything typed after it, and the person would find out when a
message they meant to keep was gone.
⚠️ The backlog filed this with the trade "the relay learns a message is
timed". It does not — the content envelope is inside the ratchet, the
same place §6.11's announcement rides, and the relay stores only an
opaque payload. Recorded in the spec.
Closes#29
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 A pin is an id and a stamp, never a copy of the message. Storing the
text would keep a pinned message readable after its own timer had
removed it — pinning would become a way to defeat disappearing messages,
silently, from either side of a conversation. E2EPin has three fields and
none of them is text, so it cannot be added back by forgetting a line.
The pin resolves through the thread every time it is drawn, and shows
nothing when the message is gone.
Monotonic, like an edit, and so is an unpin: a relay orders envelopes by
receipt rather than authorship, so two people pinning at once can arrive
in either order. Unlike a message, a pin is shared state both people are
looking at, so an inconsistency here does not resolve itself — the two
phones would disagree for ever.
One pin, not a list. A list needs an ordering, a cap, and a rule for two
people adding the last one at once — three more pieces of shared state
across devices that cannot talk to each other directly.
⚠️ One-to-one only for now. A group pin is carried correctly on the wire
but cannot resolve until a group thread records a mid (#58, in review);
shipping one that could never resolve would be a permanently empty
banner.
Closes#50
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stripping is the feature, not a side effect. Copying by hand loses
the original sender and the thread it came from, which is what people
already expect a forward to do — so it is the default rather than a
setting to find. There is no "include sender" checkbox and the spec
deliberately has no field for one: a field would become a default, and a
forward that carries its author by default is not a forward.
🔴 A fresh mid, never the original's. A mid is unique within a
conversation, so the same one in two conversations is a handle the RELAY
can use to tie them together — the exact link a forward breaks.
Forwardable is a small type naming what travels, so a field added to
E2EMessage later is excluded by default rather than by somebody
remembering.
🔴 A photo is re-uploaded, never re-referenced. Sending the original blob
id and key would work and be far cheaper, and would let the relay watch
one blob fetched from two conversations. Costs a download and an upload,
and means forwarding a photo can fail where text cannot — reported
rather than papered over with a caption and no picture.
Shows "Forwarded" and never from whom. Second-hand words presented as
the sender's own is a small dishonesty; naming the author would be the
provenance this strips. Those are different and only the first is shown.
⚠️ isForwarded was written as a plain Bool first, which would have made
every existing snapshot fail to decode and wiped everyone's history —
synthesised decoding throws on a missing key rather than using the
default. Optional now, with the test that catches it.
Closes#28
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The quote is tappable only when the message it names is actually on this
device, and inert otherwise — including to VoiceOver, which must not
offer "Activate" on something that will do nothing. A reply carries its
own preview precisely because the original may never have arrived here
(§6.9), so "nowhere to go" is the ordinary case, not a failure.
🔴 The target is matched on mid and never on the preview. §6.9 says the
preview is the replier's assertion and that they can lie about it, so
matching on text would let a crafted reply send the reader to a message
of the sender's choosing — invisibly, since the quote would look right.
Spec clarified to say so: flamenet-e2e!8.
The tap clears any active search first. The list is visibleMessages,
which search filters, and scrollTo an id that is not in the list does
nothing — a tap that appeared to work and did not, which is the exact
failure the tappable-only-when-resolvable rule exists to avoid.
Resolution is a free function on Message rather than a method on the
view model, for the same reason applyReaction is one: a ChatViewModel
needs a live inbox, which needs a network client and a Keychain.
⚠️ iOS only. Android's transcript stores no reply reference at all, so a
reply's quote is not rendered there and there is nothing to tap — see
paulhitt/flamenet-messenger-android#10, updated with that finding.
Closes#48
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.
🔴 **The remedy cannot live inside the app.** An unverified account
cannot get a relay token — that is what unverified means — so it never
reaches the app at all. A verification screen behind the door is one this
person can never open. `VerifyEmailSheet` is presented from the SIGN-IN
screen, and the sign-in error offers a button straight to it.
That needed the refusal's code kept, not just its sentence. `lastErrorCode`
now sits beside `lastError`: matching on the words would break the first
time the copy was edited, and that breakage locks somebody out silently
rather than loudly.
⚠️ **The copy must not read as a credential problem.** Somebody told
their sign-in failed will change a passphrase that was never wrong — and
here the passphrase is the identity, so that is not a harmless retry. A
test asserts "password", "passphrase", "incorrect" and "wrong" never
appear in it.
⚠️ The code field turns off autocapitalisation and autocorrect and trims
whitespace. A code pasted out of a mail client arrives with a newline
more often than not, and refusing it for that looks exactly like a wrong
code.
Four tests, 346 pass.
Nine merges (ios!46, !49, !51, !52, !55, !56, !58, !63, !65) were resolved by
a script that got the conflict boundaries wrong, and main was left with
conflict markers committed in nine source files. It did not build.
My mistake, and the cause is worth writing down: the merges were resolved in
a batch and verified once at the end, so the first bad resolution was not
caught before eight more were stacked on top of it. The check that was
supposed to stop it looked for markers only in the files git had listed as
conflicted, and missed markers that a partial resolution left behind.
This restores Sources, Tests and UITests to bd8ba69 — the last commit that
was actually verified green, 471 tests. No history is rewritten and nothing
is force-pushed; the nine features are not lost, their original commits are
safe (5bdc4185, 288dd2b7, 5548ff03, 507fb2f4, 6c014ceb, 742f343c, 22f85941,
daa1baff, 8080b8bb) and each will be redone one at a time.
Verified: 471 tests, 0 failures, TEST SUCCEEDED.
Blocking existed in a conversation and nowhere near a story, which is the
surface a stranger is most likely to reach somebody through. Reporting
did not exist at all — not here, not in the relay.
🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
That is a defensible bargain and an invisible one, so the sheet states it
in those words before anything is sent: what leaves the device, who can
read it, and that it cannot be undone. Declining to include a copy is
offered, and says plainly that there may then be nothing to act on.
⚠️ `reportStory` sends only what it was passed. It does not reach into
the thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include, and a helpful "we also attached the
conversation" would be exactly the wrong kind of help.
⚠️ Blocking and reporting are offered together and never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else and nothing may come of it. The copy says which is
which, because a combined control would make one of the two invisible.
The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each item is an ordinary story envelope carrying a `set` that names the
sequence, its position and its size (spec §6.23).
🔴 One envelope per item rather than one carrying an array. The array is
the obvious encoding and degrades badly: a client predating §6.23
requires a single `att`, fails that guard, and renders the whole set as a
message in the conversation. Separate envelopes degrade to exactly what
they are — several stories from one person in order, which every existing
build already draws correctly.
⚠️ The expiry is computed once, before the loop, and shared by every
item. Computing it per item would stagger the set by however long the
uploads took. `setExpiry` then takes the EARLIEST of what actually
arrived: items are required to carry the same stamp, 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. That decay is both
confusing to look at and a disclosure — the gaps reveal the order the
author added things.
⚠️ `grouped` renders what arrived. `n` is the sender's claim and any item
can fail to arrive, so a set is never held back or marked incomplete: a
missing item is indistinguishable from one never sent. Stories with no
set are keyed so two of them cannot collide into one group.
Only the first item carries the caption — a sequence has one thing to
say, and repeating it under every picture reads as a stutter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stories have no comment thread and no viewer list, and this deliberately
does not give them one. A reply is an ordinary direct message to the
author carrying `sre.sid` — the story's id — and it lands in the existing
conversation like anything else (spec §6.20).
🔴 The reference carries the id and nothing else: no caption, no
thumbnail, no blob. This is where §6.20 diverges from §6.9's `re`, and
the reason is worth keeping in view. A quoted message may be one the
recipient never had, so §6.9 sends a preview. A story's author had the
story by definition — 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, which is the trap §6.19 documents for
pins arriving through a different door. A test asserts the encoded JSON
has no key but `sid`.
The disclosure is the other half. Replying tells the author you watched,
which stories otherwise never reveal, and there is no way to answer
somebody without their knowing — so it is allowed, but it has to be a
decision. The alert is shown before the FIRST reply the account sends,
names what is disclosed, and says where the reply goes. Nothing
automatic is ever sent when a story is merely opened.
`storyReplyID` is optional on E2EMessage for the usual reason: Swift's
synthesised decoding throws on a missing key rather than applying a
default, so a non-optional field would make every existing snapshot fail
to decode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 A pin is an id and a stamp, never a copy of the message. Storing the
text would keep a pinned message readable after its own timer had
removed it — pinning would become a way to defeat disappearing messages,
silently, from either side of a conversation. E2EPin has three fields and
none of them is text, so it cannot be added back by forgetting a line.
The pin resolves through the thread every time it is drawn, and shows
nothing when the message is gone.
Monotonic, like an edit, and so is an unpin: a relay orders envelopes by
receipt rather than authorship, so two people pinning at once can arrive
in either order. Unlike a message, a pin is shared state both people are
looking at, so an inconsistency here does not resolve itself — the two
phones would disagree for ever.
One pin, not a list. A list needs an ordering, a cap, and a rule for two
people adding the last one at once — three more pieces of shared state
across devices that cannot talk to each other directly.
⚠️ One-to-one only for now. A group pin is carried correctly on the wire
but cannot resolve until a group thread records a mid (#58, in review);
shipping one that could never resolve would be a permanently empty
banner.
Closes#50
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 The rule that makes mentions safe to have is what they may do to a
mute. Always overriding hands every member of a group a way to interrupt
somebody whenever they like — how mentions become the thing people leave
a group over. Never overriding makes muting a busy group mean missing
the one message that was for you.
So the sender decides a message is addressed to somebody, and the person
receiving it decides whether being addressed is worth a notification.
Nothing in an envelope changes that half. Defaults to breaking the mute,
with a way off in the group's menu.
🔴 Which turned up that a group could not be muted AT ALL. Muting was
keyed on the AUTHOR of an arriving message, so silencing a group meant
muting every member one at a time, and anyone who joined later went
straight back to buzzing. A federated thread was worse: every remote
correspondent shares fromBuddyID 0, so muting one silenced all of them.
Muting is per conversation now. The 1:1 key is deliberately unchanged —
it is what every existing mute on every phone is stored under, and a new
scheme would quietly unmute everything somebody had silenced.
Mentions resolve against the roster, never the text alone: typing
"@anyone" means nothing unless somebody by that name is in the group.
You cannot name yourself. The list is bounded and deduplicated on
receipt, because a sender can name ten thousand ids and the cost lands
on the recipient.
shouldNotify moved onto the type as a testable function — it was four
clauses inline in a method needing UIApplication, which is how a
notification rule that now decides mute overrides ends up untested.
Closes#49
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 The shorter timer always wins. A per-message timer may only take life
away, never add it — otherwise a sender defeats the recipient's
conversation timer by putting a long one on every message, turning a
setting the recipient chose on their own device into something the other
party overrules.
A duration, never a deadline. An absolute expiry would be the sender's
clock: they could backdate a message so it vanished on arrival, or
forward-date one so it never expired. Counted from local receipt, the
rule §6.11 already follows.
🔴 pruneDisappeared skipped any thread whose conversation timer was off,
so a timed message in an untimed conversation would have been kept for
ever. It prunes per message now.
The one-off timer disarms on send. A timer that stayed set would quietly
apply to everything typed after it, and the person would find out when a
message they meant to keep was gone.
⚠️ The backlog filed this with the trade "the relay learns a message is
timed". It does not — the content envelope is inside the ratchet, the
same place §6.11's announcement rides, and the relay stores only an
opaque payload. Recorded in the spec.
Closes#29
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stripping is the feature, not a side effect. Copying by hand loses
the original sender and the thread it came from, which is what people
already expect a forward to do — so it is the default rather than a
setting to find. There is no "include sender" checkbox and the spec
deliberately has no field for one: a field would become a default, and a
forward that carries its author by default is not a forward.
🔴 A fresh mid, never the original's. A mid is unique within a
conversation, so the same one in two conversations is a handle the RELAY
can use to tie them together — the exact link a forward breaks.
Forwardable is a small type naming what travels, so a field added to
E2EMessage later is excluded by default rather than by somebody
remembering.
🔴 A photo is re-uploaded, never re-referenced. Sending the original blob
id and key would work and be far cheaper, and would let the relay watch
one blob fetched from two conversations. Costs a download and an upload,
and means forwarding a photo can fail where text cannot — reported
rather than papered over with a caption and no picture.
Shows "Forwarded" and never from whom. Second-hand words presented as
the sender's own is a small dishonesty; naming the author would be the
provenance this strips. Those are different and only the first is shown.
⚠️ isForwarded was written as a plain Bool first, which would have made
every existing snapshot fail to decode and wiped everyone's history —
synthesised decoding throws on a missing key rather than using the
default. Optional now, with the test that catches it.
Closes#28
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The quote is tappable only when the message it names is actually on this
device, and inert otherwise — including to VoiceOver, which must not
offer "Activate" on something that will do nothing. A reply carries its
own preview precisely because the original may never have arrived here
(§6.9), so "nowhere to go" is the ordinary case, not a failure.
🔴 The target is matched on mid and never on the preview. §6.9 says the
preview is the replier's assertion and that they can lie about it, so
matching on text would let a crafted reply send the reader to a message
of the sender's choosing — invisibly, since the quote would look right.
Spec clarified to say so: flamenet-e2e!8.
The tap clears any active search first. The list is visibleMessages,
which search filters, and scrollTo an id that is not in the list does
nothing — a tap that appeared to work and did not, which is the exact
failure the tappable-only-when-resolvable rule exists to avoid.
Resolution is a free function on Message rather than a method on the
view model, for the same reason applyReaction is one: a ChatViewModel
needs a live inbox, which needs a network client and a Keychain.
⚠️ iOS only. Android's transcript stores no reply reference at all, so a
reply's quote is not rendered there and there is nothing to tap — see
paulhitt/flamenet-messenger-android#10, updated with that finding.
Closes#48
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.
🔴 **The remedy cannot live inside the app.** An unverified account
cannot get a relay token — that is what unverified means — so it never
reaches the app at all. A verification screen behind the door is one this
person can never open. `VerifyEmailSheet` is presented from the SIGN-IN
screen, and the sign-in error offers a button straight to it.
That needed the refusal's code kept, not just its sentence. `lastErrorCode`
now sits beside `lastError`: matching on the words would break the first
time the copy was edited, and that breakage locks somebody out silently
rather than loudly.
⚠️ **The copy must not read as a credential problem.** Somebody told
their sign-in failed will change a passphrase that was never wrong — and
here the passphrase is the identity, so that is not a harmless retry. A
test asserts "password", "passphrase", "incorrect" and "wrong" never
appear in it.
⚠️ The code field turns off autocapitalisation and autocorrect and trims
whitespace. A code pasted out of a mail client arrives with a newline
more often than not, and refusing it for that looks exactly like a wrong
code.
Four tests, 346 pass.
The documented workflow ends in `rm /tmp/fn-live-signup`, and a run that
crashes never gets there. macOS does not clear /tmp between reboots, so the
file sits for days and every ordinary test run after it quietly creates real
accounts on a real relay.
That is not hypothetical either: a sentinel dated three days earlier was
found on this machine still arming the suite, and today's run created
accounts against relay2.flamenet.io before anybody noticed.
The sentinel now has to have been touched within the hour — long enough for
any deliberate session, short enough that a forgotten file is harmless by
the next morning. The `rm` stays the right habit; it is just no longer the
only thing standing between a routine run and a live registration.
The file-not-env-var reasoning above it is unchanged and still correct.
`xcodebuild test -scheme FlamenetMessenger` was sending failed sign-in
attempts for a live account to relay.flamenet.io, then hanging. Six UITest
suites had no skip guard, and two read the demo passphrase as
env["FN_DEMO_PASSPHRASE"] ?? "set-FN_DEMO_PASSPHRASE"
With the variable unset that placeholder was used as a real password against
the app's default server, which is production. FreshPairUITests then slept
30 x 10s waiting for a message that could not arrive, so a run blocked for
minutes per test with xcodebuild at 0% CPU. Observed: ~25 minutes parked on
"That handle or passphrase isn't right." for flamenetdemo2.
The relay locks an account after five failed sign-ins per IP and username in
fifteen minutes, and the accounts being hammered are the demo accounts that
exist for store review. A routine test run must not be able to lock those.
LiveDemoGate.passphrase() skips unless FN_DEMO_PASSPHRASE is set, so no live
sign-in ever runs with a placeholder — applied to FreshPair, DemoRound,
DemoAccountSetup and RelayTypes. Both placeholder fallbacks are gone.
LiveDemoGate.drivenSession() covers ReadyForHandover and DriveCall, which do
not sign in but assume a session paired by hand. They skipped as failures
before, which trains people to ignore red.
Verified: the suite now runs to completion, 50 of 52 skipped, no live
sign-in attempted.
Closes ios#62.
A timer promises the message goes away; a screenshot breaks that promise
silently and the sender never learns. It cannot be prevented, so it is
reported (spec §6.27).
🔴 Most of the care here is in refusing to let the absence of a notice
mean anything. The device that took the screenshot is the device that
reports it, so a modified build, a second phone photographing the screen,
or the platform's own recorder all produce nothing at all. A notice means
one was probably taken; no notice means NOTHING. So the notices are not
counted anywhere — a running total would be read as a measurement, and
its zero would be read as "nobody screenshotted", which is precisely the
inference the section forbids. The disclaimer names the ways detection
fails rather than merely admitting that it can: "sometimes this doesn't
work" tells nobody what to worry about.
⚠️ Only where a timer is set. Everywhere else this would be a general
surveillance feature rather than the narrow one it is — the promise a
screenshot breaks is the one §6.11 made, and where no promise was made
there is nothing to report against. The timer check comes before the
rate-limit check, so a suppression window cannot make an untimed
conversation start reporting.
⚠️ Bound to the thread being on screen, not to the app. A screenshot
taken on the settings screen is not a screenshot of their message.
⚠️ One notice per conversation per five seconds: one screenshot often
produces several platform events, and a person who took one should not
appear to have taken four.
⚠️ The last-notice times are session-only. Persisting them would be
storing who screenshotted whom — a record of exactly the thing this
reports — on disk, to enforce a window that is seconds long.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 A device that had registered once erased its local history and wiped
its Keychain whenever the relay answered a poll with 410. Nothing about
that was authenticated. The threat model says the relay is untrusted with
content — and it is, it still cannot read anything — but it could
DESTROY everything, for every user, irreversibly, by returning one status
code. That was never the intent (ios#60).
The unsigned path now signs out and stops polling, and leaves stored
messages exactly where they are. The device really is removed
server-side, so stopping is right; erasing is not something an unsigned
mechanism gets to ask for.
Erasing now requires a wipe instruction signed by the account key
(§6.26). The relay cannot produce one: the fnid this device saved at
sign-in IS the account public key, so an invented instruction fails the
signature. `deviceRevoked` still means "erase" and now has a signature
behind it; `deviceDeregistered` is the relay's unsigned word and takes
nothing with it.
⚠️ The device id is checked as well as the signature. A genuine
instruction for another device carries a genuine signature — verifying
that alone would let one device's wipe erase every device that saw it.
⚠️ The signature covers the device id and the timestamp, so a captured
instruction cannot be re-pointed or re-dated. The signed bytes are pinned
in a test: three implementations have to agree byte for byte, and a
format that drifts is a wipe that silently stops working.
⚠️ The account key is parsed from the address saved at sign-in, never
from anything the relay says now. A relay able to substitute the key it
is checked against could forge whatever it liked.
⚠️ Nothing here claims the data is gone. A device that is off or offline
never sees the instruction.
This is the honouring half of ios#38. Issuing one still needs a relay
route to carry it and a passphrase re-entry to sign it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ticket says adding a device is silent. It is not — it tripped
`.changed`, which is the loudest state there is and blocks sending. The
real defect was that the two were indistinguishable, and the doc comment
on `.changed` admitted it: "either they reinstalled / added a device, or
someone is trying to read the thread."
That was wrong in both directions at once. Adding a device — the more
realistic attack, because it reads as somebody buying a phone — was
invisible as a distinct event, and it also blocked sending on every
person who legitimately owns a tablet. Loud in the wrong way and silent
in the right one.
🔴 The split is on which key set moved. An attacker who substitutes a key
cannot produce the old one, so the old one disappears: a missing pinned
key is the substitution shape and still stops the send. Every pinned key
still present with more alongside is an addition — nothing breaks, which
is exactly why it needs saying out loud rather than blocking.
⚠️ Missing beats added when both are true. A set that dropped a key and
gained one is a substitution wearing an addition's clothes, and the safer
reading is the one that stops the send.
⚠️ An addition is pinned so the notice fires once. Left unpinned, every
bundle fetch would re-report the same new device for ever, and a warning
that never stops is one people learn to scroll past.
🔴 Verification does not survive an addition. What was compared in person
was compared against the keys they had then; a device added since was
never part of that, and carrying the tick over would let it inherit a
check nobody performed on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sealing hides which account sent a message from the relay. When the
peer's delivery key or capability is missing, the send still goes —
end-to-end encrypted as always — but unsealed, and the relay learns the
sender. That degradation was silent: `lastSendWasSealed` held only the
most recent outcome and nothing accumulated, so nobody could answer "how
often does this happen to me".
Now every send's outcome is counted, and the privacy screen says what
share went unsealed. Zero sends reports as "nothing sent yet" rather than
a reassuring 0%, which is what a plain ratio would have rendered on a
fresh install.
🔴 The copy distinguishes sender from content, and there are tests
asserting it does. Somebody who reads "sealed" as "encrypted" makes the
wrong decision in both directions — relaxed when it is off, and alarmed
when it degrades on a message the server could never read anyway.
⚠️ Counted in 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, kept instead on
the device.
Refusing rather than degrading is offered and is OFF by default. That is
the honest default rather than the lazy one: refusing turns a metadata
leak into an undelivered message, which for most people is the worse
outcome. The refusal has its own error case so it can explain itself — a
bare "couldn't send" reads as the app being broken, and the next thing
somebody does is turn the setting off without knowing that is what they
are doing.
⚠️ The gate is checked before the capability is spent. Refusing after
taking a token would burn one per attempt and drain the contact's batch
while nothing was delivered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An ordinary story envelope carrying `gid`, fanned out pairwise to the
members exactly as a group message is, and rendered in the story surface
(spec §6.24).
🔴 The group's roster is the audience and `storyAudience` does not apply.
Those are two different lists making two different statements — who may
see this account's stories, and who is in a conversation — and they will
disagree. The roster wins, because a story addressed to a group that
silently dropped some members would be a group message part of the group
cannot see, which is worse than the failure being avoided.
That makes the disclosure load-bearing rather than polite, so
`groupStoryReach` returns the overlap rather than a warning: which
members are excluded by this account's story audience, and which are not
contacts at all. The two are counted separately — a non-contact is not
"excluded from your stories", they were never on that list.
🔴 The inbound branch catches a story BEFORE the group-message path.
Falling through would render it as a message in the group's conversation:
a story that is a message, and one that never expires out of the thread.
⚠️ `exp` is the ordinary story lifetime. Group membership does not extend
it, and it is not kept merely because it is also addressed to a
conversation that keeps history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A story disappears from the author's own device too, so there was no way
to keep something you made. Saving your own before expiry is ordinary
everywhere else.
🔴 Only ever your own, and the rule is `E2EInbox.isSaveable` rather than
a condition on the button. 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 view is one refactor away
from being lost. It is tested from both directions.
⚠️ The confirmation says the copy has left the disappearing model, and
where it went. "Saved" alone would be true and useless: the point is that
this one copy now outlives the story, and somebody who is not told that
may reasonably assume the expiry still covers it.
⚠️ `.addOnly` authorisation rather than full access. Saving needs
permission to add a photo and nothing else; asking for read access as
well would let this app enumerate somebody's whole library in order to
perform a write. The narrower prompt is also the honest one.
⚠️ `NSPhotoLibraryAddUsageDescription` had to be added. Without it iOS
does not warn — it terminates the app the first time somebody saves.
The helper already takes a `hasBlob` argument the text stories of §6.21
will need; on this branch every story has one, so only the call site
moves when that branch lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 The wire always carried one and the model never stored it.
encodeGroupText has taken a mid: since §6.9 shipped and the parser reads
it back — E2EGroupMessage was where it stopped. So nothing in a group
could name an earlier message: no reply to jump to, no reaction to fold,
no edit to apply. Every one of those was "codec done" and none worked.
It also had a symptom visible all along. markGroupDelivered matched a
bubble by isMine && date == at, because there was no id to match on, so
two messages sent in the same millisecond restated the wrong one —
cheap to hit when a queued burst goes out at once. The rule now matches
on mid, and moved onto the model as a static so it could be tested at
all: it went months without a test because reaching it needed a network
client and a Keychain.
The timestamp path stays, and only for messages with no id. Those were
written before group ids existed, and dropping it would leave every one
of their pending bubbles saying "will send" for ever.
The group bubble's projection now carries mid and replyTo, so the quote
block the bubble already knew how to draw finally gets something to draw.
Closes#58
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list only ever searched NAMES, so a word somebody said found
nothing.
🔴 There is no index, and that is a decision rather than an omission. An
index derived from the decrypted history is plaintext by another name:
it would have to be sealed as carefully as the history, purged with it,
and kept consistent with it — a second copy of everything anybody ever
wrote, with its own ways to go wrong. It is not needed, because the
inbox already restores the whole history into memory at launch. A scan
over it creates nothing new at rest, so there is nothing new to protect.
MessageSearch names the condition under which that stops being true: if
history is ever paged out of memory or outgrows a linear scan, an index
becomes necessary — and must then be sealed with the history key and
purged by E2EHistoryStore.purge(), or the app will have quietly acquired
an unencrypted transcript.
⚠️ The query lives in view state and nowhere else — not beside drafts,
not in UserDefaults, not in the snapshot. What somebody searched for is
often more revealing than what they found.
An empty query finds nothing, never everything: dumping the whole
history onto one screen the moment the search box is focused is the
worst possible default in the place most likely to be read over a
shoulder.
Accent-insensitive, because somebody typing "cafe" means "café" and a
search that knows the difference quietly fails in every language with
accents. Plain substring, never a regex — a stray bracket typed into a
search box is a crash or a hang.
Closes#25
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sealed in the history file, not a plist — 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. UserDefaults would write it unencrypted, leave it readable
while the device is locked, and carry it into every backup. In the
sealed snapshot it inherits the Keychain key, the file-protection class,
and purge() on sign-out.
All three composers: 1:1, group, and federated. The federated one needs
its own key because remoteThreads has no local id — without it every
remote correspondent would share one, and a draft written to somebody on
another relay would appear in the composer for the next person.
Drafts ride the existing 1.5s history debounce rather than getting their
own writer: they change on every keystroke, and a separate one would
seal the whole file per character typed.
Whitespace is not a draft — restoring " " gives a composer that looks
empty and reports itself as having content, which here means an enabled
Send button that sends nothing. But text is stored as typed, not
trimmed: somebody who left a trailing space was mid-word.
Closes#47
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An envelope naming an earlier mid replaces its text. Two things keep
that from being a forgery primitive, and both are enforced and tested
rather than assumed.
Only the author may edit. In a 1:1, isMine is the whole of authorship,
so an inbound edit may only touch a message the buddy wrote and a local
edit only one we wrote. Without it a single envelope naming any mid
rewrites what YOU said in your own transcript. re.author is never
consulted — anyone can put any id there — so authorship is decided from
the copy of the message we already hold.
The reader is always told. "Edited" shows on both sides and is not
behind the timestamp setting or any other, because an edit nobody can
detect puts new words in a bubble they already trusted.
Also: monotonic (relays order by receipt, not authorship, so two edits
can arrive backwards), never a bubble of its own, never a notification,
and it may not empty a message — retracting is a different operation
with different honesty requirements (ios#57).
Send routes through one submit() on the view model rather than the three
places in the view that call it, so the one that forgets an edit is in
progress cannot send a duplicate instead of a correction.
Closes#26
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first version fetched a challenge before every sign-up. That put a
second round trip on the one screen where somebody is already waiting,
for a feature that is off by default and off on every relay running
today — and the full test run caught it, because five existing
RelayAccounts tests suddenly saw an extra request.
Now the first attempt goes out plain and only a challenge_required
refusal sends us to solve anything. A relay with this off sees exactly
one request, unchanged. The refused attempt costs one of fifty per hour
from that address, which is the right trade for not charging everybody
else a round trip.
The fetch also moves onto the app's own transport rather than
URLSession.shared: the app's session has the disk cache turned off and a
second session built here would quietly not.
An expired check is reported rather than silently re-solved — grinding a
second puzzle would double the wait before saying the same thing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay can charge a few seconds of arithmetic before an account
exists — cheap for one person, expensive for a farm, and unlike a
CAPTCHA it hands nothing to a third party. This is the client half:
ask what work is wanted, solve it off the main actor, send the answer
beside the sign-up fields.
Every failure path ends in "sign up anyway". An older relay, a
WordPress-backed site, a request that times out — none of those are
reasons somebody cannot create an account, so the fetch returns nil for
all of them and the fields are simply absent.
Two refusals get their own sentences: an app too old to answer, and an
answer that did not hold up (nearly always a challenge that expired
while somebody was typing). Sharing one message would send half these
people to the App Store for nothing.
The bit counting is pinned to fixed vectors, not checked against our own
arithmetic. A byte-counting implementation still SATISFIES a 9-bit
difficulty — it overshoots to 16 bits and does 230x the work — so an
assertion about "at least 9 leading zeros" cannot see the bug. The
first draft of these tests had exactly that hole and caught nothing.
Refs flamenet-relay#39
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay gained two refusals alongside look-alike handle checking, and
`RoutesAccount` says why mapping them here is not optional: *"These codes
are not arbitrary: the shipped client switches on them to pick its own
error copy, and anything else falls through to a generic failure."* A
refusal somebody could act on, rendered as "something went wrong", is the
worst version of it — they retry the same thing and are refused again.
⚠️ **Both messages say what to DO.** "Too similar" on its own leaves
somebody retyping the same name with a different digit and being refused
again; the fix is to change a letter, so the copy says that. The
disposable-address one explains the consequence — that address is how
they get back in — rather than sounding like a judgement about their
email provider.
**`SignupErrorCopyTests` is new, and error copy had no coverage at all.**
It pins every sign-up refusal to its own words, fails if two share a
message, and fails if a code renders as itself — which is what falling
through the switch looks like from the outside.
⚠️ It also pins the sign-in refusal: `bad_credentials` must not reveal
which half was wrong. The relay answers identically on purpose, and a
message here that told them apart would undo that at the last step.
347 tests pass, up from 342.
Pairs with flamenet-relay!11.
Seven faults across four repositories in one day. Three of them were
shipping, two were self-inflicted inside the same session, and one was
the reason the Android download had been pulled a day earlier.
Recorded because the reasons they were hard to find outlast the fixes:
- asymmetric clients hide bugs — Android sends text as bare bytes and
iOS wraps it in an envelope, so the bench (iOS↔Android) never
exercised the pairing that was broken (iPhone↔iPhone);
- a change with two halves must land as one — sign-out keeping the
device shipped without sign-in loading it, and the result was not a
half-working feature but an account that destroyed itself once per
sign-in, with every symptom looking unrelated;
- strings in a binary prove nothing, because comments do not survive
compilation — `nm -gU` is the check that means something;
- os_log from an XCTest host never reaches `simctl log show`;
- establish a baseline with a worktree at the last good commit BEFORE
carrying "unknown whether I caused this" for hours.
Open work is in GitLab rather than here: iOS #6–#10, Android #6–#7,
relay #13. The table at the end says what guards each fault now, and
says plainly which guard has never completed a run.
Every unit suite here runs inside one process. Every bug that reached the
owner today lived between two of them, over time: sign-in minting a new
identity instead of loading the stored one; the key-log check asking for
the wrong subject and accusing a contact of tampering; the relay
announcing protocol traffic as messages. None reachable from a unit test,
none caught by sending one message by hand.
`Scripts/lifecycle-gate.sh` runs the sequence instead: two simulators
sign up, exchange a message, one signs OUT and back in, and then —
without anybody accepting anything — messages must cross again and a call
must not be refused. The assertion the whole file exists for is a
negative one: "Security keys changed" must NOT be on screen.
🔴 One `xcodebuild` invocation per side. A second reinstalls the app and
takes the container with it, and state SURVIVING is the entire point.
🔴 A skip exits 2, never 0. A run that silently did nothing looks exactly
like a passing one.
⚠️ **It refuses to start above load 20, and that is not caution.**
Measured today: it drove this machine to a load average of 591 and
starved the owner's own emulator and simulator out of existence
mid-session. The two sides must run concurrently — each is the other's
peer — so the concurrency cannot be traded away, which leaves refusing.
Also adds `chat.voicecall`, which the video button beside it has always
had. Without it the gate finds the call button by its label, which is the
string a translator changes.
⚠️ NOT yet proven able to fail. It has not had a clean run on a quiet
machine, so it is committed as a tool, not as evidence.
The relay announced every envelope as "New message" because it holds
ciphertext and cannot tell one from another. It now takes `silent` from
the sender, and this is the sender.
Marked silent: call answers, call ends, delivery keys, key-log gossip,
reactions, avatars, disappearing-timer changes — everything that is a
layer talking to a layer. A call offer still announces itself, and so
does anything a person would see in the thread.
⚠️ Decided from the CONTENT rather than passed in by each send path,
same as `isCallSignal` beside it: a flag threaded through six callers is
a flag one caller forgets, and the failure is an extra banner nobody
traces back to a missing argument.
⚠️ A plain string is a message. Text from an older build carries no
envelope at all, and the safe reading of something unrecognised is that
a person meant to say it.
🔴 **Also narrows `urgent` to the OFFER.** It was every call signal, so
an answer and a hang-up each sent a VoIP push as well. A VoIP push is a
promise: Apple requires the app to report an incoming call to CallKit the
moment one arrives, and an app that takes them without doing so is
terminated and eventually loses the entitlement. Two thirds of the VoIP
pushes this app asked for had no call to report — and on a device with no
VoIP token they fell back to an ordinary alert, which is where the
banners during calls were coming from.
Also drops a binding left dead by the EXIF fix: the picked item's content
type must NOT be read any more, because `ImagePrep` re-encodes to JPEG
whatever came in, and naming the file after the original would label a
JPEG as a HEIC.
342 tests green.
🔴 **The banners were not coming from this app, which is why nothing I
did to the posting side changed them.** `NotificationManager` is the only
thing here that creates a notification, and the log shows it did not fire
once during a session full of them. The relay pushes them. A push arrives
at `willPresent` having gone nowhere near the suppression added earlier,
and `willPresent` said `[.banner, .sound, .badge]` to everything.
🔴 **And the relay pushes per ENVELOPE, not per message.** Call
signalling, delivery keys and log gossip are all envelopes, so placing one
call or sending one message produced a burst of "New message" banners for
things nobody sent and nobody can act on. From the client they are
indistinguishable from real mail — same payload shape — so the rule has
to be about the app, not the payload.
While the app is in front: no banner. The screen already shows what
arrived and the unread badge already marks it; a banner over a
conversation the reader is looking at is noise by construction. Badges
still update, and the moment the app backgrounds this method is not
called at all, so notifications behave exactly as before — which is the
case they exist for.
A call in the foreground now gets nothing at all, not even a sound. The
ring screen is already filling the display, and the banner was landing at
the exact moment someone is trying to hit Answer.
⚠️ The earlier `visibleConversation` work is still right and still
needed — it governs what this app posts itself. It was just never the
thing on screen.
A call refused by the trust gate said "Their security code changed —
open their chat to review it", and stopped there. Accurate, and a
scavenger hunt: the control that fixes it lives in the conversation and
nothing on the call screen went there. Reported as having to hunt for
where to accept new keys, which is exactly what it asked for.
The chat already carries the whole banner — what changed, why it might
be innocent, compare the safety number, accept the new keys. So the
ended-call screen now offers "Review keys" when that is the reason, and
it opens that conversation. Cleared when a call starts, so a stale one
cannot offer a review for the wrong contact.
**Location.** Neither app has a location feature — no permission, no
CLLocation, nothing. What travelled was metadata riding inside the media,
and two iOS paths carried it out:
- A story VIDEO. `AVAssetExportSession` copies the source asset's
metadata unless told otherwise, and a clip from the camera roll
carries the capture location — so posting a story handed every
viewer the coordinates of wherever the person was, with nothing on
screen to suggest it. `export.metadata = []`: emptied rather than
filtered, because an allow-list of "safe" keys is a list somebody
has to keep correct for ever and a story needs none of them.
- The legacy photo upload. It sent the file exactly as the picker
handed it over, EXIF and all, and it is the path that talks to the
PLAIN server — so the weakest route was also the least protected.
It goes through `ImagePrep` now like every other.
Chat photos, group photos, avatars and story photos were already safe,
but only as a CONSEQUENCE of being redrawn to resize. That protection
disappears the first time somebody adds a reasonable fast path for
pictures already small enough to send. `E2EAttachmentPrivacyTests` turns
the side effect into a promise: it builds a JPEG carrying real GPS
coordinates, asserts the FIXTURE is dangerous before asserting the
output is clean, and covers the small-photo case separately because that
is exactly what such an optimisation would target.
**Stories.** The lifetime was a constant, so the one thing people want
to change about a story — how long it lasts — was the one thing they
could not. 1 hour / 6 hours / 24 hours / 3 days / 7 days, defaulting to
what it has always done, in the screen that already governs stories.
⚠️ Read at post time rather than captured once: a `static let` would
hand every story whatever the preference was when the app launched.
⚠️ `StoryLifetime.current` falls back rather than trusting the stored
number. `UserDefaults` answers 0 for a key never written, and a story
that expires zero hours after posting is one nobody ever sees — the
failure would present as the story never sending.
The expiry rides inside the sealed envelope (§6.6), so this asks nothing
of the relay: every copy carries the number its poster chose. Both
footers say what it does NOT promise — a viewer who screenshots a story
has it regardless.
342 tests green.
The control-traffic guard added this morning tested
`text.hasPrefix("{\"fnc\":")`. An ordinary text message IS an `fnc`
envelope — iOS has sent text that way since messages gained ids, so
they can be replied to and reacted to — and the guard sits immediately
before `appendToThread`. So it consumed every message instead of
delivering it: 338 tests, 19 red, including
`testTwoAccountsExchangeAMessage`, which the file itself describes as
the path every other test assumes works.
It was also redundant. Control traffic marks itself with `ctl: 1` and
`content.isUnknownControl` already consumes it one screen further up. I
added a second, blunter guard for a job that was already done, and the
blunt version could not tell a message from a protocol frame.
What is left for it to catch is the envelope `parse` could not decode at
all — malformed, truncated, or written by a build whose shape this one
does not know. `parse` reports that by handing back the raw plaintext as
the message text, so comparing the two identifies it exactly, with no
guess about what any other kind means.
🔴 **Manual testing could not have found this.** The Android client
sends plain text as bare bytes when there is no reply, link or id, so
Android-to-iOS never carried the prefix and kept working all afternoon —
while iPhone-to-iPhone delivered nothing at all. That is the pairing
that was about to go to TestFlight.
The new test pins all three cases apart rather than only the one that
broke, and was proven able to fail: restoring the old guard turns it red
on "a text envelope must reach the thread, got: []".
Baseline established with a worktree at 55943eb — 6/6 there against 3/6
on the branch — rather than left as a suspicion, which is how this sat
unexplained for most of the day.
Three separate things were raising a "New message" banner for something
the reader could already see, and together they read as the banner
firing on everything.
* A group chat on screen never suppressed anything. The check was a
buddy id, and a buddy id cannot name a group — the group notifier
passes the AUTHOR's id, so "am I reading this thread" compared a
group against whoever had just spoken in it and was false every
time. A thread is now identified by what it is: a buddy, a group,
or a federated address.
* The in-app tone was played BEFORE every guard, so a muted
conversation, a thread already open, and an account with
notifications switched off all still chimed on each arrival. Two
handsets a foot apart pinging on every message, with no banner to
explain it, is most of what this sounded like.
* The 1:1 suppression was written but had never shipped: the running
build carried no `visibleConversation` symbol at all.
Also drops the call-stack trace from the notify log — scaffolding that
found the above; walking the stack on every notification is not
something to leave in.
Signing out no longer leaves wreckage behind either, which is the same
class of bug and shares a file. It did three things in an order where
each was wrong:
* The credential was cleared LAST. Killed anywhere in the middle, the
app relaunched holding a valid app password and no identity, signed
itself back in, minted a fresh identity and registered a SECOND
device — so every contact's pinned key stopped matching. Reachable
by nothing worse than the system reclaiming memory at a bad moment.
Now: signed out locally first and synchronously, so every state
this can be interrupted in reads as signed out.
* The relay was told on a detached Task that ran after the wipe, so
it read a device id out of an emptied store — MINTING one, because
that getter registers a device as a side effect of being read — and
asked the relay to forget an id that never existed while the real
device stayed registered and kept being woken. Teardown now reads a
non-minting `existingDeviceID`.
* Nothing ever retired the device. The account went on publishing a
reader that would never read again, and fan-out is per device, so
contacts kept addressing copies to a dead mailbox while the relay
reported them all delivered. `retireThisDevice()` forgets the push
token and revokes the device, bounded at six seconds so a bad
network cannot hold the person or leave them half-wiped.
A device's identity is one fact and was stored as two: the id in
UserDefaults, the key in the Keychain. Anything that replaced the app
container — a reinstall, an Xcode rebuild, `simctl install` — kept one
and lost the other, and every combination produced a different failure
that blamed something which was not wrong:
- identity lost, id kept → the relay refuses the pair and the app reads
its own 401 storm as a credential problem
- id lost, identity kept → a SECOND device is registered on the account,
contacts fan out to a mailbox nobody reads, and their pin sees an
account publishing two identities, which is what a planted device
looks like — so they stop sending and ask about safety numbers
- either → peers key sessions by our id, so ours become unfindable and
the far side reports "identity keys changed" about an identity that
never moved
Together they cannot diverge. A reinstall either keeps both, in which
case nothing changed, or loses both, in which case this genuinely is a
new device and every path downstream already handles that. The third
state is gone, and with it this whole class of bug.
⚠️ The UserDefaults value is still READ, once, to migrate. An install
that predates this has a perfectly good id there, and minting a new one
would inflict the exact failure this removes on every existing user at
the moment they update. Verified against a live install: same device id
across the change, no re-registration, sessions intact.
⚠️ Written to BOTH, and the mirror is not belt-and-braces. If a Keychain
write fails — no entitlement, a locked device, a test host without one —
the read fails too, and the getter would mint a fresh id on every access:
a new device per call, sessions dropped underneath the app. The Keychain
is authoritative; the suite keeps the value stable when it is not there.
`wipe()` clears it, or signing out would leave an id that comes back as
the same device on an account no longer holding it.
Also here: a banner is no longer posted for the conversation already on
screen. The app presents banners while frontmost, so a message arriving
in the open thread dropped a banner on top of the bubble it was
announcing — every message, in a chat the person was actively reading.
Only that conversation and only while active; on another tab, another
chat, or in the background it still fires.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`rotateDeviceID` carried this comment:
Ratchet state is keyed by OUR device id on the peer's side, so a new
id means new sessions. Leaving the old ones would have us decrypting
with keys the peer no longer sends to.
and then cleared only `published_proto`. Every ratchet stayed.
A peer looks a session up by the device id we send FROM. Once ours
changes, none of our sessions can be found at the far end — but this
device still holds them, so it goes on sending ordinary `msg` envelopes
on chains the peer has no entry for. The peer cannot match them, falls
through to its handshake branch, finds no handshake in an ordinary
message, and reports the only thing that branch can report: "identity
keys changed". Nothing about the identity changed. Messages stop
arriving in one direction and every diagnosis goes looking for a trust
problem that does not exist.
Both paths that change the id now drop sessions: `rotateDeviceID`, and
the getter that mints one when the id is missing — which is the case
that actually bites, because the id lives in UserDefaults and the
identity in the Keychain, so a reinstall loses one and keeps the other.
The archived copies go too; they are keyed the same way and are equally
unreachable.
The next send then has no session, runs X3DH, and arrives as a prekey
envelope the peer can adopt — which is the only thing that rebuilds the
pairing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device id lives in UserDefaults and the identity in the Keychain, so
they are lost independently — and only ONE of the two directions was
handled. `recoverFromDeviceMismatch` covers "new identity, old device id":
it rotates and retires the abandoned row. This covers the reverse, which
happens whenever the app's container is replaced while the Keychain
survives: the device id is gone, the identity is not, so the app mints a
new device id, registers it, and cannot retire the old one because
nothing remembers it existed.
The account then publishes two devices. A contact fans out over the
directory and sends half of everything to a mailbox nobody reads, while
the relay reports every one of those copies delivered — and the peer's
identity check sees an account whose published keys no longer match what
it pinned, so it refuses to send at all:
REFUSED by guardIdentity: device f70f82fd has published keys, but
none match what this relay served
🔴 **The identity key is what makes this safe rather than a heuristic.**
Every real device has its own identity key, so a second phone never
matches. A device publishing OUR identity key that is not our device id
can only be a copy of this install that lost its id — there is no other
way to hold that key. It cannot delete a legitimate second device.
⚠️ Runs only after the replacement registration is confirmed. Deleting
anything before that risks an account with no device at all.
Found by causing it: `simctl uninstall` during a "clean cache" redeploy
replaced the container, kept the Keychain, and reproduced the whole
failure — messages refused in both directions and calls unanswerable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The encryption description pasted into App Store Connect listed X25519,
Ed25519, HKDF, HMAC and AES-GCM — and stopped there. The app has shipped
hybrid post-quantum key agreement (ML-KEM-768, FIPS 203) and hybrid
post-quantum identity signatures (ML-DSA-65, FIPS 204) since §12 and §20,
and neither appeared anywhere in this file.
The ANSWER does not change: both are NIST standards reached through
CryptoKit, so "no proprietary or non-standard algorithms" is still
correct. But a declaration that omits an algorithm the binary contains is
inaccurate, and this is the text that goes to Apple and underpins the
annual BIS self-classification report.
⚠️ Anything added to the protocol from here belongs in this list on the
same commit that ships it. This drifted precisely because updating it was
nobody's job at the moment the algorithm landed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A banner appearing at a moment nobody expects one is impossible to trace
when the only thing that knows it fired is the banner itself. Both paths
now log: the buddy it is for, and — for a message — how long the body was.
⚠️ The LENGTH, never the body. The whole point of the preview setting is
that message text does not leak to places it was not meant to go, and a
log line is one of those places.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two changes, one of which is a bug I introduced and caught in the same
sitting.
**Calls get their own notification.** The app had one banner for
everything that arrived, so a call — the one thing that stops mattering
if you look thirty seconds later — announced itself as "New message" and
gave no reason to hurry. `postIncomingCall` says "Incoming call" or
"Incoming video call", uses the ringtone sound, and clears when the call
finishes: a banner still saying "Incoming call" after the caller hung up
sends somebody back to an app with nothing ringing in it.
⚠️ Not posted while the app is in front. This app presents banners even
when active, so a foreground call would put a notification on top of the
full-screen ringing UI it duplicates. The notification is for the person
who is NOT looking at the app.
⚠️ Governed by the existing message toggle and per-buddy mute rather than
a new switch. A second control nobody knows to look for is how somebody
silences messages and still gets rung at midnight.
**Control traffic that does not parse is consumed, not shown.** Every
control branch matches on a KIND and then requires its fields, so an
envelope that is malformed, truncated, or written by a newer build with a
different shape falls past all of them and is delivered as an ordinary
message — raw JSON in a conversation, announced by a "New message"
banner, for something no person sent.
🔴 The first version of this guard sat one branch too early and ate every
incoming call. `call-offer` is an `fnc` envelope like any other, so a
catch-all placed in front of the call branch catches calls: offers were
logged as "control traffic we could not read" and the phone never rang.
Caught within minutes because the log said exactly what it had dropped,
which is the whole reason those lines exist. It now sits after every
handler, and the ordering is the correctness argument.
Verified after the fix: an offer from Android reaches `call signal in`
and the ringing screen appears, 1.1s from the caller pressing the button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous commit added a second fast-poll mechanism to the inbox
without noticing the one already in `CallManager`, which has polled every
second since calls shipped. This reverts that and fixes the real defect,
which was one word in a switch:
case .dialing, .ringing, .connecting: // .active was missing
Fast polling stopped the moment a call connected, so the one signal still
to come — the other side hanging up — dropped back to the ordinary
five-second cycle. Ending a call looked like a button that did nothing,
on whichever device did not press it. Adding `.active` is the whole fix,
and it keeps one mechanism instead of two overlapping ones polling the
relay twice a second between them.
Measured on a live pair, Android to iPhone: offer seen in 1.0s, hang-up
in 1.2s. Both were previously bounded by the five-second poll.
⚠️ What remains is on the SENDER: ~1.2s of ICE gathering before the offer
is transmitted at all, because the SDP is sent only once gathering is
COMPLETE. That is a separate change (trickle ICE) and is not attempted
here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A call is three round trips a person is watching — the offer, the answer,
the hang-up — and each waits for the far side to poll. iOS polled every
five seconds regardless, so each leg could sit for that long: up to
fifteen seconds of a ringing phone and buttons that appear not to work.
Android has had a faster cadence during call setup since calls shipped
(`CALL_SETUP_POLL_MS`), which is why the lag was one-sided and why it
showed up as "iOS is slow to connect and slow to hang up".
The inbox now drops to a one-second gap while call signalling is moving,
and returns to five afterwards. Marked from BOTH directions on purpose:
the caller is waiting on the answer, the callee is waiting on the
hang-up, and watching only our own sends would leave whichever side is
receiving slow exactly when the other is waiting on it. Marked before
the send, so the very next poll is already the fast one.
⚠️ Ninety seconds, then back to normal. Long enough to cover ringing and
the hang-up after it; short enough that a missed call does not leave the
app polling five times harder for ever. A relay that long-polls is
already faster than this and keeps its own 0.5s gap.
Measured on a live pair, Android to iPhone: hang-up seen in 0.43s where
the same signal previously waited on a five-second poll. The offer took
3.0s end to end, of which ~1.2s is the SENDER waiting for ICE gathering
to complete before it transmits anything — a separate cost on the
Android side, not addressed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`E2EClient` is a plain class and `useRelayAuth` is a synchronous setter,
so the await on it was noise — flagged by the compiler as "no async
operations occur within this await expression". Removing it changes no
behaviour and leaves the build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AVAudioSession says it itself — "This method can lead to UI
unresponsiveness if called on the main thread" — and both remaining
callers were on it.
The recorder's teardown. `setActive(true)` was moved off the main actor
when a tap on the mic button cost sixty seconds of a frozen app;
`setActive(false)` was left exactly where it was, on the path taken
every single time a recording stops or is cancelled. Deactivation waits
on the route being torn down and on other apps being notified, which is
the same block in the other direction. Fire-and-forget: the file is
already read, the state is already cleared, and the next start()
activates again regardless.
The player, which matters more because it runs far more often — every
voice note anyone plays. Activation now happens off the main actor and
playback starts when the route is up, back on the main actor, so
ordering is preserved and only the waiting moves. `isPlaying` is set
first so the control responds immediately, and re-checked before play()
because routing takes long enough that somebody can tap pause in the
gap — starting playback then leaves a bubble playing under a paused
control.
Unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two separate faults, reported together as "away and busy do nothing".
Away and busy rendered NOTHING. Four call sites each hand-rolled a green
circle gated on `presence == .online` — the contact row, both places on
the profile, and the chat header — while `PresenceDot`, which has had the
colours for all four states the whole time, went unused outside its own
preview. So a contact who set themselves away or busy lost their dot
entirely and became indistinguishable from offline, which reads as the
status never having arrived. All four now use the component; it gained a
ring parameter so each keeps the border that lifts it off the avatar.
Presence never updated. It rides the buddy list, and that list was
reloaded only when the Home tab appeared, on pull-to-refresh, or after
adding somebody — so a contact's status was whatever it had been when
you last opened that one screen. `ChatViewModel` was worse: it captures
`buddy.presence` at init and never touches it again, under a comment
saying the poll loop refreshes it, which described the WordPress client
this replaced.
`AppEnvironment` now refreshes the roster on a timer and both views read
their dot from it, falling back to their own snapshot. Deliberately slow
at 20s: presence is the least urgent thing the app shows, and matching
the message loop's cadence would multiply that traffic for a coloured
dot. The refresh says what it saw and says when it fails — a refresh
that fails silently looks exactly like a contact who never changes
status, which is the bug it exists to fix. The task is cancelled on sign
out, or a signed-out app goes on asking the relay who is online.
Verified against a live pair: Android to away at 09:30:45 read as Away at
09:30:52, back to online at 09:31:16 read as Online at 09:31:34.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An established v3 session refused every message a peer sent after the
first, and said nothing about it anywhere. The relay reported those
envelopes delivered; the app showed an empty conversation.
`ingest` decodes `pqdh`/`pqct` from the header and then passed them to
only ONE of its three decrypt attempts. The live session and the
archived session were both called without them, so both defaulted to
nil — and a v3 session correctly refuses a ratchet step carrying no
post-quantum material (§13.4). Only the handshake path passed them,
which is exactly why first contact always worked and everything after
it died. iOS to Android was unaffected, because the Android engine
reads the header properly; Android to iOS broke the moment the
conversation turned its first DH ratchet.
Three silent discards are now reported. Between them they swallowed
every failure in this file: an unopenable envelope looked identical to
one that was never sent, from either end, which is what made this take
so long to find.
- the live chain's refusal, thrown away by `try?`
- the AEAD failure, a bare `return true`
- "we hold a session but nothing opens this", also a bare `return true`
The last of those now also REBUILDS. Advancing is right — a chain
mismatch cannot be retried — but leaving it there means every later
message from that contact is lost too, so the peer is asked to
re-establish. The repair drops the dead ratchet first: `encrypt` reuses
any session it finds and only runs X3DH when there is none, so
repairing while holding the broken chain sends the repair down the
broken chain.
Also here:
- A cursor held for a missing session is now BOUNDED. It returned
false for as long as the session was missing and the poll loop
breaks on false, so one envelope whose prekey never arrived stopped
delivery of every later message from every contact, for ever.
- A rotated device is RETIRED rather than orphaned. The old row stayed
registered, so senders went on addressing a mailbox this device no
longer read, and the account advertised two identity keys — which is
indistinguishable from a planted device, so contacts stopped sending
and asked whether to accept new keys.
- The repair guard is a cooldown, not a latch. As a boolean it was set
on the first attempt and cleared only on success, so a repair that
did NOT work — the case it exists for — disabled every later one.
- Startup logs which device and identity the process is running as.
Not knowing that is where most of this went sideways.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay half shipped and deployed. No screen called any of it, so production
stayed at four groups, four of them plaintext, and seven readable membership
rows. Sealing protected nobody.
Making a group now seals it. The relay is handed a blob it has no key for and
one opaque tag per member instead of a `gid -> user_id` row each.
Existing groups needed their own answer, because the migration is additive on
purpose: the relay keeps a plaintext roster until a client replaces it, so a
group made before sealing stays readable for ever unless something does it.
`upgradeGroupToSealed` keeps the gid — a new one would strand the thread, the
history, and everyone else's copy — reads the members out of the roster we
already hold rather than asking for them, and is owner-only. The owner gets a
button for it, worded to say what it does and does not buy: it protects the list
if the database is stolen or handed over, and does not hide the group from
whoever runs the server day to day.
`updateGroupMembers` now routes on whether the group is sealed, the way
`leaveGroup` already did. Without that the screens would have asked the relay to
edit a membership table it no longer has: the call returns fine and the member
is not there. One entry point, so no view has to know which kind it holds.
The startup refresh also asks for sealed groups. `refreshGroups` cannot return
them — the relay holds no rows to answer with — so they were invisible until
something else happened to refresh them, which was nothing.
Three tests. The fixture now deletes the plaintext rows when a group is sealed,
as the relay does in the same transaction; without that an upgrade test passes
while the readable roster it exists to remove is still sitting there.
Executed 338 tests, with 0 failures.
Closes#1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The repaired gate's first act was to fail this MR on a real test failure, which
is the gate working. The test it caught is the one at fault.
`testTheSealedBlobRevealsNothing` sealed member ids 41 and 42 and then searched
the BASE64 text for "41" and "42". Two characters out of a 64-symbol alphabet:
about one chance in 4096 at any position, and across a blob this size that is
roughly a fourteen percent chance of a match that means nothing at all.
Measured, not guessed:
ciphertext 228B -> base64 304 chars : 14.06% of runs contain '41' or '42'
So it passed locally twice and failed on CI, on a build that changed nothing
about sealing. Under the old gate it would have gone green either way, which is
why a one-in-seven test has been sitting here unnoticed.
Nine-digit ids and a real-sounding name make a chance match impossible rather
than merely unlikely. The search now also covers the decoded bytes, which is
where the property actually lives — the base64 could in principle hide a
plaintext run that the raw bytes reveal, and it was never checked.
Executed 8 tests, with 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two faults in one job, and the second is the serious one.
The pin. `.gitignore` excludes the whole `.xcodeproj`, and SwiftPM keeps
`Package.resolved` inside it, so the pin git can see lives in `Scripts/` and
only `Scripts/generate.sh` copies it into the generated workspace. This job ran
bare `xcodegen generate`, so CI resolved every dependency fresh against the
network while local builds stayed pinned — which is why it only ever broke on
CI. It broke when upstream MOVED the 151.0.0 tag of stasel/WebRTC, a prebuilt
XCFramework, from 19aa8c1f to 3e43fa9b. The pin is what noticed, and the pinned
commit is still fetchable, so honouring it needs no decision about the new one.
The gate. The job ended in:
| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || exit 1
A pipeline exits with the status of its last command. A failing run prints
"** TEST FAILED **", grep matched it and exited 0, and the job went green on a
red suite. Every pass this job has ever reported was unverified. It went red
today only because the build died before printing anything to match — failing
for the wrong reason, which is what made it look like a dependency problem
rather than a gate that never worked.
So the log is now the gate: "** TEST SUCCEEDED **" must be present, and a run of
at least one test with zero failures. `[1-9][0-9]*` because a suite that ran
nothing is not a pass — a tree that silently stops building tests otherwise
reports the same green as one that runs them all.
Checked against a wiped project, exactly as the runner's git clean leaves it:
the pin lands at 19aa8c1f and `Executed 335 tests, with 0 failures`. The gate
was exercised on all four shapes first — genuine pass, failing suite, empty
suite, dead build — and only the first passes.
Closes#3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clients refuse group messages from non-members (§6.7). That check reads
`groups`, and `refreshGroups` assigned `groups` outright from GET /e2e/groups
— a route with no signature on it. So one registry refresh after
`refreshSealedGroups` replaced every decrypted roster with whatever the server
sent, and the gate then enforced the server's answer.
Encrypting the roster bought nothing while that line stood. A relay could name
anyone it liked as a member and have their messages accepted, which is the
attack sealing exists to close.
The negative control is the part worth keeping: with the fix reverted, the
relay's forged roster [2450, 2451, 2452] replaced the blob's [2450, 2451] and
Mallory's message landed in the thread. Both assertions are needed — checking
only the roster would pass with the gate broken.
It also broke against an HONEST relay, which holds no membership rows for a
sealed group at all: a routine refresh silently emptied the roster, and the
group vanished from the list. That is the second test.
Three changes. `refreshGroups` filters out any gid we hold secrets for and
keeps the decrypted entry. The receive path resolves those gids through the
sealed refresh instead of asking the relay. And a gid becoming sealed drops any
row the relay supplied earlier — the one ordering left where a server-supplied
roster could survive, since the sealed refresh only overwrites gids whose blob
actually opened.
One residual, documented rather than papered over: for a gid we hold no secrets
for there is nothing to check against and the relay stays authoritative. That is
the unsealed model, unchanged, and it cannot reach an existing sealed group —
for those we always hold secrets and take the other branch.
Closes#2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding and removing members rewrites the roster, signed by the owner key
we hold iff we created the group. A member who is not the owner has
nothing to sign with and is refused locally rather than sent to be
rejected.
🔴 Removal takes the TAG with it. The blob is what members read, but the
tag is what the relay enforces — a removed member whose tag survived could
still fetch every future roster. The test asks the relay directly with the
tag the removed client still holds, because that client has no way to know
it has been revoked.
Existing members keep their tags across a rewrite; only newcomers get
fresh ones. Reusing a tag for a new member would hand them a token
somebody else had held.
And the encrypted history export now carries the tags and keys. Without
them a restored device can see its old messages and can never rejoin the
groups they came from — the relay cannot say what you are a member of,
which is the whole point, so there is nobody to ask. Gaps only on import,
like the names.
⚠️ This makes the export the most sensitive thing this app writes: a group
key opens every roster it ever sealed. It is already encrypted under a
passphrase-derived key, which is what makes carrying it defensible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The invitation carries the tag and the group key over the pairwise
ratchet, riding `delivery_key` like the group name does. It is the only
way a member ever gets in: the relay holds neither, by construction, so
somebody who is not told cannot fetch the group at all — there is nobody
left to ask.
🔴 An invitation is accepted only for a group we hold no secrets for.
Without that, anyone able to reach us could overwrite the key for a group
we are really in, and every later roster would fail to open — a working
conversation turned into one that silently has no members. Negative-checked.
Leaving a sealed group surrenders the tag. There is no account row to
remove; the tag IS the membership.
The tests assert on what the relay was HANDED, not on what it chose to
keep — what it kept is the weaker question. No member id and no group name
appears in the create request. And the read-back goes through the relay
rather than the local list `create` appended, which would have passed with
the fetch, the tag lookup and the blob all broken.
The stub relay models a sealed group as a blob, a version, an owner key
and opaque tags, with no member list — a fixture holding one would be
modelling a relay we did not build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client half of §8.9b: create mints a gid, a group key, an 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 used to ask "does this
account have a device" and needed to know who the members were to do it.
The client is the party that suffers an unreachable member anyway, since
fan-out is per device.
🔴 **`project.yml` now carries the iOS 26 deployment target.** It had been
raised in `project.pbxproj`, which is GENERATED and gitignored — so
post-quantum key agreement compiled on the machine that made the change
and reverted to an iOS 16 floor for anyone who ran `xcodegen generate`,
which is the documented way to add a file. I hit exactly that adding
SealedRoster.swift: the build failed on `MLKEM768` availability, pointing
at the crypto rather than at the project file that had quietly reset.
A blob that will not open is skipped, never treated as an empty group —
dropping every member from a conversation that still exists is worse than
showing it stale. Sealed and unsealed groups merge rather than replace, so
groups that have not been upgraded do not vanish.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
§8.9b's client half: the group key, the member tag, and the blob itself.
The key is random and travels with the invitation rather than being
derived from the gid or the member list — a derived key changes when the
list does, and every older blob stops opening.
Tags are random for a sharper reason: anything derived from the account
is a pseudonym the relay can link across groups, which is the same social
graph wearing a hash.
🔴 The test that matters is the last one: a roster sealed by the KOTLIN
engine, opened here. Every other test in the file seals and opens with the
same code, which proves the two halves agree with each other and nothing
about whether this engine agrees with the other one — and every real group
has members on both platforms.
Only that direction is pinned, deliberately. Decoders do not care about
key order, and nothing re-encodes a roster to check a signature: the owner
signs the blob it was handed, so differing key orders cannot break
verification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Group names are announced to our own ACCOUNT as well as to the members,
because fan-out excludes us everywhere else and a second phone we own
would otherwise be told by nobody. That is only safe because `transmit`
drops this device from every recipient list — without the filter,
addressing our own account opens a ratchet session with ourselves and
posts an envelope into our own inbox.
Nothing checked the filter. It does now, as an absence: no queued envelope
is addressed to this device. That is the only shape available with one
device in the fixture, and it is the shape that regresses — remove the
filter and the test fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gate landed on 2026-08-29 and `canSubmit` has required a year of birth
ever since. The test was never taught to pick one, so it failed on "submit
stayed disabled" — and the live sign-up path, the one thing that proves a
new person can actually get in, went unverified from then until now. The
app was fine the whole time; the check was not.
Two things it got wrong on the way, both worth keeping in the comments:
the picker's options are not buttons in every OS version, so the year is
matched by label across element types; and the list runs 1900…this year in
reverse, about 127 entries, of which a menu renders only what is on screen
— a year thirty rows down is not in the accessibility tree at all, and the
failure reads as "the list never opened".
It now picks the youngest year the gate accepts, which is near the top of
the list and is also the boundary worth exercising.
Verified against production: passes, creating a real account through the
real UI and ending signed in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The announcement has always been refused from an unproven sender (audit
C2). An ordinary group envelope carrying `gname` was not — so the weaker
of the two routes decided what a group is called.
On an unproven device `fromUser` is a CLAIM: the binding says that device
belongs to somebody else. The membership check then asks "is the person
they are pretending to be in this group", which answers yes for any member
the attacker cares to name — and a group heading in someone's chat list is
a good place to put a lie, because the reader has no reason to doubt it.
The test needed a seam onto `deliver`: a real sender cannot make its own
claim fail to match its binding, so the only way to exercise a refusal is
to hand the receive path the shape an attacker would produce. Removing the
gate fails it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 snapshot (optional, for the decode-compatibility reason already noted
on `calls`), 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. `transmit` now drops our own device from every recipient list — a
no-op for anyone else, since their directory cannot contain our device id —
which makes it safe to address the announcement to our own account.
DemoRoundUITests no longer asserts the old group name: the relay deleted
it, so a fresh install has been told nothing, and the round is about the
group being reachable rather than what it is called.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Names used to be fixed at creation and served by the relay for ever, so
no client ever needed this. The relay has now dropped them — and that
makes every group created before the change read as the placeholder on
every phone, with nobody left to ask what it used to be called. The relay
deleted its copy and no client had ever kept one.
Without a rename, those groups are called "Group" permanently. This is
the repair, and it is the first time a group's name has been changeable
at all.
Stored first, announced second: the name is ours the moment we set it, so
a member who cannot be reached now does not stop us seeing the group
under the name we chose. They pick it up from the next message, which
carries it. A blank rename is a no-op — the field is pre-filled, so an
accidental clear-and-save would otherwise leave a group unnamed with
nothing left to restore it from.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder every group shares. The real name
is held in the keychain 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 — those builds read
§6.10's `ctl` flag with `optBoolean`, which does not coerce the number
the spec writes.
A name is adopted only from someone the roster says is in the group.
Without that check anyone who could reach you could rename any group in
your list by guessing a gid — an entry in a chat list that says whatever
a stranger wants, where the reader has no reason to doubt it.
`refreshGroups` substitutes the local name into `E2EGroup.name` rather
than teaching every view where names come from now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
§21.4 gave a lever to cut off one contact and nothing pulled it — a
capability built and left unreachable, which is the failure this project
has been fixing all day elsewhere.
Blocking is where it belongs, and for a stronger reason than
convenience. The relay CANNOT enforce a block against a sealed sender:
the request carries no sender to compare against a block list, and the
send route's own comment says so. So blocking stopped the identified
path and nothing else, and a blocked person's sealed messages were
dropped by this app after decryption rather than refused at the door.
All four call sites go through one place now — the profile screen, the
chat menu, the unblock path and the `/block` command — because a fifth
one would otherwise forget.
The copy changes with it. "Buddies you block can't send you messages"
was not true, and a safety screen is the last place to overclaim. It now
says what blocking does and names the gap: the shared key everybody
holds is not that person's alone, so it takes replacing to close
completely, with the cost said in the same breath rather than on another
screen. The sealed-sender section no longer calls replacing the key "the
only way" to cut somebody off, which was true when written and is not
now.
307 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
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 the Android build in the field reads that flag with
`optBoolean`, which does not coerce the number §6.10 calls for. A new
kind would have arrived on those phones as a bubble full of raw JSON.
Replenishment is counted rather than requested, for the same reason. The
issuer counts what it receives, because every message from a contact
spent one of their capabilities, and mints afresh at half a batch — the
count is a lower bound, so it runs behind on purpose.
A refused capability means the batch was revoked, so it is discarded and
the same envelopes re-sent on the shared key; they are addressed to the
device, not to the authorisation. Not a way around revocation: revoking
moves a sender back onto §14.4's key rather than silencing them, and the
code says so where somebody changing it would read it.
The receive branch no longer requires a valid key to bind before it will
look at the batch. The Kotlin engine had the same shape for a different
reason and it was a live bug there — a device that had not published a
key of its own dropped everything in the envelope. Two engines diverging
on which halves of an envelope survive is exactly the class of
disagreement neither one's own tests can see, so both now handle the
batch first and independently.
307 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple first ships ML-KEM and ML-DSA in iOS 26. The app deployed to iOS
16, so both sat behind `#available` — and an iPhone on 16 through 25 got
no post-quantum protection at all. The only other way to close that was
to bundle our own implementation of a lattice KEM, which is a serious
thing to take on for a messenger and worse than asking for a newer OS.
So the floor moves and the gates go. `PQKEM.isSupported`,
`PQSign.isAvailable` and `E2EProtocol.canAdvertiseV2` are gone rather
than left returning true: a constant that every call site still branches
on reads like a decision when it is not one. The v2 block and the
post-quantum identity are now attached unconditionally.
⚠️ The floor is load-bearing. Lowering `IPHONEOS_DEPLOYMENT_TARGET`
without restoring the gates would not fail to compile — `MLKEM768` would
just be missing at runtime on the devices that gained support, which is
a crash rather than a downgrade. Said so where the code is.
`pqMode` stays `optional`. Every iPhone running this build does
post-quantum key agreement, but a session is only post-quantum when both
ends are, and Android builds already installed register `proto: 1`.
Requiring v2 would make those contacts unreachable rather than
classically protected.
Three X3DH tests were skipping on the old gate. A skipped test is not a
passing one — on a simulator below the floor the hybrid handshake was
never exercised and the suite still reported green. They run now.
The `onChange(of:perform:)` calls went with it. Their comments named the
iOS 16 floor as the reason for the one-parameter form, so leaving them
would have left twelve files asserting something false about the
project. Migrated to the two-parameter form; no deprecation warnings
left from them.
307 tests, all passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things found while writing the tests.
**A relay name could contain an `@`.** `decode` splits on the first one
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. That string is parsed from input nobody
authenticated: typed, pasted, scanned, and read out of a sealed envelope
written by a stranger on another relay. The Kotlin engine refused it and
this one did not, which is exactly the kind of disagreement neither
engine's own tests can see — it took writing the Kotlin suite to mirror
this one.
**Signing in at a second relay went through `URLSession.shared`.** Its
responses go through the shared on-disk URL cache, and that response
carries an app password — a credential written into `Cache.db` and
outliving every screen that could remove it. It uses an ephemeral
session now, with the same refusal to cache that `E2EClient` already
makes for ciphertext. That also gives the request an interception seam,
so the one call here that handles a credential is reachable from a test
at all.
New: 11 tests for adding and dropping a mailbox — including that a relay
refusing the sign-in or the record leaves nothing stored, that both
homes are told the same list and each record verifies, that the relay
being dropped is not asked to accept its own removal, and that a relay
serving a shortened or lengthened list is caught by arithmetic. Plus 4
for the control flag in both spellings.
307 tests, all passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four things this app could describe and not do, and two bugs found
proving it.
**The address was derived and never claimed.** Every account worked out
its `fnid` on sign-in, showed it on a card, and sealed it into federated
envelopes — and never registered it. A peer relay resolving `fnid@here`
asks this relay, and until the claim lands the answer is "no such
address", so 0 of 47 accounts were reachable at an address they could
all display. Claimed now on startup, detached and swallowed: a relay
that is down, too old for the route, or slow must not keep somebody out
of their own messages.
**The mailboxes screen existed and nothing opened it.** No way to read
your own address, no way to add a second mailbox. Linked from the
profile, and it falls back to the stored address — the account key only
exists in the session that stretched the passphrase, so a restored one
showed "sign in to see your address" to somebody who was signed in.
**Adding a mailbox was a stub that said so.** Now it signs in at the
new relay, registers this device, claims the same address, and publishes
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 gets a
stale list and skips the others. Removal publishes the shorter list
first and forgets the credential only if that worked; the reverse leaves
mail arriving where this device no longer reads. The registration
carries the same capability block as the home relay, because a sender
fetches a bundle from ONE mailbox and a second one advertising v1 would
make post-quantum protection depend on which it asked.
**Key transparency was a switch almost nobody found.** Checking is
automatic on any relay that serves a log; the switch survives as the
stricter question underneath. The head is fetched separately from
enforcement, so a relay cannot 404 the middle request to make a
substituted key look like a deployment without transparency, and a relay
that has answered before cannot stop.
Two bugs, both found by writing the tests rather than by reading:
A disappearing-messages timer never reached the other side. It went out
through `send`, which wraps a body in a TEXT envelope with a message id
so it can be replied to — so the control envelope arrived buried in a
caption, the ttl branch was never reached, and the timer was set on the
phone that set it and nowhere else. It goes through `transmit` now.
And `ctl` decoded as `Int?`, which the Android engine wrote as `true`.
That did not fail to set the flag: it failed the whole envelope's
decode, so an Android user turning on disappearing messages sent an
iPhone a bubble of raw JSON. Both spellings are accepted now.
State that belongs to one relay is keyed by relay: the head pin and the
"has this relay served a log" flag were both global, and a second relay
would have been reported as a fork on the strength of the first one's
root.
New: 6 tests for reading several mailboxes, 10 for automatic key-log
enforcement. Full suite green at 291.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
E2EInbox was at 16% — 2,546 lines, over half the E2E subsystem.
Everything beneath it is well covered and proven in isolation; this is the
first thing that proves the pieces were assembled correctly, which is what
the Android side calls "the test the whole stack exists to pass" — and on
Android that test only runs against a live fleet.
The relay here is a stub URLProtocol holding envelopes in a dictionary,
which is why E2EClient gained a `protocolClasses` parameter alongside
MessengerClient's. The session stays built inside the client because its
configuration carries a privacy decision — responses are never written to
the on-disk URL cache, since envelope traffic persisting in Cache.db after
a chat is deleted is what "deleted means deleted" forbids — so only
protocol classes are injectable. Everything below the transport is real:
real identities, real X3DH, a real ratchet, real sealed blobs.
Writing the stub surfaced its own lesson. My first version answered
from_user: 0 for every envelope, where a real relay records the
AUTHENTICATED sender for an unsealed send. Every incoming message was
then filed under thread 0 — and the history test still passed, because it
flattened every thread and only asked whether the text existed somewhere.
The stub now reads the Basic credential exactly as the relay does, and the
history assertion is keyed by sender so it cannot pass that way again.
The privacy claim is checked against what an operator would actually
hold: the stub keeps every payload it was handed, and none of them
contains the message text.
E2EInbox 16.3% -> 46.5%; iOS E2E subsystem 42.5% -> 60.5%. 265 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SealedSender.swift was at 0%, and it is the feature that decides what the
relay can see. Every property here is one an attacker would probe.
🔴 The seal is confidentiality only, never authentication. Anyone holding
the recipient's public IK_dh can produce a well-formed seal claiming any
sender, so the claim is worth nothing until the inner envelope opens.
That is asserted from the outside: a seal made by a stranger is still a
valid seal, and what stops it mattering is the ratchet and the pin, not
this file.
The AAD binding gets the test its comment asks for. The destination
device is authenticated, so a relay cannot replay one user's sealed blob
into another user's inbox — without it a seal is portable between
mailboxes and sealed sender becomes a delivery primitive for anyone who
can read one. Swapping the ephemeral key or touching a byte of ciphertext
is likewise refused rather than yielding different plaintext.
The privacy claim itself is checked directly: a seal carrying user
424242, device "secret-device-id" and address "whoami@peer.example" is
serialised to the wire and asserted to contain none of them, nor anything
of the inner envelope.
Two seals of the same message to the same recipient are asserted
unlinkable — a reused ephemeral would let a relay group a sender's
messages without ever learning who they are, which is most of what
sealing was for.
And isSealed is pinned in both directions, since the receive path
branches on it: a misclassification either skips the seal or tries to
open a plain envelope as one.
SealedSender 0% -> 83.1%; iOS E2E subsystem 41.3% -> 42.5%. 258 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ring exists because a single slot was not enough, and the failure it
caused is invisible. Rotation is weekly, so one slot holds last week's key
and the week before is simply gone — while the server keeps an
undelivered envelope for thirty days. A sender fetches a bundle, the
recipient is offline for a fortnight, and by the time the envelope is
polled the key that would open it has been overwritten twice. What the
user sees is an AEAD error on the first message, indistinguishable from
tampering, which nobody would read as a rotation problem.
So the property under test is the ring's depth in TIME, not its size: a
key retired three weeks ago still opens, retention is asserted to exceed
the server's own window, and seven weeks of ordinary weekly rotation are
all still openable at once.
The cap is tested for what it is — a backstop, not the bound. Forty
rotations in a loop grow the blob no further than sixteen entries, and
the NEWEST survive, because an old key is the one least likely to still
be named by a queued envelope. Retiring the same key twice does not stack
duplicates, which would fill the ring with one key and evict the rest.
For the one-time pool: consuming a prekey removes it and it cannot be
taken again, since a one-time prekey handed out twice is not one-time and
the forward secrecy it exists for is gone with no symptom. The private
halves are checked to survive the round trip, not just the ids — a pool
that keeps ids and loses key material fails only when somebody actually
opens a session.
And the post-quantum seed is asserted stable across reopens: it is
derived once at sign-in from a passphrase that is never kept, so a seed
that does not persist cannot be recomputed and the account silently loses
its PQ identity on relaunch.
KeychainE2EStore 23.7% -> 70.6%; iOS E2E subsystem 39.8% -> 41.3%.
225 -> 247 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MessengerAPI was at 13%. Its Android counterpart shipped three separate
field-name bugs — the block list reading user_id where the relay sends
id, notifications reading kind/body/read which exist nowhere, and the
roster guessing the user_id shape — and every one failed SILENTLY,
producing an empty list indistinguishable from "nothing to show yet". The
iOS models happen to use the right names. Nothing was checking.
So each decode is pinned against the relay's real shape, and the
Android-era names are asserted NOT to decode, which is what makes the
pairing load-bearing rather than decorative.
Two behaviours are about refusing to guess. A malformed roster surfaces
as an error rather than an empty list, because "you have no contacts" is
a lie a user would act on. And an undecodable status response is an error
rather than an assumed "online" — claiming online because a response
would not parse is the same failure this route already had on the server,
reporting a state nobody stored.
MessengerClient gains a `protocolClasses` parameter, and only that. The
session is still built inside the client because its configuration
carries a privacy decision — urlCache = nil, so messages are never
written to Cache.db — and a caller passing its own session could drop
that without noticing. ⚠️ URLSessionConfiguration.default does not pick
up classes registered with URLProtocol.registerClass, so a custom session
cannot be intercepted globally; without this seam the file is unreachable
from a test.
MessengerAPI 13.2% -> 35.7%, MessengerClient 0% -> 63.3%;
iOS E2E-and-networking subsystem 34.0% -> 39.8%. 194 -> 225 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wipe() had no test, and it had recently gained five secrets it used to
miss. The one that mattered most is the delivery key: it authorises
submission to the inbox on its own, with no token, so a copy left behind
after account deletion is a working key to a mailbox its owner believes
is gone. The address pins were the subtlest — Keychain cannot enumerate
accounts by prefix, so `pinfn.*` items are reachable only through the
index kept for that purpose, and without it they survive every wipe,
invisibly, and are inherited by whoever signs in next.
The obvious test would write those five things, wipe, and assert they are
gone. It would pass forever while saying nothing about the sixth secret
someone adds next year — which is precisely how all five were missed.
So the load-bearing test is generic: populate one of everything, wipe,
then enumerate the Keychain for this account and the account's
UserDefaults persistent domain and assert both are empty. Deleting
`delivery_key` from wipe() makes it fail with
`these Keychain items survived wipe(): ["delivery_key.u909930"]`, which
is the message a future reader needs rather than a count mismatch.
⚠️ The suite's own keys come from `persistentDomain(forName:)`.
`dictionaryRepresentation()` composes the global domain in, so it reports
AppleLanguages and friends and can never be empty — my first version
asserted on it and failed for reasons that had nothing to do with the
code under test.
Round trips for the rest of the store alongside, so wipe is proven to be
removing something real: identity, device id, history key, peer delivery
keys, pins per peer and per address, and the protocol pin's ratchet
(it must never go backwards, or a relay that once proved v2 could
quietly serve v1 again).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
X3DH.swift was at 0% line coverage while DoubleRatchet sat at 72%. The
tests exercised the ratchet given a root key and never the step that
produces one. If the two sides derived different secrets, or the one-time
prekey contributed nothing to the KDF, or the associated data disagreed
by direction, every existing ratchet test would still have passed.
Eighteen tests, all of which run BOTH sides, because the property that
matters is not "does it return 32 bytes" but "do two parties, given only
what the wire carries, arrive at the same 32 bytes".
Covers the audit fix that had no test: the one-time prekey is the single
bundle field carrying no signature, so a relay can swap it freely, and a
low-order point yields an all-zero shared secret that CryptoKit does not
reject. The handshake then desynchronises and surfaces as messages to a
targeted contact silently vanishing — indistinguishable from that person
being offline. Every RFC 7748 small-order point is checked, along with
the other half nobody writes: that fifty genuine keys are NOT flagged,
since a false positive there makes a contact permanently unreachable.
The post-quantum tests skip rather than fail where ML-KEM is unavailable;
a v1-only device is a supported configuration. They ran here, so §12.5's
version separation is exercised: without the KDF info change, v1's
DH1..DH4 and v2's DH1..DH3||SS are both 128 bytes and would be
indistinguishable inputs.
X3DH.swift 0% -> 100%, KeyLog.swift 0% -> 98.9%.
E2E subsystem 30% -> 34.0%; the app-wide figure barely moves because
13,412 of its 29,806 lines are SwiftUI views.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KeyLog.swift was at 0% line coverage. It is the code that decides whether
a key the relay served is the key the log committed to — the whole
substance of the transparency claim — and nothing executed a line of it.
Its correctness was not hypothetical. The relay's own mirror verifier
read the audit path in the opposite order and was wrong for 15 of the 36
leaf positions in a tree of eight, while verifying happily at index 0 at
every size. This file is right where that one was wrong, and it was
right by inspection only.
Twenty tests. Expected roots are the published RFC 6962 vectors rather
than values recorded from this implementation, because a golden file
taken from the code under test proves only that it agrees with itself.
Every leaf position at every size from 1 to 8, substituted keys, tampered
paths, proofs replayed at another index, wrong-length proofs, consistency
across every prefix pair, rewritten history, shrinking logs, heads signed
by the wrong key, and heads lifted onto another tree size.
KeyLog verifies but cannot build a tree, deliberately — a client that can
construct a proof can be argued into believing one it made itself. So the
suite builds the tree, and the RFC roots are what anchor that builder to
the standard instead of to our own opinion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The delete sheet told people "all messages on this device will be removed."
They were not. `signOut()` cleared the credential and the app password and
stopped there, so a deleted account left `history-<uid>.bin` on disk WITH ITS
KEY still in the Keychain, alongside identity keys, ratchet state and pinned
identities. Anyone who later unlocked the phone could read conversations from
an account its owner believed was gone. `E2EHistoryStore` has documented all
along that deletion and sign-out must purge; nothing called it.
Sign-out and deletion now both erase, through one method on the type that owns
both halves so the ordering cannot be got wrong at the call site — history
first, then the store, because the wipe removes the key the history is found
by.
`wipe()` was also incomplete. It missed `delivery_key`, `peer_delivery_keys`,
`proto_pins`, `pq_identity_seed`, `pq_opk_pool`, and every `pinfn.<fnid>`
address pin. The delivery key is the one that matters: anyone holding it can
reach the inbox without authenticating, so leaving it behind left a working key
to a mailbox. Address pins had no index, and Keychain accounts cannot be
enumerated by prefix, so they were invisible to any wipe — there is an index
now, written as each pin is.
⚠️ And sign-out is genuinely destructive now, so it says so. Android has
carried that warning all along; the iOS button was bare, and making it erase
without a word would be destroying conversations at the moment somebody
expected to just step away.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same change as Android, same reasoning: adoption keeps a stranger's first
message visible, which is why it exists, but it also puts someone in your
contacts because they wrote to you. Adoption now sends `auto: true`, and the
thread says so with Keep as contact / Block in it.
Delivery is untouched. This is a label and a choice, not a gate — holding first
messages back is the design that already shipped here as a bug.
Accepting is optimistic and reverts on failure: `Buddy` is a value this screen
only holds a copy of, so the banner clears at the tap and comes back if the
relay never recorded it. A safety control that silently does nothing is the
worst outcome, which is the same rule blockBuddy already follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The signed device build writes derived data to `device-build`, which the
`build/` rule does not match — that matches a directory named exactly "build".
Same trap that staged 3,500 files of index cache once already, one spelling
along.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems, and the second is why the first mattered.
🔴 **It showed a guess.** `chosen` started at `.online` and nothing ever asked
the relay, so the control displayed a default rather than what contacts could
actually see — including after a relaunch, when "appear offline" had been chosen
and forgotten. It reads the stored value now. Omitting `status` on that route is
a READ: it only writes when the field is present and answers with the stored
value either way, so this needs no endpoint of its own and cannot accidentally
set anything.
⚠️ The read is guarded against its own `onChange`. Assigning `chosen` looks
identical to a user picking, so without the guard, reading the status back POSTs
it straight to the relay again on every appearance — a write per screen visit
that can only ever set what was already set.
**And it was unlabelled.** A small grey "Status" footnote over a bare segmented
control, in a screen full of rows. Now a real heading, which is the difference
between a feature existing and a feature being findable.
iOS unit suite 156/156.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`git ls-files` lists only TRACKED files. A brand-new one — which is exactly what
a dropped credential looks like — was invisible to this gate, while `git add -A`
would stage it happily. A planted `sk_live_…` in a new file passed.
It now scans `--cached --others --exclude-standard`: the same set `git add -A`
would stage.
🔴 **My earlier "verified it still fails on a planted key" was testing the wrong
thing.** That probe was `git add -N`'d first, which made it tracked, so it
exercised the path that already worked and reported confidence in the path that
did not. A verification that quietly changes the conditions is worse than none —
it is why this survived being checked.
Also allowlists the two VoIP test fixtures (`alert-token`, `voip-token`) by
literal value. A gate that is always red is a gate everybody learns to ignore,
which is the failure this one was one commit away from.
Verified in all four repositories: an untracked planted key now fails the gate,
and all four are clean without it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
§6.13 shipped the wire format, the poster-plus-URL model, the spec and the
viewer's consent gate — and no way to enter a URL. The receiving half had
nothing to receive. Reported as done; it was not.
Adds the field to the composer on both platforms, with the disclosure said at
the moment of attaching rather than only at the moment of watching: the picture
becomes the cover, and anyone who taps play connects to that site, which sees
their IP address.
⚠️ Post is disabled for a URL that could not be disclosed. A destination with no
host cannot be named, and a viewer cannot consent to one they cannot be shown —
so it is refused at the point of writing rather than silently dropped later.
⚠️ `streamNote` is extracted rather than inlined: a conditional string inside an
already-large ViewBuilder defeated the SwiftUI type-checker outright, the same
way the disappearing picker did.
iOS unit suite 156/156.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`remote-notification` wakes a suspended app briefly to fetch, which is right for
a message and useless for a call — by the time a background fetch has polled,
decrypted and decided, the caller has given up. Ringing needs a VoIP push, so
`voip` joins UIBackgroundModes and PushKit is registered alongside the alert
registrar.
🔴 **A VoIP push is a promise, not a notification.** iOS requires the app to
report an incoming call to CallKit *before the push handler returns*, terminates
it if it does not, and revokes VoIP entirely from repeat offenders. So the call
is reported FIRST and identified afterwards: everything slow — the poll, the
decrypt, working out who it is — happens after the report, because iOS is
watching for the report and not for the reason.
⚠️ **Sealed sender means the name is unknown when the phone starts ringing.**
The push carries nothing; the relay holds ciphertext and could not name the
caller even if that were wanted. The first frame says "Flamenet call", and
`identify(caller:)` fills the name in once the offer has actually decrypted.
Showing an unverified name would be worse than showing none — it is exactly the
surface a spoofed caller would want.
⚠️ **Two tokens, one row.** The VoIP token comes from a different registry than
the alert token and either can arrive first, so each upload carries both and the
relay leaves an absent field alone. A registration carrying one half must not
blank the other, or ordinary messages silently stop waking the app.
🔴 `urgent` is derived from the message CONTENT rather than threaded through
every send path. A flag six callers must remember is one a caller forgets, and
the failure is silent: the call does not ring and nothing says why.
Also stops ringing on sign-out — CallKit holds a call the system believes is
live, and tearing the session down under it leaves the phone ringing for an
account that no longer exists.
iOS unit suite 156/156.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every other media path in this app is a sealed blob — the relay stores
ciphertext and nobody else is involved. A live stream is not that: it is
continuous and hosted elsewhere, so there is nothing to seal, and watching one
means this device connects to that host, which learns the viewer's IP address
and that they watched.
🔴 **§6.12's answer does not transfer.** A link preview works because the sender
can fetch it once and put the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.
The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a story that started playing on its own would make that
disclosure after the fact, which is not a disclosure.
⚠️ The blob is the POSTER, not the stream. Keeping a real attachment means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.
⚠️ The host comes from the URL, never the caption — a story captioned
"youtube.com" pointing somewhere else is the phishing shape link previews
already guard against. Non-http(s) URLs and URLs with no host are refused: a
`file:` URL arriving from another person is not something to hand a media
player, and a destination nobody can name is one nobody can consent to.
iOS unit suite 151/151.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away the thing it was
protecting.
So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.
⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address, before the message goes. That is what the
setting is for, and the copy says exactly who learns what rather than implying
the whole thing is free.
🔴 **The host is shown separately and derived from the URL.** Title and
description come from a page the sender did not write, so a card claiming one
destination while the link points at another is a phishing primitive. The host
is the part that cannot lie, and there is a test asserting it comes from the URL
rather than the page.
⚠️ Both fields are bounded on the way IN, not only at render, so the cap does
not depend on every future renderer remembering one. Parsed with a bounded
regex over the first 64 KiB rather than a real HTML parser: a full parser for
two attributes is a dependency and an attack surface, and giving up is the
correct failure for a decoration.
⚠️ Fetching is time-boxed and best-effort. A preview must never be able to delay
or fail a send — every unusual case ends as a message with a plain URL in it,
which is a perfectly good thing to be.
iOS unit suite 143/143.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay could hold a scheduled message and deliver it on time even with the
phone off. It would then know that a message exists, who it is for, and exactly
when it was meant to land — a richer piece of metadata than anything else this
design gives it, for a convenience. So it waits here, and the UI states the cost
rather than hiding it: "when the app next runs".
⚠️ The banner deliberately does not promise a delivery time. A phone that is off
at the appointed minute has not sent anything, and a line implying otherwise is
exactly the one people would rely on and be let down by.
Rides the existing outbox, so a scheduled message inherits the retry behaviour
instead of getting a second, less-tested delivery path. The flush treats "not
due yet" as keep, never as failed — that loop is also the retry queue, and
conflating the two would drop the message entirely.
⚠️ `notBefore` is optional, so an entry queued by a build that predates
scheduling still sends immediately rather than being held for a date it has no
value for.
iOS unit suite 134/134.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on. Both local to this device on purpose: "I am ignoring this
person until Tuesday" and "remind me if they have not answered" are statements
about a relationship, and the relay has no business holding either. Neither
follows you to a new phone, which is the better half of that trade.
🔴 **A reply cancels its own reminder, on the arrival path.** Done where every
inbound message lands rather than on a screen, so no view has to remember. A
nudge that still fired after they answered is worse than none — it teaches
people to ignore reminders.
Reminders fire from the poll cycle, deliberately AFTER ingest, so a reply that
just arrived has already cancelled its own. Cleared as it fires: a reminder is
one-shot, and leaving it set would re-notify on every cycle for ever.
⚠️ Menus of buttons, not Pickers. A picker needs the current state to be one of
its options and neither of these has that property — a snooze with nine minutes
left is not "1 hour", and labelling it so would be a worse lie than showing
nothing. The live state is a banner on the thread, which can say what is true.
⚠️ The reminder notification honours the preview setting (who you are waiting on
is as sensitive as what they said) but NOT the per-buddy mute: muting silences
what *they* send, while this is a reminder the user asked for about their own
message. Swallowing it would make one control quietly disable another.
iOS unit suite 129/129.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
**Full quality media.** iOS always downscaled to 2048px at JPEG 0.8 with no way
to ask for better — Signal's most-voted open request, and the same limit here.
Now a setting, bounded honestly: the relay refuses a blob over its cap, so
"full quality" means *as good as will fit* rather than "whatever the camera
produced", and `ImagePrep` steps down through size and quality until the bytes
are under budget. A photo that failed to send would be worse than a slightly
softer one, so no path here can produce one the server will reject.
⚠️ The budget is deliberately under the server's cap: the blob is encrypted
before upload and the AEAD tag rides on top of these bytes, so sizing to the
exact limit produces a file that passes locally and is refused remotely.
🔴 The chat composer carried its OWN copy of the downscale-and-encode routine,
identical to `ImagePrep` and one edit from disagreeing with it. Only one of the
two would have honoured the new setting — and the one people use most for
photos is the copy. Deleted; there is one now.
**Devices.** Removing a device was reachable ONLY by swiping the row, with full
swipe disabled. This screen is the single remedy for the three-device cap — its
own comment says it exists so "the limit is 3" is not a support ticket — and a
control you have to already know about is not a remedy. There is a visible
Remove button now; the swipe still works for people who expect it.
Verified the relay side rather than assuming: a device revoking ANOTHER device
on the same account, which is what this screen actually does, had no coverage
at all. The harness only ever tested a device revoking itself. Added, passing.
⚠️ The first ImagePrep test helper killed the test runner outright — four
million Core Graphics fill calls per image. It builds a raw pixel buffer now.
iOS unit suite 119/119.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The edge geo-gate answers 451 for a country the service is not offered in,
before the request reaches the relay. Nothing here knew what 451 meant, so it
fell through to the generic branch and showed the number.
⚠️ **The download site is deliberately not gated**, which makes this worse than
a bad error string: someone can read the page, install the app, choose a
password, tap sign up, and be told "Server returned HTTP 451". Nothing in that
mentions their country, it is not a failure they can retry past, and the
reasonable reading is that the app is broken.
The message also says what it is NOT — not the account, not the connection —
because someone who has just typed a password will otherwise assume they got it
wrong and try again.
iOS unit suite 113/113.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No client could set a per-conversation timer, and for E2E the relay's own
lifetime field would not have helped: it expires rows in the legacy plaintext
table, not the envelopes these apps exchange. So the timer is client-side,
which is also the only place it can be honest.
Symmetric: either party sets it, the other is told inside the ratchet and
adopts it. A timer only the setter honoured would be worse than none, because
they would believe their words were gone from a phone still holding them. The
relay never learns a conversation is timed, let alone for how long.
⚠️ **The timer is parsed BEFORE the generic `ctl` swallow.** This is a control
kind the build handles, and falling into the bucket for kinds it does not
handle would consume it perfectly correctly while acting on nothing — a silent
no-op with no error anywhere. There is a test pinning that ordering.
Expiry runs at launch and on every poll cycle, not only when a thread is
opened: a message still in the store because nobody looked at it is a
disappearing message that did not disappear.
⚠️ Ages come from the date this device recorded — never a clock the peer
supplies. A sender who chose the timestamp could backdate a message so it
vanished from the recipient's phone immediately, or forward-date one so it
never expired at all.
The picker is two taps in the overflow menu and the thread carries a banner
whenever a timer is on. "Buried" was the complaint, and a timer nobody can see
is one nobody trusts — the banner says "on both phones", because that is a fact
about the conversation rather than a preference on this device.
⚠️ `DisappearingPicker` is its own view because inline the SwiftUI type-checker
gave up — a Picker with a computed Binding inside an already-large ViewBuilder.
Extracting it is the fix; shrinking the menu would have moved the problem.
iOS unit suite 110/110.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Control envelopes are matched by name here — `delivery_key`, `kt_head`,
`avatar` — and a control kind from a newer peer matches none of them. It fell
through to the attachment path and surfaced as an "[unsupported attachment]"
bubble: a message the user can neither read nor act on, for something addressed
to a layer rather than a person. Every control kind added from here would have
shipped that to every older install.
Envelopes carrying `ctl: 1` are now consumed whatever their kind.
⚠️ Unknown *content* is still shown with its placeholder, unchanged. The two
rules pull against each other and the receiver cannot guess which applies, so
the sender's flag decides — dropping a real message silently is the one failure
a messenger cannot make.
iOS unit suite 103/103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was none. Presence was reachable only through the chat slash-commands
(`/away`, `/invisible`) — which you have to already know exist — while Android
has offered a row of chips for months. The two apps disagreed about whether this
was a feature.
Adds a picker to Profile, next to your identity, since presence is a claim about
you. Four states matching the relay's vocabulary exactly, each with a line
saying what it does to other people: "Appear offline" rather than "Invisible",
because what is being chosen is what contacts see, not the name of a flag.
`busy` is new here in every sense — it did not exist on this platform. Adding it
to `Presence` broke two exhaustive switches, which is the compiler doing its job:
the presence dot needed a colour (red, distinct from away's amber — two shades
of the same hue read as one state), and the buddy sort needed a rank. Busy sorts
with the reachable states above away, because someone marked busy is present and
reading; sorting them down beside offline would say the opposite.
Also removes `?? "online"` from `setStatus`. Claiming online because a response
would not decode is the same failure the server side of this route already had —
reporting a state nobody stored. It throws now.
⚠️ A custom status *message* is still deliberately unimplemented. The relay
does not store one on purpose: it would be a server-readable line about your
whereabouts attached to your account, which is the metadata this project keeps
removing. It stays out until it can be encrypted to the buddy list.
PresenceTests pins the vocabulary to the relay's — a cross-repository contract
no compiler checks, and the mismatch was the whole bug.
iOS unit suite 100/100.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`startE2E` returns early when `E2EInbox.make` cannot produce a signed prekey,
leaving `e2eInbox == nil`. `ChatScreen` then built the legacy transport, which
POSTs the message body IN THE CLEAR to `flamenet/v1/messenger/messages`, where
the relay seals it with its own key. The server can read those messages. They
are not end-to-end encrypted in any sense.
Nothing told the user. There is no encryption indicator on the chat screen, so
a signed-in person could not tell the two apart — the app silently stopped being
the one thing it promises to be. This is the iMessage green-bubble failure, and
encryption is the product, so the fallback is gone rather than labelled.
Removed the whole path, not just the branch: the legacy `ChatViewModel` init,
its `api` send and poll, `MessengerAPI.send`/`poll`, and the `SendResponse` /
`PollResponse` / `MessagePayload` types that decoded it. A deprecated plaintext
send is one autocomplete away from being a live one. The route still exists on
the relay for other callers; this app no longer has a way to reach it.
⚠️ **A refusal has to be visible or it is just a different silence.** With no
model to build, `ChatScreen` fell through to a bare `ProgressView` — a spinner
that never resolves, which is the same failure wearing a nicer coat. It now
shows what happened, states plainly that nothing was sent unencrypted, and
offers a retry: `retryEncryptionSetup()` re-runs setup from stored credentials,
because the failure is local and often transient (CryptoKit under memory
pressure, a Keychain not yet unlocked after cold boot).
This is safe to remove because the fallback was never for old relays. `make`
returns nil on exactly one condition — local signed-prekey generation failing —
and `Phase` has no plaintext mode. Device registration and delivery-key
publication are already best-effort and do not gate it.
Also adds SystemImageTests. `Image(systemName:)` renders NOTHING for a name
that does not exist — no warning, no placeholder — and the screens most likely
to name an unusual symbol are error states, which nobody sees until something
has already gone wrong. The new refusal screen is exactly that kind of screen.
iOS unit suite 96/96.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`postForm` and `getHTML` were the `admin-ajax.php` and page-scrape primitives.
Nothing has called them since the app was decoupled from WordPress on
2026-08-20; `formEncode` and `escape` existed only to serve `postForm` and go
with them. Their doc comments still asserted "Used by every admin-ajax.php
action", which is the worst state for dead code to be in — it reads as load
bearing.
The file header was worse. The wp- purge did a find/replace over it and left
prose that had stopped parsing as English: "a single the relay site", "the
relay core's `determine_current_user` filter", "No `a session cookie` cookie
needed". This is a public repository, and that paragraph is the first thing
someone reads about how the app authenticates. Rewritten to describe what
actually happens, with a note saying what it used to say and why — the history
is the useful part.
iOS unit suite 95/95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`keyLogHeadPayload` built the §19.4 gossip envelope and nothing called it. The
receiving half was fully wired — a peer's head is compared against our pin, a
consistency proof is demanded, `forkDetected` is set — so transparency
reported working while the mechanism was inert.
That is the failure that looks like success. §19.4 catches a forked relay only
when two clients COMPARE heads; a client that never offers one is a client no
fork can be detected against, and each side goes on confirming the relay is
consistent *with itself* — precisely what a relay serving two self-consistent
branches wants. Only the JS reference sent it, so gossip worked web→app and in
no other direction. Android had the identical dead function and is fixed in the
same pass.
The send path carried a single optional prelude; it now carries a list, so the
delivery key and the head can both ride the first message to a contact. Each
prelude marks only its own bookkeeping — conflating them would mark the
delivery key as delivered on a send that only gossiped. Once per contact per
session, tracked in memory to match the JS reference: a device that pins a new
head after a restart has to be able to mention it.
Also removes `clearAllThreads`. Its own comment claimed "Used by Data & Storage
→ Clear chat cache", and that was false — the button calls `clearAllCaches`,
which is a strict superset (threads, remote threads, group threads, stories,
attachments, the sealed history file, call log, outbox, notifications). Two
functions both presenting as the clear-cache implementation, one wired, is how
a later fix lands in the half nothing runs.
iOS unit suite 95/95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`rotateDeliveryKey` has existed in E2EInbox since sealed sender shipped, with a
doc comment calling it "the only way to cut off a sealed spammer" — and nothing
called it. Android wired the same function into its settings screen; iOS left
it unreachable, so the one lever §14.4 provides against sealed abuse was, on
this platform, not a lever at all. There is no sender named in a sealed
envelope, so blocking is not available as a fallback.
Adds the control to Privacy & Security next to key transparency, mirroring
Android's placement and its wording, behind a confirmation because the cost is
real and irreversible: every contact loses the ability to reach you sealed
until you next message them. The footer says that in the UI rather than
burying it.
Reports the publish outcome honestly. `rotateDeliveryKey` discards the old key
locally and then tries to publish the new digest; a failed publish is retried
next launch. So a rotation whose publish did not land still cut the spammer
off, and the app says the key will publish on the next connection and that
messages go out unsealed until it does — rather than claiming it is done.
`deliveryKeyPublished` is exposed read-only for that, since the store is
private.
Also ignores `ci-build/`. The test command this repo documents passes
`-derivedDataPath ci-build`, and the existing `build/` rule does not match it
— 3,500 files of Xcode index and compilation cache staged themselves on the
first commit after running it. Drops the `server/`, `android/` and `engine/`
rules, which have pointed at subtrees that do not exist since the split.
iOS unit suite 95/95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Everything from "The three engines" down still described the pre-split layout:
it pointed at `engine/src/`, `android/engine/` and `server/`, and told a reader
to run `cd server && swift build`, `cd ../engine && npm test`. None of those
paths have existed since 2026-08-28. The build instructions on the front page
of a public repository could not be followed.
Also wrong, and worse: "What is published, and what is not" claimed this app is
not published and is mirrored out by an allowlist. All four repositories are
public — verified against the API, not assumed — so that section described a
privacy property the project does not have. It now says so plainly, and states
the constraint that follows: no keystores, no passphrases, ever, history
included.
Smaller corrections: the XcodeGen link pointed at a user who does not host it;
the Kotlin engine is a complete engine now, not "safety numbers and key
transparency only"; and `server/DEPLOY.md` is `docs/HOSTING.md` in its own repo.
Adds CONTRIBUTING, and the secret-sweep gate the other three repositories have
and this one did not — with the three UITest password fixtures allowlisted by
literal value. Named individually and not by directory: excluding UITests/
wholesale is the easy fix and makes the sweep blind to a real credential
sitting there. Verified it still fails on a planted key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
loadAvatars assigned a freshly built dictionary, so a refresh made while
the roster was momentarily empty — a fetch in flight, or the contact list
loading from the sealed store — wiped every picture from the UI at once
while the refs sat safely on disk. Absent from the store means 'not
loaded yet', never 'deleted'; only setAvatar(nil) removes one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
allSatisfy on an empty array is vacuously true, so a directory answering
with NO keys for a pinned contact read as 'unchanged' — the strongest
verdict — when it had said nothing. It reports unknown now.
The attachment id arrives from a peer and is interpolated into a URL
path; the relay refuses anything but 64 hex and the client did not.
And the one-time prekey is the only bundle field carrying no signature:
an all-zero or small-order X25519 point yields an all-zero shared secret
that CryptoKit accepts, desynchronising the handshake into a silent AEAD
failure — messages to a targeted contact vanishing, indistinguishable
from them being offline. Refused explicitly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 60 digits are the one check that does not trust the relay, and they
covered only the Ed25519 key. An adversary able to forge Ed25519 — the
exact adversary the ML-DSA identity was added to stop — could keep the
real signing key so the number still matched, while substituting the PQ
identity at first contact. The PQ authentication was defeated invisibly.
Both keys are now folded under their own label when BOTH sides publish a
PQ identity; a classical pair keeps the number they have already
compared. The profile says which guarantee the digits cover, because a
PQ number and a classical one must not look alike. The exact value is
pinned against an independent implementation of the rule, so iOS and
Android cannot drift into showing two people different numbers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay signs every head, and nobody checked it — so a fork caught by
gossip was one party's word rather than a statement bound to the relay's
own key. The verifier already existed and was never called, and no
client held a key to call it with.
The issuer key is pinned on first sight, the way identity keys beside it
are: taking it fresh from the same relay each time would let that relay
serve whichever key matches what it just signed. A head whose signature
fails under the pin is a fork. A relay that publishes no key is treated
as weaker, not hostile — refusing it outright would be a worse failure
than the one being prevented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The envelope carries the blob's size and it arrived through the ratchet,
so the relay cannot inflate it — which makes it usable as a ceiling. The
download read whatever the relay sent and only checked the digest once
the whole thing was resident, so a malicious relay answered a photo
request with a multi-gigabyte body and killed the app before the check
it would have failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
H1: a group message was filed after checking only that WE were in the
group — so anyone we share a session with could post into it, and a
member we removed kept posting, their 1:1 session outliving membership.
The sender must be on the roster too.
H2: enforceKeyLog had zero call sites — the transparency log constrained
nothing on iOS, because nobody ever asked it for a proof. It now runs on
the bundle-fetch path behind a persisted Privacy toggle, with the fork
short-circuit Android already had, and the setting explains its own cost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ratchet proves which DEVICE wrote a message; the user id inside a
sealed envelope is a claim its author chose, and nothing bound the two.
A contact could encrypt on their own session and stamp any other user's
id on it — the message filed and named as that person, and worse, their
delivery key overwritten, which forces the next send UNSEALED and hands
the relay the sender sealing exists to hide.
Devices now bind first-use-wins (the pattern already used for federated
addresses), the claim is checked against the binding on every decrypt,
and control traffic — delivery key, avatar — is refused from a sender
whose claim did not match. Accepting an identity change re-binds, so a
genuine reinstall is never mistaken for an impersonator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press a message for Delete for me (Unsend on your own) — it removes
the message from this device's sealed history and persists the removal.
Group chats gain the context menu they lacked entirely. Leaving a group
already existed and stays. Delete for me is honest about its scope: the
other person's copy is theirs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poll loop now runs a throttled key-log consistency sweep (one head
an hour, best-effort), so a relay that forks or rewinds its log between
bundle fetches is caught on an established conversation instead of never.
And the changed-keys banner names what happened — 'A new device key was
added on <date>' — read from the log, which already knew. The formatter
is pure and unit-tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A project runner on the development Mac, tag mac-local, concurrency one.
UI tests stay manual — they need a relay and simulators.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The roster mirrors into the sealed history snapshot (riding the D4
export), and Privacy & Security gains the one-way door: 'Take my
contact list off the server' deletes the relay's copy, after which adds
and removes are local-only. The costs are stated where the button is —
presence goes dark, and a new device restores contacts from your
encrypted backup rather than the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notifications: the icon badge reflects unread on backgrounding and
clears on return — without APNs that is the honest ceiling; the push
path updates it live once the relay has a key.
Video stories: the picker takes movies, AVAssetExportSession brings them
to 960x540 mp4 under the attachment cap, capped at 30 seconds, and the
viewer plays them for their own length. The wire's dur field now travels
for any attachment that has one, not just voice notes.
Parental controls: a parent's PIN guards adding contacts, the stories
surface, and calls — enforced at the manager and the tab bar, not just
the buttons; incoming calls answer busy. An age gate at sign-up checks a
birth year on-device and forgets it. None of it touches the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Answered, but ICE never found a path — no TURN, hostile NAT — used to
leave both screens in Connecting until someone force-quit. The connect
timeout mirrors the dial timeout: give up out loud, tell the peer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay keeps no history, so a new device starts empty; this is the
designed remedy rather than a workaround. The snapshot the store already
seals per-device is re-sealed under a passphrase-derived key (PBKDF2
600k rounds, AES-256-GCM) into a file the user parks wherever they like.
Import merges — dedupe by content identity, never row id, so the same
backup twice adds nothing and nothing already on the device is lost.
Merge logic is pure and unit-tested; the passphrase is stored nowhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Simulator's audio stack times out bringing up the voice-processing
unit and AudioToolbox answers with abort() — so a call, either direction,
killed the app. On simulator builds WebRTC now runs manual audio that is
never enabled: calls connect silently there. Devices are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The authenticator concluded 'this deployment has no token endpoint' from
a single 404 — but a reverse proxy answers 404 for every route while the
relay behind it is down, so an outage latched the client into sending no
Authorization header at all, forever. invalidate() now clears that
conclusion too: the 401 that triggers it is proof tokens are required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poll's 410 (device_revoked) stops polling, purges the sealed
history file, wipes the keychain store — the device id is dead
server-side, so the next sign-in registers a fresh device — and signs
out with a sentence that says what happened. Without this, a device
removed from the Devices screen sat on stale ciphertext, polling a
mailbox that no longer exists, and its 401s eventually told the user
to "sign in again" as though nothing had been decided about it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An UNSEALED envelope from a contact we have told our delivery key means
their copy is gone — a reinstall wiped it. Sealing to us requires that
key, and a peer who holds it seals everything; until they get it back,
the relay sees their sender on every message they write us. The ingest
path now un-marks such a peer (the sender on an unsealed row is
relay-vouched, so acting before decrypt is safe) and the next send
re-tells the key. Same staleness class, and same cure, as the avatar
marker. Worst case: one redundant control envelope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MessengerError grows rateLimited(retryAfterSeconds:) — the E2E client
reads the relay's new Retry-After on 429 and the copy becomes "Too many
attempts. Try again in about N minutes." instead of "Server returned
HTTP 429." A refusal with a clock is the difference between a person
who waits and one who hammers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Account → Registered devices: every device on the relay's roster for
this account, oldest first (the forgotten reinstall is the one worth
removing), each dated from the directory's new created_at and the
current install badged. Swipe removes any device except the one in
hand — that is Sign Out, which also wipes local keys — behind a
confirmation that says what deletion means: undelivered mail gone, no
undo. The relay caps accounts at three devices, so this screen is the
only way off the cap when registration refuses a fourth.
E2EClient grows revokeDevice (DELETE /e2e/devices/{id}); the device
wire row decodes created_at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- BuddyRow held the inbox as a plain let: SwiftUI compared the row's
inputs — same Buddy value, same object REFERENCE — and skipped the
body, so the Messages list re-rendered around rows memoized stale. A
new message updated nothing until navigating away and back rebuilt
them. (The avatar always updated live because AvatarView observes the
inbox itself — that asymmetry is the tell.) @ObservedObject now.
- ChatView, GroupChatView and RemoteChatScreen scroll to the last
message on APPEAR, not only on arrival — onChange fires when the
count changes, so a thread with history opened at the top with the
newest message below the fold. The first jump is unanimated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The Messages list badges E2E arrivals now (inbox.unreadPeers): the
server's unread count only knows the legacy plaintext table, so
encrypted messages never marked anything new. An open chat clears its
peer immediately, and again as messages land while it is on screen.
Session-local by design — a relaunch starts clean.
- StoryComposerSheet grows a visibility picker under the caption:
Everyone / Only… / All except…, with a member checklist for the two
narrow modes. Seeded from the account-level audience and never written
back to it — one photo for close friends must not silently change who
sees everything after it. Both entry points (strip, Stories tab) pass
the chosen audience to the post.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Seed localIDCursor past restored STORY ids too, and heal persisted
collisions — a story received after relaunch could reuse a restored
story's id, and the older card vanished when the feed re-diffed.
- Group chats name authors from the buddy list (knownName) instead of the
relay roster, which carries no names — "Member #54" is gone from the
thread, the member line, the info sheet, previews and notifications.
- Messages tab observes the inbox (MessagesListContent) — it read the
nested ObservableObject through the environment and never re-rendered,
so new messages moved no row and updated no preview.
- avatarShared marker stamps the peer's device set, not just the picture:
a bare ref.id marker outlived their reinstall and they saw initials
for ever. Old markers mismatch once and self-heal with one resend.
- Your own story card carries your own picture (myAvatar).
- Home toolbar gains + (AddContactSheet: look up, then add; an address
hands off to the search path); Messages toolbar gains the same + and
scan pair, routed to Home via pendingAddress so the cross-relay
warnings are never skipped. Sheet gets an opaque ground.
- Groups heading printed twice on Messages — GroupsSection's own header
stands alone now. New-chat picker beside the Chats heading.
- QR payload appends ?u=<handle> so a same-relay scanner can resolve the
account through the ordinary username lookup; decode ignores it.
- Air between story cards, carried on the card so Home and the tab match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/MIGRATION.md replaces the roadmap's hand-wave with what the code and the
live fleet actually show. Identity claim, signed home records with a serial
that must advance (rollback is the attack a signature alone does not stop),
the publishing-relay-must-be-listed guard, verifiable reads, and sender-side
fan-out with duplicate collapse - all implemented, and multihoming.mjs proves
the lot against the live fleet: 12 passed, 0 failed, run today.
The insight that shrinks the feature: a phone that moves relays keeps its
device identity, so contacts' pinned keys still match and the identity-changed
alarm does not fire. Migration collapses to sign-in-elsewhere + claim + publish
an updated record. What remains is client work only, ordered: publish homes
(neither app can), "add a relay", resolution fallback, "leave a relay".
Also states what migration is NOT before marketing does: a new phone is a
device event and must keep tripping TOFU; key rotation stays refused until a
verifiable migration record exists; and "your account survives your server" is
not claimable until the client half ships - the same overstatement discipline
as the federation row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rotation's first attempt created accounts against the RELAY - the mistake
DemoAccountSetupUITests' own header warns about, made a third time while that
very file was open. relay.flamenet.io is a pinned host: the app authenticates
against the shared account service with a passphrase-derived secret and never
calls the relay's /signin, so those accounts answered 200 to curl and "handle
not found" to a person holding a phone. A reviewer would have hit that wall.
The replacement pair burned two more handles to a second cause found only by
peeling layers in order: XCUITest was dropping characters while typing into
SecureFields, so UI-provisioned accounts stored passphrases nobody knows. What
finally isolated it, each proven while the UI run still failed: the KDF
(byte-compared against an independent python implementation - identical), the
service (200 with the derived secret), and the app's own client stack (a
unit-test probe, passphrase as bytes - 200). Then the UI suite ALSO went four
consecutive runs green before going reliably red with no relevant change,
which pins the one remaining link: keyboard input on the iOS-27-beta
simulator. A reviewer types by hand and is unaffected. Documented at the point
of future confusion, so the next red does not restart this investigation.
flamenetreview1 and flamenetreview2 are registered on the account service with
the real passphrase (provisioned deterministically, not via the flaky
keyboard), verified server-side, via the app's stack, and through the app's
real screens. New gated tests: DemoAccountProvisionUITests (opt-in, creates
real accounts) and SignupSigninRoundTripUITests (opt-in diagnostic that
separates "stale account" from "broken app" in one run).
Also fixed on the way: the second-iteration failure that wore five disguises -
relaunching XCUIApplication inside one test leaks system state into the next
iteration, so the setup test is now one account per test method.
Diagnostic tests that embedded live or throwaway credentials are deleted, not
committed. The demo passphrase appears nowhere in this tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fanOut swallowed per-member failures and threw only when nobody was reached.
That is BOTH failure shapes the outbox exists to close, in one function: a
group whose members were all signed out lost the message silently - nothing
appended, nothing queued, the sender's own thread showing they never spoke -
and a partial send never retried the members it missed.
Android's rules, ported: fanOut returns who it reached and which members are
queueable; exactly the unreached members are queued, each entry carrying the
gid whose bubble it restates; and the bubble flips to delivered only when the
LAST queued member is reached. Only prekeyMissing is queueable - no registered
device is cured by coming back, while an identity or trust failure is not
cured by waiting, and retrying it forever would hammer a door locked for a
reason.
Group images take the same path, with the blob uploaded once and the queued
entries carrying the reference, not the bytes. The group bubble renders the
pending label for free: it projects into the same MessageBubble the 1:1 path
uses, and the projection was dropping delivery (and sticker) on the floor.
Restating matches on the timestamp the send stamped, as Android does, because
group messages carry no mid. New snapshot fields are optional like every one
before them; the old-entry test now also asserts a missing gid reads as a 1:1
entry rather than a broken one.
71 iOS tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
android/, server/ and engine/ are gone - 234 files. Each already lives in its
own repository with its own history (the subtree splits preserved it), its own
pipeline, and now every fix that had landed on a subtree after its split was
cut. There were three such races, found by diffing the final subtree state
against each standalone repo and by actually running the cross-repo suites in
the new layout rather than assuming the split was clean:
- flamenet-e2e was missing the harness half of the signup-limit fix, the
interop always-rebuild, and the RelayDiscovery build dependency
- flamenet-relay was missing the server half of the signup-limit fix - the
conformance suite failed against it exactly the way CI failed this morning
- flamenet-messenger-android was missing the README rewrite, so it still
opened with "Nothing here is an app yet"
All three ported, all three verified: interop 92/0 and conformance 253/0 run
from sibling checkouts, which is the shape they run in from now on.
The pipeline here shrinks from seven jobs to the two that genuinely need a Mac
- both still declared `when: never` so their absence from CI stays visible.
The relay's §8 conformance moved to flamenet-relay's own pipeline rather than
dying with the split. Scripts that drove the Android checkout now find it as a
sibling (FNMSG_ANDROID_DIR overrides). The mirror jobs are gone: there is
nothing left to mirror, and a patch to a subtree here has nowhere to go
because the subtrees are gone.
Untracked leftovers (the local server/.env, build output) were moved to
Code/_to_delete/flamenetmessenger-subtrees-20260828, not deleted.
⚠️ Operational consequence: the production deploy used to rsync server/ from
this checkout to /srv/prod/messenger/src. Future deploys rsync from a
flamenet-relay checkout instead - same command, different source directory.
The iOS app builds without the subtrees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code sweep of the split repos turned up one apparent anomaly:
flamenet-e2e contains a Swift file (test/interop/main.swift). It is not a leak -
it is the interop harness, deliberately published so a self-hoster can check
the JS engine against the real iOS one rather than take agreement on trust.
But it cannot build from that repository alone: build.sh compiles it against
the iOS app's Sources/E2E, which is a different repo. The behaviour is correct
- interop.mjs skips cleanly without the app, and build.sh exits with a clear
message - and the top-level README documents it. The gap is only that someone
who clones flamenet-e2e and opens test/interop/ meets Swift with no explanation
until they read two pages in.
A README in that folder closes it: what the driver is, that it builds from the
app repo not this one, and that skipping is deliberate rather than broken. No
code change - the harness was already right.
Sweep result otherwise clean: android is Kotlin-only, relay is Swift-only,
e2e is JS plus this one intentional harness. Verified from fresh clones of all
three, not the working copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite built the Swift driver only when the binary was missing, so a
five-day-old interop-swift ran on every invocation since. Today it failed
loudly - a harness command whose arguments had moved, erroring on a field name
the current source does not even contain - but it would just as happily have
PASSED against five-day-old engine code, which is the worse outcome: a
tiebreaker between three engines that silently tests an old one of the three is
not a tiebreaker.
Now it always rebuilds (~20s), and the build gained
Sources/Auth/RelayDiscovery.swift, which SiteConfig grew a dependency on since
the harness was last compiled.
With a fresh driver the full suite is green: 92 passed, 0 failed, including the
§19 cross-engine key-transparency checks that appeared to be failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prompted by one stale row - SELF-HOSTING.md calling flamenet-messenger-android
"The Kotlin engine. Not an app yet" - and the sweep found the same staleness in
five files, some much worse:
android/README.md opened with "Nothing here is an app yet" above a tree
containing the finished Compose app, and listed X3DH, the Double Ratchet,
sealed sender, the relay client and "the app itself" as Not started - all of
which shipped. It described the repository as it was when the engine landed and
nothing updated it as the app grew around it. Rewritten to point at PARITY.md
as the definition of done rather than duplicating a status table that rots.
engine/README.md said "The server is here too, in server/" - describing a
directory that no longer exists in the flamenet-e2e mirror since the server
moved to its own repository this morning. AUDIT-SCOPE.md and SPEC.md pointed
auditors at server/ paths in a repo that no longer carries them. All now point
at flamenet-relay.
Root README: android/ is the app, and the published-repo table lists all three
mirrors rather than two. server/README.md's opening sentence was also circular
("as a standalone service, so the relay is a standalone service") and now says
what the thing is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay job passed on every commit before my push and failed on it. My fault,
and a foreseeable one: relay.mjs drives §8 against a relay it spawns and creates
EIGHTEEN accounts doing it. A hard limit of five per IP turned the sixth signup
into a 429, and the suite died at a poll that found no message — a failure that
reads as a protocol regression rather than as the gate I had just added.
Every other limit in Config is already an env var and this one was not. That is
the whole of the mistake. FN_SIGNUP_MAX_PER_HOUR, default 5, and the harness
raises it for itself: the limit is real and worth having, so the test that needs
more accounts than a person does says so rather than the production default
bending to it.
AccountRoutes takes the value rather than the whole Config — threading the
config in would make every future limit look like it belongs there too.
Verified by running the suite that failed: 253 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Production had no signup rate limit at all until now. Deployed: the limit, the
password-reset token sweep, and the retention logging. Verified live - five
signups succeed and the sixth returns 429.
🔴 Correcting myself: moving the retention log from .info to .notice did NOT
make it visible, and I said it would. swift-log orders notice BELOW error, and
production runs FN_LOG_LEVEL=error, so the line was still suppressed. The level
is now `notice`, which is the useful setting rather than the loudest one -
`debug` logs request URLs and SQL, and a protocol URL contains a device id.
And the moment the line could be read it showed a bug in itself:
retention swept [attachments: 0, delivered: (), messages: 0, undelivered: ()]
Fluent's delete() returns Void, so the two numbers that matter most were empty
tuples. The message paths already counted before deleting and the envelope
paths did not - invisible for exactly as long as the line itself was invisible,
which is the whole argument for logging it at a level somebody reads. Now
reports real counts.
⚠️ Verifying the rate limit created five real accounts on production. Deleted,
along with their credentials. A pre-deploy database backup was taken first,
matching the existing pattern in that directory, and the hand-made .bak source
copies on the box were excluded from the sync rather than swept away by
--delete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⚠️ Two, not six. I said six. The leaked value opened only appreview1demo and
appreview2demo - the fndemo* and fnreview* pairs use a different passphrase and
were never reachable with it. Recording the correction rather than quietly
narrowing the claim: the first count was wrong in the alarming direction, and an
overstated finding costs credibility on the next one.
Removed through the app's own DELETE /messenger/account route rather than by
hand in SQL, because that route walks a graph: devices before prekeys, so a
crash cannot leave a device advertising prekeys that no longer exist, then
envelopes in both directions, groups owned and joined, and the buddy edges
pointing back at the account. A DELETE by hand gets some of that and looks
finished. Verified: the old passphrase now returns 401 on both.
Four superseded review accounts remain. They are cruft rather than exposure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 The find: the secret sweep was failing closed on false positives, so the
mirror job refused to publish. flamenet-e2e has been stale since 2026-08-21 and
flamenet-relay since 08-25, and this is why. A gate that blocks every release on
nothing is a gate somebody switches off, and then it is not a gate.
Two exclusions, neither weakening the shape probes. Interpolated values -
`let token = "\(selector).\(secret)"` builds a string from variables and has no
literal to leak. And `sweep:allow`, an explicit annotation for genuine
exceptions, deliberately visible so an exception is written on the line it
applies to rather than hidden in the sweep script.
⚠️ It must be on the SAME line as the match. I put one on the line above first
and it silently did nothing - documented, because the next person will do the
same.
The test fixture that tripped it sixteen times is now one named constant, which
is better code regardless: one place to change instead of sixteen.
Pipelines for the three mirrored repos, each of which is a mirror and therefore
needs its own - a green pipeline upstream proves the code passes THERE, next to
siblings it does not ship with, and says nothing about whether what landed is
complete. Each has a `complete` job asserting the shape of the tree, because
what a mirror gets wrong is missing files, not failing logic: a test that cannot
import a module is a test that does not run, and a partial copy passes
everything.
And the mirror job whose absence let flamenet-messenger-android rot. It was a
hand-made extract carrying only :engine, from before :client, :app and :interop
existed; its settings.gradle.kts still declared one module while this tree
declared four. Its `complete` job now fails on exactly that drift.
Upstream also runs :client:test, which was never in CI - the module where the
silent message loss lived, whose regression test is worth nothing unless
something runs it. And the server is no longer copied into flamenet-e2e as
well: two public copies of one server means one of them is stale with no way to
tell which.
All four validate against GitLab's linter. Engine offline tests: 87, 32 and 124
passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The demo passphrase was committed, later removed from the tree, and remained
readable in three commits on origin/main of this repository - which is PUBLIC.
Because an fnid is derived from the passphrase, that value was not a password
anybody could reset: it WAS those accounts, on every relay, permanently, and
they are the accounts Apple's reviewers use.
Replaced rather than rotated, on the owner's call. Rotating changes the
passphrase and therefore the fnid, which leaves the pair unable to recognise
each other and showing "identity changed" - the state that has already made a
contact permanently unmessageable on Android once. flamenetdemo1 and
flamenetdemo2 are created, signed in, and verified against the live relay; the
new passphrase exists only in the password store and CI.
Also fixes drift nobody had noticed: TESTFLIGHT.md named fndemo1/fndemo2 while
the tests used fnreview1demo/fnreview2demo. Two documents describing "the demo
account" and disagreeing about which one. There are eight such accounts on the
relay across four generations; the six older ones are listed in TESTFLIGHT.md
for deletion, since the burned passphrase still opens them.
And the comment in .gitlab-ci.yml said the iOS app "stays in this private
repo". It is not private and has not been for some time. That comment was
load-bearing - the allowlist's private-by-default reasoning rested on it - and
believing it is how a secret gets committed here on the theory that only the
mirror publishes. A secret committed here is leaked at the moment of the
commit.
⚠️ My own contribution: I wrote the burned credential verbatim into
docs/ROADMAP.md while documenting the need to rotate it, putting it into three
more commits. Redacted, and that section now says why the value is deliberately
absent. The secret sweep would NOT have caught it - it matches shapes
(sk_live_, glpat-, assigned password = "...") and a credential in prose matches
none of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three rows moved, and two of them moved because of work done since the tag
rather than because the tag was wrong.
Item 3 - cross-relay. The relay half re-run against the live fleet today: 11
passed, 0 failed, unchanged. The client half is no longer "neither app" - iOS
gained remoteDevices, remoteBundles, sendRemote, transmitRemote, deliverRemote
and a separate remoteThreads keyed on fnid@relay. Android still calls none of
it. Marked "built, not proven": the iOS path compiles and its tests pass but has
never been driven across a real relay boundary from the app, and the previous
row looked better than it was for exactly that reason - a Node harness passing
is not a product working.
Item 8 - deployed. The running container was started before the day's relay
work, so production does not have the signup rate limit, the reset-token sweep
or the retention logging. Also recorded: production runs FN_LOG_LEVEL=error,
which suppresses the hourly retention line completely. Retention IS working -
verified by querying the database and finding zero rows past any window - but
that cannot be seen from the logs, which is the same unverifiable claim the
retention code says it exists to avoid.
New section for what turned up after the baseline: the Android silent message
loss and its fix, the Android to iOS call failure with what has been ruled out
and what was actually found, and the iOS history store that had been written
and never merged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the four privacy features Android was missing that iOS already had.
The sealed history file protects a phone that is off or locked; it does nothing
about the phone somebody just handed to a friend. iOS asked for Face ID; on
Android anyone holding an unlocked handset read every conversation.
Biometrics OR the device credential, deliberately - a phone with a PIN and no
fingerprint reader should still be able to use this, the same reason iOS asks
for deviceOwnerAuthentication rather than biometrics specifically. Offered only
when the device can actually authenticate: a switch that turns on and then
cannot show a prompt locks somebody out of their own messages with no way back.
Two traps worth naming. MainActivity had to become a FragmentActivity -
BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently
cannot show and the callback never fires, which looks like a broken lock rather
than a missing base class. And the gate wraps Root rather than drawing over it:
composed underneath, the message list still renders for a frame, and a frame is
all a screenshot or a shoulder needs.
The lock screen deliberately says nothing - no sender, no preview, no count. A
lock screen reporting "3 new messages from Dana" has given away the two facts
that usually matter most.
The setting states what it does NOT do. This stops someone who picks up your
unlocked phone; it adds no encryption, because the vault and ratchet keys are
already sealed by the keystore either way. Claiming more would be selling a
screen as if it were a key.
Android engine, client and app suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The machine is free (load 584 -> 6 once the idle iOS-27 simulator was shut
down), so the work committed unbuilt is now actually built.
One real error surfaced: Retention referenced `PasswordReset`, and the model is
`PasswordResetToken`. That commit could not have compiled - which is exactly
why it said so rather than claiming otherwise.
All three components green:
Android builds, engine + client + app suites pass
iOS builds, 71 tests pass
relay builds, 16 tests in 5 suites pass
⚠️ Correcting myself twice on the relay suite: `swift test` under /usr/bin/swift
fails to build Vapor's own VaporTesting ("no such module 'Testing'"), and under
the Xcode toolchain it reports "Executed 0 tests" - both of which look like a
missing suite. Neither is. The tests are swift-testing style, so the XCTest
runner counts none of them; the real result is 16 passing. Worth knowing before
somebody concludes the relay is untested and writes a second suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⚠️ NOT BUILT. The machine has been at load 550-640 for three hours with 180
iOS-27 simulator processes on it; xcodebuild was killed rather than failing on
its merits. Committed so the work is not lost, and must be compiled before it
is trusted.
FLAG_SECURE on Android. Blocks screenshots and recording, but the half that
matters is the recents thumbnail: without it Android captures the open
conversation and WRITES IT TO DISK, so a thread existed as a plaintext bitmap
outside the ratchet, the sealed history file and the device-only key. iOS has
had the equivalent since app lock landed; Android had nothing.
iOS notification previews now default to OFF, and the sender is hidden too.
Correcting what I told the owner earlier: Android does NOT leak notification
content - it shows "New message" and never the sender or body. I misread the
count string for the message text. iOS was the leaky one, and worse than it
looked: content.title was set to the sender unconditionally, so turning
previews off hid what was said and still announced who said it. On a lock
screen that is often the more sensitive half, and the setting was labelled
"Show previews".
No incognito-keyboard flag, deliberately: Compose has no stable API for it, and
FLAG_SECURE already makes most keyboards including Gboard suppress
personalised learning. A second mechanism that might be a no-op is worse than
the note.
docs/PADDING.md designs envelope padding rather than implementing it. It is a
wire-format change on a live protocol; the group-decode mismatch in this
repository's history is what that looks like when it goes wrong. Includes the
three-stage rollout, why stripping cannot be inferred, and why the relay needs
no change at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The store came back from a parked branch with no test at all, and "it builds"
was the only evidence for it - which is the state it was in when it got lost
the first time.
Five tests. A thread written and read back keeps its text, its sender and its
delivery state. Calls, the outbox and the story audience all survive, which is
what makes the outbox promise true across a relaunch rather than only while the
app is running. Purge removes the file rather than only the memory. A snapshot
does not open under a key it was not sealed with - the "lose the device, lose
the key" design only holds if that is actually true.
And the one that mattered most: a snapshot in the OLD shape, with calls, outbox
and storyAudience absent entirely, still decodes. Three commit messages claimed
those fields had to be optional because synthesised decoding throws on a
missing key rather than using a default. If that reasoning had been wrong,
every snapshot written before those fields existed would fail to decode and the
history would be silently discarded on the next launch. Now checked.
⚠️ What this does NOT cover: that E2EInbox restores from the store on init. The
store round-trips; the wiring around it is still only read, not exercised.
65 iOS tests were already green before this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android's Session.sendCallSignal called the send path and threw the result
away. That result is the same Sent every other send returns - how many devices
took it, which failed - so an offer that reached NOBODY was indistinguishable
from one that rang. The caller watched "Calling..." for a signal that had gone
nowhere, and neither device had anything to say so. The same shape as the
message-loss bug fixed earlier today, on the one path where the UI never shows
a delivery banner because a call is placed from inside a WebRTC callback.
Also instruments the drops. An incoming offer can be discarded in five places
in iOS's CallManager - already-ended, older than the dial timeout, busy, wrong
state, unknown id - and every one was a bare `return`. Working out which of the
five happened costs more than the logging does. Same treatment E2EInbox.dropped
already gives ingest, and for the same reason.
Not a fix for "Android calls iOS and nothing happens" - I have not reproduced
that. It rules out and instruments: the wire shapes match, iOS builds its
CallManager eagerly at sign-in so onCallSignal is always hooked, Android sets no
from_addr so the federated fork is not swallowing it, and every clock (mac,
host, relay, db) agrees within a second so the staleness check is not
misfiring. The next attempt will say where it stopped instead of nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AIM/ICQ bridge moved to its own product; this screen was the last of it
here. Gone: both views, the settings row, the three API calls, and the two
response types.
Buddy.screenName stays. The relay still sends it, the buddy list still searches
on it and falls back to it as a preview line - removing the connect screen does
not remove the field.
The relay routes are untouched: other clients may still call them, and deleting
a server route because one app stopped using it is somebody else's outage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same three modes as Android, same storage posture, same enforcement: the
audience decides who is SENT the bytes. A story is sealed per recipient through
the ratchet, so somebody outside the list is never sent it - there is no copy
on the relay for them to be denied.
The saved list is intersected with the live contact list in every branch,
including "only chosen", so an id left behind after that person was removed as
a contact cannot resurrect them as a recipient. The screen says what will
actually happen, resolved the same way, and a post that would reach nobody is
refused rather than quietly succeeding at nothing.
⚠️ CombineLatest tops out at four publishers, so the audience is folded in with
the outbox rather than adding a fifth - noted in the code because the shape
looks arbitrary otherwise.
The snapshot field is optional like the two before it: synthesised decoding
throws on a missing key rather than using a default, so a non-optional field
would make every snapshot written before it fail to decode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three modes, stored in the vault, applied at post time.
🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is
sealed to each recipient's devices through the ratchet, so somebody outside the
list is never sent it - there is no copy on the relay for them to be denied.
Every mainstream product holds one server-side copy and decides who is shown
it; this cannot work that way even if somebody wanted it to, and that is the
feature rather than a limitation.
Which also answers "make sure stories are only visible to friends": they
already were, structurally. There is no public audience and there cannot be
one - a story has to be encrypted to somebody in particular.
⚠️ The saved list is intersected with the live contact list in every branch,
including "only chosen". An id left behind after that person was removed as a
contact must not resurrect them as a recipient.
The screen says what will actually happen - "your next story goes to 3
contacts" - resolved the same way, and refuses a post that would reach nobody
rather than silently succeeding at nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Send, receive, file, and draw bare - no bubble, for the same reason as Android:
a glyph inside a grey rounded rectangle reads as a broken image. The pack is
read from the shared manifest rather than hard-coded, so neither client can
drift from the other about what exists.
A second composer control rather than a tab inside the emoji one, and the two
are mutually exclusive. An emoji goes INTO the draft, so its panel stays open
on pick; a sticker IS the message, so its panel closes. Behind one button they
would imply they behave the same way.
Two things found while doing it:
iOS already HAD an emoji picker, with a recents list and sensible grouping -
better than the flat grid I had just written for Android. My duplicate compiled
and would silently have shadowed the nicer one. Deleted; Android's is the side
that now wants recents and grouping.
A sticker that reaches nobody is marked pending but is NOT put in the outbox:
that queue replays text, and replaying a sticker through it would send the id
as a message body. Marked rather than silently passing for sent, and noted as
unfinished rather than half-built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stickers send, arrive, file into the right thread - group messages under the
group, not scattered across one thread per member - and draw bare. No bubble:
the kit draws none, and a glyph inside a grey rounded rectangle reads as a
broken image. The timestamp and avatar stay, because that is the line saying
who and when.
Two controls, not one, because they are two different things. An emoji goes
INTO the draft and is indistinguishable from any other character once sent -
nothing on the receiving end needs to understand it. A sticker is its own
message kind that is never rendered as text. Behind one button they would imply
they behave the same way. Hence also: the emoji panel stays open on tap, since
picking three in a row should put three characters in the box, while a sticker
sends and closes because it IS the message.
The emoji set is a short curated list, said plainly in the code: a full picker
is search, skin tones, categories and recents - a real feature the system
keyboard already ships. This is what a thumb can reach.
docs/ROADMAP.md now holds the queue, including the security and privacy work.
Written down because this project has already lost a finished feature to a
parked branch. Two items there are real and current: Android spends a one-time
prekey on EVERY message via fetchKeys, silently costing initial forward secrecy
once the pool drains - iOS fixed this and Android has the unused listDevices
sitting next to it - and a passphrase IS the account, with no second factor and
no recovery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sticker is a content envelope carrying an id, never an image: sending the
picture would cost an upload and a download of bytes both ends already ship
with, on every sticker. This is about sixty bytes.
Recognised before the text path on both sides, because a sticker is renderable
but is NOT text - it draws as a glyph with no bubble, and falling through would
render it as an empty message. An unknown pack or id has to degrade to the
pack's fallback rather than break, which is what the envelope format exists for.
The pack is one JSON manifest read by both clients, so there is a single source
of truth for what exists. docs/STICKERS.md says plainly what the first cut is:
the glyphs are Unicode, because real sticker art is a design job, not a code
one. The protocol, picker, rendering, fallback and tests are real and finished;
swapping in artwork is a change to the manifest plus an image loader, not to
any of that. It is not a claim to have drawn a sticker set.
Emoji are deliberately NOT this. They go into the text of an ordinary message
and are indistinguishable from any other character once sent - no new kind, no
new field, nothing for the receiver to understand. That picker is a keyboard
convenience, not a protocol feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the four remaining items.
OUTBOX. iOS threw on a recipient with no registered device, so it never lost
the message the way Android did - but it did not hold it either, leaving the
sender to remember, notice their friend was back, and retype it. Now the same
shape as Android: queued, retried on every poll, never expiring, and the bubble
says "Undelivered - will send". Only prekeyMissing is caught; everything else
still throws.
THE STORY SPINNER. timeoutIntervalForRequest is an INACTIVITY timeout - it
fires when no bytes move for 25 seconds. timeoutIntervalForResource bounds the
whole transfer and was never set, so it defaulted to seven days. An upload
crawling up a saturated uplink never idles for 25 consecutive seconds, so
nothing fired; and because E2EInbox is @MainActor and merely awaiting, the main
actor stayed free and the spinner kept spinning. No error, no end. Now capped
at 180s.
Android was never exposed to this: OkHttp's callTimeout(25s) is a TOTAL call
timeout, so it fails rather than hangs. That asymmetry is why the spin only
ever appeared on iOS.
Correcting an earlier claim: the 4MB upload probe that "took 0.64s" returned
401 - the server refused it before reading the body. It proved the route
exists, not that large uploads complete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the Android tab added alongside it, and the kit's 08 Calls-Listing:
48pt avatar, 16pt name, a direction mark before a 12pt line, and one call
action. No video icon, though the kit's row has one - there is no video path in
CallManager.
The log rides in the sealed history file with the messages. Its field is
optional, which is not cosmetic: Swift's synthesised decoding does not apply a
property default for a missing key, it throws, so a non-optional field added to
Snapshot would make every snapshot written before it fail to decode - and not
losing history is the entire point of that file.
Filed once at teardown, before the state changes, reading a flag captured while
the call was still up. Asking the connection afterwards always says no, which
would record every answered call as missed.
The timestamp formatter is now RowStamp, used by both the chat row and the call
row. It had already been written twice within the hour - the drift its own
comment warned about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kit's bottom bar is four icons and one of them is a phone; its
"08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call
notification from the relay, and no history at all - so "who rang me yesterday"
had no answer anywhere.
So the log had to exist first. It lives in the vault with everything else: who
somebody called and when is precisely the metadata this app exists to keep off
a server, and encrypting the words while leaving the call log in the clear
would be an odd place to stop. Bounded at 500, because the whole log is
re-sealed on every write.
Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING ->
ACTIVE on the way, and recording each transition would turn one call into five
rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever
connected is captured BEFORE the manager is torn down - asking afterwards
always says no, which would file every answered call as missed.
The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp
direction icon before a 12pt line. Missed calls carry the kit's pink so the row
somebody is actually looking for is the one that stands out, and the tab badges
missed incoming calls only - an answered call is not news.
No video icon, though the kit's row has one at x=255: there is no video path in
either CallManager.
relativeTime is shared with the chat list rather than reimplemented - two
screens formatting times differently is drift nobody files a bug about and
everybody notices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured out of "Light Mode - iOS/07 Messages-Details 1" rather than eyeballed:
the App Bar is 88 tall with back at x=24, title 16pt Medium over a 12pt line
with a dot beside it, and Call at x=279. The Message Box is 84 tall: plus at
x=16, an F3F3F3 pill from x=56, camera at x=287, more at x=335.
The composer is one row now. Voice recording was a chip hanging underneath it -
the last thing on the screen that did not belong to that row - and the mic is
an icon in the row, with the permission plumbing hoisted to where it is used.
Send appears only when there is something to send; at rest the row is the kit's.
iOS tab bar drops its labels, matching the kit's "Bottom Bar/Without Label" and
Android. Titles survive as accessibility names - dropping text from the screen
must not drop it from VoiceOver.
PresenceDot was still an olive, a mustard and a rust: the previous brand's
palette, left behind when the accent changed. Now teal, blue and pink from the
kit.
Two things the kit draws that this deliberately does not: a video-call button
and an emoji key. Neither CallManager has any video path at all, and there is
no emoji picker to open - the system keyboard already carries one. A mockup can
draw a feature; the app cannot draw one it does not have, and this codebase has
already shipped one dead call button once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E2EHistoryStore was written on 2026-08-26 and never reached main. It sat alone
on wip/message-formatting-park-20260826 while main moved 174 commits past it,
so the app went on discarding every conversation when the process died - months
after that was explicitly decided against, and while the inbox still carried the
comment saying threads are memory-only.
Brought back narrowly rather than by merging the parked branch, which is 174
commits behind and touches 19 files: the store itself, historyKey() on the
Keychain store, and the wiring in E2EInbox re-applied against current main.
Two things had drifted in those 174 commits and needed fixing to compile:
E2EMessage and E2EGroupMessage had gained fields and lost their Codable
conformance. E2EAttachmentRef and E2EAttachment.Reference were already Codable.
clearAllCaches now purges the sealed file too - without it, "clear" cleared only
what was on screen and the next launch restored what it had just said it removed.
Builds. Not yet verified surviving a real relaunch on device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked: how does the sender know to try again when the other person has no
registered device - especially since they are friends. They should not have to.
Marking it undelivered and stopping there moves the problem onto the person who
did nothing wrong: they would have to remember, notice their friend was back,
and retype it.
So the message goes into an outbox and is retried on every poll tick. Nothing
expires and nothing is discarded - a contact who reinstalls in a month gets it
in a month. Timing out would mean deleting something somebody wrote in order to
tidy up a label.
The queue has to live on the sender's phone, and that is forced by the
encryption rather than chosen: every envelope is sealed to a particular
device's keys, so somebody with no device has no keys to seal to, and the relay
could only queue plaintext. The consequence is that it goes out the next time
THIS app runs and polls, not the instant the recipient signs in - so the label
says "Undelivered - will send" rather than implying it is in flight.
Delivery is now a tri-state. PENDING is quiet and grey because it is the normal
thing that happens when a friend is signed out and there is nothing to do about
it; only FAILED is a warning, and the outbox never produces one.
OutboxTest covers it end to end, including delivery against a real second
identity rather than a hand-written bundle: queued while away, sent when they
register, and the bubble restated - a version that delivered the message but
left it reading "undelivered" would pass a test that only checked the queue.
Engine, client and app suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked: how do we prevent ghost messages. Fixing the one cause does not, so
this fixes the thing that makes any cause invisible.
A message could only be a ghost because a delivered bubble and an undelivered
one were the same picture. The code had one bit - record it or do not - and
both answers were wrong, and the app shipped one of each:
1:1 text recorded only on success, so a message sent while the other person
was signed out vanished out of the box with no bubble and no error. That is
why it took sending twice to get one to appear.
Group text recorded unconditionally, so it left a bubble that looked exactly
like a delivered one and had reached nobody.
Now every send records, carrying the outcome, and a bubble nobody received is
marked. Null stays null for received messages and for anything written before
the field existed - absence of a claim, not a claim of failure - so old
threads do not sprout warnings.
Correcting the previous commit message: it said the lost 1:1 message was
written into the sender's own thread. It was not; nothing was written at all.
The ghost bubble was the group path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.
Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.
iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.
Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".
SendToNobodyTest pins it; verified failing with the guard removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: profile pictures do not appear in the chat boxes. They did not.
iOS had an avatar slot in MessageBubble that hand-rolled only the initials
fallback - it never had the user id or the inbox, so it could not reach the
picture that AvatarView already downloads and decrypts everywhere else.
Android reserved the kit's 40dp received inset for an avatar and drew nothing
in it at all.
Both now pass the author and the picture through. Sent bubbles get none: you
know who you are, and a picture on both sides halves the width a thread has.
Also gives the iOS list row a real last-message preview and a timestamp,
matching the Android row - it was showing a screen name and the word 'Online',
a second copy of the dot already on the avatar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kit's row is four things - avatar, name, last message, time - plus an
unread count on the right. Ours was a name and the word 'online', which is
most of why a two-contact screen read as empty.
The preview comes from the local vault, not the relay: the relay sends none
and asking it to would leak which conversations are active, while the history
is already decrypted on this device. Presence moves to the dot on the avatar,
where the kit draws it and where it costs no line.
Section headings now appear only when both contacts and groups are on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It held Refresh and Sign Out. The list is already .refreshable, so pulling it
down is the gesture people reach for, and signing out lives on Account with
the rest of the account — where it also gets a confirmation the menu item
never had. The kit's Messages bar carries compose and contacts, nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every icon in the Android app was a text character or an emoji — +, →, ‹, ⋯,
☎️, 🎤, ⊞ — and material-icons-extended had been a dependency the whole time
with zero Icon() calls against it. Reported three times as bare/text-links;
each time I adjusted spacing and type, because the structure genuinely did
match the kit and the code read correctly.
Adds KitIcons.kt as the vocabulary and migrates the tab bar, bar actions, row
overflow, composer, call controls, contact rail and alert glyphs. The tab bar
loses its text labels to match the kit's 'Bottom Bar/Without Label' artboard
and keeps them as accessibility names.
Also fixes the iOS composer sitting under the tab pill: HidesPillTabBar was a
PreferenceKey, and preferences do not travel out of a NavigationStack
destination, so the chat screen's request never reached the bar watching for
it. Now an observable with balanced retain/release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tab rather than a strip, for the same reason: a column of full-width
cards cannot sit above the chat list without pushing it off the phone.
The strip on that screen stays as the way in.
🔴 The card takes plain values and two closures, not an `E2EInbox`. The
first version took the inbox — which needs a keychain, a client and a
registered device — so the harness could not render the feed at all and
the only thing a screenshot produced was the "transport is not up"
fallback. That is the THIRD screen to learn this after the call screen
and Android's: presentation that takes plain values can be looked at, a
state machine cannot. It is now cheap enough to be the default.
⚠️ No like-or-comment bar, as on Android. The kit's post card has two
emoji reactions and a share icon; a story here has no comment thread,
no reaction and nothing to share it to, and drawing the bar wired to
nothing would be a lie told in pixels. The deadline sits in the slot
the kit gives a description — the one fact about a story that changes
while you are looking at it.
⚠️ The feed shows the deadline, not the post time. "2 hours ago" is the
wrong fact about something that vanishes.
"Image unavailable" in the render is correct: the harness has no bytes
to hand it, and the card says which half failed rather than a generic
"could not load", which would cover a network error, a wrong key and a
relay serving different bytes alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The avatar work mirrors Android exactly, because two answers to "how
does a face travel" would be worse than either. An avatar is an
attachment, not a field on the account: the relay stores the same
opaque blob it stores for a photo, and the key reaches each contact
inside the ratchet as the delivery key already does. A JPEG the server
serves per user would hand it everybody's face on a product whose whole
claim is that it cannot see what you send.
Same three things that would have been bugs, forked at the same points
for the same reasons:
🔴 `avatar` is parsed WITH its attachment ref and consumed before
anything renders it. Without that fork it arrives in the conversation
as though somebody had sent you their face as a photo.
⚠️ Sharing is keyed on the blob id, not a flag. A boolean says "told"
for ever and a new picture never reaches anybody who had the old one.
⚠️ Contacts are told when a conversation OPENS, not when the picture is
chosen. Somebody added afterwards would otherwise see initials for ever
with nothing on any screen to explain it.
`E2EAttachmentRef` became `Codable` so a reference can be persisted —
the reference, never the decrypted bytes. A face on disk in the clear
is the one thing a stolen phone should not hand over about somebody's
contacts.
Initials are documented as the NORMAL case rather than a placeholder
waiting for bytes: a picture only exists once somebody has set one AND
messaged you, so most avatars will be letters.
Also verified, now that Xcode builds again, the two fixes committed
blind earlier: the contact detail's cover is full-bleed and its
presence indicator is on the name's own line rather than behind the
action rail. Both confirmed by render. 63 iOS tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **An avatar is an attachment, not a field on the account.** The
obvious design — upload a JPEG and let the relay serve one per user —
hands the server everybody's face, on a product whose whole claim is
that it cannot see what you send. This rides the same encrypted blob
path as a photo message: the relay stores ciphertext, and the key
travels to each contact inside the ratchet exactly as the delivery key
already does.
⚠️ **The cost, stated on the screen rather than discovered.** It is per
contact. Somebody who has never messaged you cannot see your picture,
and changing it means telling every contact again. Sealed sender
already makes this bargain; a different answer for avatars would mean
two answers to the same question.
The pieces: a new `avatar` control kind, which is parsed WITH its
attachment ref rather than falling through the CONTROL early-return —
otherwise a profile picture arrives in the conversation as though
somebody had sent you their face as a photo. Encrypted at rest in the
vault like everything else, because a face on disk in the clear is the
one thing a stolen phone should not hand over about your contacts.
Sharing is keyed on the blob id, not a boolean: a flag would say "told"
for ever and a new picture would never reach anyone who had the old one.
⚠️ Contacts are told on OPENING a conversation, not when the picture is
chosen. Somebody added afterwards would otherwise never be told, would
see initials for ever, and nothing on any screen would explain why.
⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told"
is the truth when two contacts have a changed key, and silence would
let somebody believe it had gone out.
Also: the chat bar gained the kit's second line — a presence dot and a
12sp fact, or a member count for a group, which shows no dot because a
group has many presences and one dot cannot speak for them. And the
reassuring identity states dropped to 12sp grey; they were as loud as
the title bar, which is the wrong weight for "nothing has happened".
The CHANGED state is untouched and stays loud.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **There was none.** Everything you could do to a person was an inline
row that unfolded under their list entry — Block, Clear chat, Remove,
three text buttons in a line — with nowhere to explain what any of them
did and nowhere to put a safety number. iOS has the kit's version; this
is the same screen: a cover, a name plate, and a rail of actions
floating at the seam between them.
⚠️ The rail carries only actions that do something. The kit's has a
"personal page" icon; there is no such page here, and a fourth control
that opens nothing is worse than a rail of three.
⚠️ Presence sits on the name's own line, not the plate's trailing edge.
The rail floats over the right-hand third of the plate, so anything put
there is behind it — a thing only visible once rendered, and it was
caught on iOS the same way an hour earlier.
Removing a contact confirms in place rather than in a dialog, matching
the Account screen: a dialog is dismissed reflexively; a row that
changes to say what it is about to do is read.
🔴 The on-device test asserted `block-$id`, `clear-$id`, `remove-$id`,
which no longer exist — those actions moved off the row and are not
keyed by peer id any more. Updated to follow them, plus an assertion
that there is a way back, or the screen is a trap. That suite needs a
device and cannot run here, so it would have failed silently until
somebody next ran it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **Android had no call screen — it had a banner.** A line of text and
two TextButtons stacked above whatever you happened to be on, for the
single most attention-demanding thing this app does. iOS has had a full
screen throughout and the kit draws one. Same split as iOS: the look
takes plain values so the screenshot harness can photograph it, because
a call screen nobody can look at is a call screen nobody has checked.
⚠️ The privacy prompt stays a banner deliberately. It is a question
asked BEFORE a call exists, and a full screen for it would look like a
call already happening.
🔴 **And a bug I introduced: the floating tab pill was sitting on the
message box.** Reported from the iOS app. The pill overlaps content by
design — that is what makes it float rather than reserve a strip — and
the composer is exactly where it lands, so the field could not be
tapped. `TabView` hid its own bar on a push and the replacement had
nothing to do that with. Both conversation screens now declare
`hidesPillTabBar()`, which is what the kit's own Messages-Details
artboard shows: a composer at the bottom and no tab bar at all.
⚠️ The iOS fix is written but NOT yet rendered — the machine is pinned
copying Xcode out of iCloud Drive and I am not running builds into
that. It compiles; it has not been looked at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 Split the look out of the state machine first. `CallManager` needs a
live `E2EInbox` — a keychain, a client, a registered device — so the
call screen was the one surface the harness could not photograph, and
therefore the one being restyled blind. `CallScreenBody` takes plain
values; `CallScreen` maps the state machine onto it and does nothing
else.
Light, not a dark gradient. The kit's Calling screen sits on the same
white ground as everything else, with the caller at 20 bold over a 14
status line and the controls arranged around a 72pt primary. This was a
full-bleed dark gradient with a row of equal-sized buttons — a
different app for the duration of a call, and the one place a person is
most likely to be looking for something familiar.
⚠️ The kit draws a 400pt animated ring. A static ring pair stands in
and says so: an animation that never resolves is worse than none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both platforms now open on the same thing: a 48pt mark, a 40pt
greeting, and one line saying what this is. iOS had an 88pt tinted
circle with a glyph in it, a 20pt "Welcome back" and a subtitle about
buddies -- a login card rather than a masthead.
The submit button is 240 wide and centred, as the kit draws it, on both
platforms now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A screen, not a strip. Stories were a horizontal row of circles on the
Messages screen -- enough to tell you something exists and nothing
more. The kit's Timeline is a column of full-width cards: author over a
description line, the image, the caption. A card feed cannot sit above
a contact list without pushing it off the phone, so it gets a tab; the
strip stays on Messages as the entry point.
⚠️ No like-or-comment bar, and the kit's post card has one -- two emoji
reactions and a share icon. A story here has none of those: no comment
thread, no reaction, nothing to share it to. Drawing the bar and wiring
it to nothing would be a lie told in pixels. What a story DOES have
that the kit's post does not is a deadline, so that is what sits in the
slot the kit gives a description -- the one fact about a story that
changes while you are looking at it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I kept Flamenet orange and defended it as brand continuity. That was
wrong on the facts: the orange had already been retired and the public
pages carry none of it, so the apps were the last thing wearing a brand
colour the brand had dropped — on the authority of a comment in
Theme.kt that nobody had rechecked. The comment is corrected in place
so nobody restores it for the same stale reason.
The values are measured out of the Sketch file rather than sampled from
a render. The two text colours are the ones that actually appear in its
symbols — #090710 on 165 text layers, #505C69 on 164 — and the accent
#2298FF is what it uses at 12pt as well as on buttons, so it is their
link colour and not only a fill.
The constants were called Ember and held orange. They are Accent now:
a constant named for a colour it no longer holds is how a palette
drifts back.
⚠️ Recorded because it will come up: #2298FF on white is about 3.3:1 —
fine for a bold button label, not for small text. The small text on
these screens is grey, and it needs to stay that way.
Also in this commit, the Android sign-in takes the kit's Welcome
layout: a logo mark, a 40pt greeting and one line saying what this is,
centred with room above them. It was a 24pt product name jammed against
a Server field, which is the shape of a login dialog rather than the
first thing anyone sees of the app. The submit button is 240 wide and
centred, as the kit draws it — a button spanning the phone reads as the
bottom of a form; one that does not reads as a choice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every claim about iOS in the last several commits was "it builds", not
"it looks right" — there was no way to look. Now there is:
`Scripts/screenshots-ios.sh`, one simulator boot for every screen
instead of one boot, one install and one tap sequence per check.
⚠️ Unlike Android's, this DOES need a simulator: UIKit has no headless
renderer. Two things had to be right and both fail misleadingly.
🔴 Not `ImageRenderer`. It skips the UIKit layout pass a `List` needs,
so every settings screen came out an empty rectangle — which reads as
"the screen is broken" rather than "the renderer never laid it out".
Hosting the view in a real window and calling `drawHierarchy` is the
ceremony that avoids it. And one runloop turn before drawing, because
SwiftUI commits its first real layout on the next tick; without it the
PNG is a plausible-looking half-laid-out screen rather than an obvious
failure.
What it found immediately:
The Profile header was half the screen — a 110pt avatar stacked over
the name, over the handle, over a full-width filled button, four
centred elements before a single setting on a screen whose job is the
list below them. The kit puts an 80pt avatar on the leading edge with a
secondary button beside it, on one row. Seven settings fit now where
four did.
Bubble timestamps sat BELOW the bubble on iOS and above on Android, so
the two platforms drew the same conversation differently and the kit
agreed with neither of them — it is above. A received avatar was
bottom-aligned and drifted down the side of a long message, reading as
belonging to whatever came next.
⚠️ Also worth recording: the disk cleanup left simulator device
definitions whose data directories were gone, and `simctl` does NOT
recreate them — it lists the device and refuses to boot it with
"cannot be located on disk". I said earlier they would rebuild on first
boot; they do not. The script now creates a working device when it
finds none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JVM harness renders whole screens now, not just parts. The screens
take their state as `phase` and only touch the view model when
something is tapped, so an unstarted model is enough — it opens the
Keystore vault in `init`, which fails inside a runCatching on a
background dispatcher under Robolectric. That is why this works rather
than by design; if it starts hanging, look there first.
With the screens visible at the kit's own 375x812 canvas, four things
were obvious that were not from inside the code:
🔴 The large title said "fnreviewalpha" over a relay and a device id.
The kit's title names the SCREEN — "Messages". Three lines about who
you are, at the top of a list about other people, and it dominated
because a handle is longer than a title. Identity belongs on Account,
where it already was.
Contact rows read "fnreviewbeta / fnreviewbeta · online" — the name
twice and a status — because the handle is usually the same string as
the display name. It is dropped when it matches.
"No groups yet. Start one with your buddies." was a line of prose in
the middle of a list, with a section label above it, for the ordinary
state of a new account: two lines of furniture for zero rows. Both go
when the section is empty AND loaded. The failure case still speaks,
because the reason that message existed is real — an empty list
rendering nothing gave no way to tell "you have none" from "it did not
load".
Alerts offered "Mark all read" on an empty screen, where its only
possible effect was nothing, and it was the most prominent thing there.
"Nothing new." sat against the heading like a subtitle of it. Proper
empty state now, and the button appears only when something is unread.
Alert rows drew initials of the KIND, so "contact request" became "CR"
— two letters that look like a person's initials and name nobody.
Address card: the QR was the largest thing on the Account screen and
pushed everything below the fold, and the address wrapped mid-hostname
as "…@rela / y.flamenet.io". A relay name split across a line break is
the kind of thing somebody retypes wrong, so the host is its own line
now. Two fields whose placeholder repeated the label above them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying a layout meant booting an 8 GB AVD, installing an APK,
tapping to a screen and pulling a screencap — and the emulator was
killed by memory pressure four times in one session, each time
mid-check. Robolectric in native-graphics mode draws the same Compose
pixels on the JVM. `Scripts/screenshots.sh` now renders the screens in
about four seconds with no device.
Two things had to be got right and both fail in a way that reads as
something else:
`isIncludeAndroidResources` is not optional — without it the theme
cannot resolve and every render throws on a missing resource, which
looks like a Compose problem rather than a configuration one.
🔴 And the capture. `captureToImage()` goes through PixelCopy and waits
for a frame-commit callback Robolectric never delivers, so every render
died as "Condition still not satisfied after 2000 ms" — which reads as
a hung composition and is nothing of the sort. Drawing the decor view
into a software canvas produces the same pixels with no window
involved. `createComposeRule()` has the same problem for the same
reason; it needs the Activity rule.
⚠️ These are output, not assertions: they write files and pass unless
something throws. A screenshot test that diffs against a stored
baseline is a different thing, worth adding once the design stops
moving — right now it is moving, and a baseline would be re-blessed on
every change, which proves nothing.
This immediately paid for itself. The address card had only ever been
seen in its empty state, because the emulator's stored session predates
the fnid and a fresh sign-in erases that phone's keys. It renders
populated here, and the QR in it — encoded by our own Kotlin encoder,
drawn by the app's own Compose code — decodes back to the exact address
through CoreImage's detector, which shares no line with any of it.
Rendered at the kit's own 375x812 canvas, so a PNG can be put beside
the Sketch artboard rather than mentally rescaled from a 1080x2400
phone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "looks like a bunch of text links", with the honest caveat
of not knowing whether that is normal for Android. It is not, and it is
not the kit either: almost every action in the app was a bare
TextButton -- Back, Call, Safety number, Record a voice note, Mark all
read, Look up, Leave, Create group, and the four presence states -- all
the same undifferentiated orange text with nothing around it. That is
not an Android convention; it is the absence of one.
The kit ships three buttons, all 64 tall at radius 32 with a 14 medium
label: a filled primary, an outlined secondary, a grey tertiary. Plus a
small chip for something sitting in a row of its peers.
Presence was the worst of it -- four orange words that gave no sign of
being a single choice with one answer; the selected one was merely
bold. Chips now. Accepting a changed identity key is the most
consequential tap in the app and sat as matching orange text beside
"Show number", so the two read as interchangeable; it is an outlined
danger button now and the other is a chip.
Bar actions became glyphs in tappable circles with real accessibility
labels, rather than words. No icon set is pulled in for eight buttons.
Also in this pass: section headings shrank to 12sp labels and their
buttons moved into the app bar as icons. Two heading rows -- "Groups /
New" and "Contacts / Add" -- took the top third of a screen whose whole
content was two rows, and the kit's list has no headings at all.
iOS gets the same treatment: the tab bar is the kit's floating pill
rather than a TabView strip (PillTabBar records what the system bar was
doing for us that now has to be done by hand, including the one thing
that does not come back -- scroll-to-top), the list screen lost its
cards, and settings moved off .insetGrouped, which draws every section
as a floating card, onto plain rows separated by the kit's 16pt band.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last pass changed row internals and left the screen architecture
alone, and the screen architecture is what makes the kit look like the
kit. Reported back as "looks exactly the same and nothing like the
sketch", which was accurate.
Four cards -- a presence picker, a paragraph about what stories are,
contacts, groups -- each on its own white surface with a heading. The
kit's messages screen is one flat list under a title, a search box and
a strip of stories. No amount of restyling the rows inside those cards
was going to get there; the cards were the thing.
🔴 The type scale was the other half, and it was invisible from inside
a single screen. Material 3's defaults are considerably larger than the
kit's, so a section heading rendered at 24sp where the kit says 16 and
every screen inherited it. Set once in the theme, which fixes screens
nobody has touched.
Stories was a heading, a Post button, and -- with nothing posted, which
is the normal state -- three lines of prose explaining what a story is.
Prose where the design has a row of circles is the loudest possible
signal that this is not the same app. It is a strip of faces now, with
the add-circle always first so there is always somewhere to tap.
The tab bar is the kit's floating pill rather than a row of text
buttons; it is the most recognisable single element in the design.
Group rows carry the same avatar and the same 72dp pitch as contact
rows, so the two read as one list rather than two that follow each
other. The presence picker moved to Account, where the kit would put
it, instead of being dropped with the card it lived in.
The buddy list is plain rows in the page's own scroll now. It was a
lazy list bounded at 320dp -- a small scrolling window inside a
scrolling page, which is exactly what a flat list is not. The bound
existed because a lazy list in a scrolling Column is measured with
infinite height and crashes; rendering rows directly sidesteps that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured out of `Messenger App V2.sketch` by reading the file's JSON
rather than eyeballing a render, and written down in DESIGN.md so both
apps cite one source instead of drifting.
The layout is theirs; the colour is ours. The kit's primary is blue and
adopting it would mean the app no longer looks like the product it
belongs to, so everywhere the kit says primary, read Flamenet orange.
Only #F3F3F3 is taken directly, as the received-bubble and pill fill.
The typeface is not adopted either -- bundling DM Sans is a licensing
decision, not a layout one -- so the sizes, weights and line heights
land on the system face.
🔴 The bubble corners are asymmetric and that is their whole character:
radius 16 everywhere except one corner at 2, which points at its author
-- bottom-right on a message you sent, top-left on one you received.
Rounding all four equally is what makes a chat app look like every
other chat app, and it is exactly what gets lost when a spec is taken
from a screenshot. Only reading the per-corner radii out of the JSON
found it; the symbol's fixedRadius reports a single number and is
misleading.
Conversation rows now have the kit's 48pt avatar, 16pt gutter and
16/14 type. Android had no avatar at all -- a coloured dot beside two
lines of text -- and no amount of adjusting the type would have made
that read like the design.
The Android chat screen needed more than bubbles. Back and the name sat
on one line, Call on a line of its own beneath, and the identity panel
under that, so a third of the screen was chrome before a message. One
bar now. The composer was an outlined field with two labelled buttons
beside it, which was the last part of the app that looked like a form;
it is the kit's pill, built on BasicTextField because a Material text
field reserves room for a floating label and will not go below 56dp
whatever shape it is given.
The timestamp moved outside the bubble, where the kit puts it. Inside,
a one-word message became a tall two-line box -- "sup" rendered taller
than it was wide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps, one in each direction.
Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.
The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.
A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.
The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the two pieces cross-relay messaging was missing, and three bugs
found while testing it.
Inbound threading. The sender's address now travels INSIDE the seal.
It has to: the receiving relay records no sender for a federated
envelope, and the user id in the envelope is a row number on the
SENDER's relay. Filing that in `threads` does not merely lose the
message, it files a stranger's message under whichever local buddy
happens to hold that integer. Remote conversations get their own
keyspace, keyed by `fnid@relay`, and their own section in the list --
without which a message from someone our relay has never heard of
decrypts, notifies, and then leads nowhere. Address is bound to device
first-use-wins: the ratchet proves the device, the address is only a
claim, so a device cannot later move the conversation.
Scanning. Fills the search box rather than opening a chat, so it lands
on the card carrying the "this crosses relays, compare safety numbers"
warning instead of routing around it.
Stories, photos and voice notes were 401. `uploadAttachment` and
`downloadAttachment` were the only two /e2e/* routes that hand-rolled
their request, and set the Application Password directly -- which the
relay refuses once the client holds a token. Text and polling go
through the helpers and kept working, so it read as "stories are
broken" rather than as auth. Android was never affected.
Presence read offline both ways. `MobileRoutes.caller` has always
recorded liveness, but that is the legacy /messenger/* mount. The
app's real sign of life is the long-poll on /e2e/messages, which
authenticates elsewhere and touched nothing -- so somebody who opened
the app and sat in a conversation went offline after five minutes
while their device polled throughout. Fixed on the relay: both apps,
no client release, no new traffic.
Connected services could not be read. The relay encodes `screen_name`
as null and says so in a comment; iOS declared it non-optional and
threw on a well-formed response.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Item 3 through the app: paste an address, open a conversation, send. Where
it stops is recorded honestly below.
Works, driven through the UI on relay1 against a live peer on relay2:
- a pasted `fnid@relay` is recognised as an address rather than sent to
the username lookup, which would ask OUR relay about somebody it has
never heard of and answer "no such user" — true and useless
- the remote directory and bundles are fetched through our own relay
- the client REFUSES a bundle whose SPK signature does not verify. Found
for real: the federation harness registers placeholder keys because it
only checks envelope plumbing, and a real client rightly threw
"Invalid SPK signature on recipient bundle" at it
- with a peer holding real keys, the envelope is sealed and relay1
accepts it
🔴 Does not work: nothing arrives in the peer's inbox on relay2.
relay1 reports envelopes accepted, relay2's poll returns none. The
relay-to-relay path itself is fine — engine/test/federation.mjs is 11/11
against these same two relays — so something differs between the envelopes
that harness builds and the ones the client does. Not yet found.
And a bug of my own that this caught: transmitRemote checked `unknown` and
`unreachable` and never `accepted`, so a send where the relay took NOTHING
reported success. The app said sent, the inbox stayed empty, and nothing
anywhere said otherwise — the exact failure this codebase keeps producing.
`accepted == 0` is now a failure.
RemoteChatScreen is deliberately separate from ChatScreen: that screen is
built around a row number this relay assigned, with presence and a
server-side buddy list behind it, none of which exists for somebody homed
elsewhere.
55 unit tests passing.
Item 3. The relays have federated since the fleet went up — 11/11 against
it — and no client had ever sent a byte across a boundary.
transmitRemote is a PARALLEL path and local transmit is untouched. The
local one is keyed on a row number this relay assigned; a remote peer has
an fnid and a home. Folding them together would mean editing the code where
a mistake loses a message silently, on the path that already works.
Ratchets need no change at all — they are keyed by device id, which is
globally unique.
🔴 Always sealed, and here that is not an improvement but a requirement. A
relay accepting a federated envelope stores it with no sender — it was not
told and must not invent one — so an unsealed envelope arrives
unattributable and is dropped.
Trust gets its own keyspace, keyed by address. Two relays hand out the same
row numbers to different people, so folding a remote peer into the integer
keyspace would let one relay's user 7 inherit another's pins.
⚠️ And I had the model wrong first time round, which is worth recording
because the mistake is easy to repeat. Three things were conflated: sealing
uses the recipient DEVICE's ik_dh, which the remote directory returns; the
delivery key is a separate anti-abuse capability the local relay checks
before accepting unattributed mail; and SendRemoteRequest carries no
delivery key at all. I had put it in the address, which published a
secret-shaped thing for nothing and doubled the length of every address.
The address is just fnid@relay.
One envelope id per MESSAGE, not per device, or a recipient polling several
homes sees the same message once per mailbox. And `unknown` and
`unreachable` are surfaced rather than reduced to a boolean, because with
several homes "sent" is genuinely partial.
55 unit tests passing.
**The address**
Across a relay boundary the receiving relay stores an envelope with no
sender at all — "we were not told who sent it and must not invent an
attribution" — so a federated message can only be attributed if the sender
sealed it, and sealing needs the recipient's delivery key. Locally that key
is handed over on first contact, riding the ratchet. There is no first
contact to ride across a boundary.
So the address has to carry it: flamenet:<fnid>@<relay>?k=<deliveryKey>.
That is far too long to read aloud or retype, and an address that is nearly
right addresses somebody else — hence a QR code, with the string underneath
because a scanner is not always to hand.
Six tests. The ones that earn their place: base64 contains + and /, and
without percent-encoding the / ends the host and the + decodes as a space;
and a relay is a hostname, so a card naming a URL is refused rather than
letting it point the client at a path on somebody else's host. The screen
also says when a card has no delivery key, rather than handing over one
that silently cannot receive a first message from another relay.
⚠️ Error correction M, not H: at H the module count grows enough that the
code stops scanning from a phone held at arm's length, which is the only
way anyone will use it. And .interpolation(.none), or the modules blur and
it will not scan at all.
**The compose files**
Postgres was in both; Redis was in neither, and docker-compose.prod.yml had
drifted from what actually runs — it was missing FN_REDIS_URL,
FN_DELETE_ON_ACK and the five APNs variables too. A self-hoster following
the documented compose got no Redis at all.
Both now carry it, marked optional and deliberately unpersisted: a queued
token only says "look now", the poll cursor is the truth, so losing every
token on a restart costs one poll and nothing else.
api-docs described the protocol thoroughly and never said how to start a
relay. It now has a compose file, every variable with whether it is
actually required, and curl checks. The parts worth reading twice, each of
which has bitten somebody: FN_RELAY_AUDIENCE must be the hostname clients
reach, because a mismatch fails as a signature error rather than a name
error; FN_TURN_HOST must stay unproxied in DNS or every relayed call fails
while the record looks healthy; FN_DELETE_ON_ACK is Postgres-only; and
FN_LOG_LEVEL must never be debug in production, because a protocol URL
contains a device id.
Android sign-up had never been driven. It now passes against all four
relays. Two silent faults in the driving, both of which reported a broken
sign-up for a form that was never filled: `adb shell input text` drops
characters on longer strings — "@example.test" arrived as "@exs" — and the
first fix used `fold | while read`, which swallows the last chunk when
there is no trailing newline. Every field is typed in chunks and read back.
And the comparison page claimed "Servers can talk to each other — Yes"
beside Signal's No. True of the relay — 11/11 against the live fleet — and
false of the product somebody holds, because neither app calls a single
federation route. It now says "Server-to-server", that the apps do not use
it yet, and that we would rather say so than let you find out.
NINE-ITEMS.md records what was verified and how, including that no payment
was ever taken end to end.
⚠️ The demo passphrase is deliberately absent: this repository is public
and those are live accounts. Scripts/android-signup.sh reads
FN_DEMO_PASSPHRASE and refuses to run without it.
2026-08-27 11:03:21 -04:00
946 changed files with 127055 additions and 57646 deletions
FlameNet sticker pack `flamenet.retro`: 24 original drawings in `svg/`, rendered to the 288x288 transparent PNGs the app ships in `Resources/stickers/flamenet.retro/`, in the picker order of `Resources/stickers/flamenet.retro.json`. The Android app ships the same PNG bytes.
To re-render after editing an SVG, run this from this folder with Node 22 or later: `MANIFEST=../../Resources/stickers/flamenet.retro.json OUT=../../Resources/stickers/flamenet.retro PW_DIR=<folder with node_modules/playwright-core> CHROME=<Chromium binary> node render.mjs`. It also writes two contact sheets here, which are not shipped.
| `relay3.flamenet.io` | local self-hosted | n/a | yes |
**"Three clean rounds" cannot be run back to back against a shared account
service, and that is correct.** It allows 3 new accounts per hour per IP;
rounds 2 and 3 exhaust it and the service answers `429 Too many accounts
created from here recently`, which the app shows verbatim. The suites report
that as SKIP, not FAIL, a suite that fails for a service working properly
teaches everyone to ignore it.
Only **local self-hosted** survives repeated rounds, because it keeps its
own accounts and has no such quota.
Repeatable via `Scripts/android-signup.sh` and `RelayTypesUITests`.
## 3, cross-relay messaging
**The relays do it.**`test/federation.mjs` (in [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e) since the split) against the live fleet:
<string>Flamenet uses the camera to scan a contact's address code. Nothing is recorded and no image leaves your device.</string>
<key>NSFaceIDUsageDescription</key>
<string>Flamenet can require Face ID before opening the app, so somebody holding your unlocked phone cannot read your conversations.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Flamenet uses the microphone for voice notes and voice calls, which are end-to-end encrypted before they leave your device.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Flamenet can save a story you posted yourself to your photos, so you keep it after it disappears for everyone else. It only ever adds; it cannot see the photos you already have.</string>
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.