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>
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>
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>