745 Commits
Author SHA1 Message Date
extant2000git 2e9c2e0802 Merge pull request 'On-device tests for the app over an obfs4 bridge, and what they found' (#192) from test/the-app-over-a-bridge into main
ci / secret-sweep (push) Successful in 13s
ci / vectors-head (push) Successful in 4m48s
ci / unit-tests (push) Successful in 10m35s
ci / image (push) Successful in 11s
2026-10-14 22:27:21 +00:00
Paul HittandClaude Opus 5 4469df0bf3 Stop throwing away a sign-up that finished, and the app sends through a bridge
ci / image (pull_request) Successful in 8s
ci / secret-sweep (pull_request) Successful in 13s
ci / vectors-head (pull_request) Successful in 4m32s
ci / unit-tests (pull_request) Successful in 9m59s
The on-device test now passes. The app, on an emulator, with an obfs4
bridge in force, signs up on relay1, publishes its device, looks up a
contact, adds it and sends a message. Six obfs4 handshakes counted by the
app, five of them before the send, and the relay logged
POST /e2e/messages 200.

What was in the way was not the bridge.

🔴 **signIn discarded work that had already succeeded.** It ran
`withTimeoutOrNull(60s) { withContext(IO) { authenticate(...) } }`.
`authenticate` is blocking socket work, so cancelling the coroutine does
not stop it: the timeout fired, the screen said "The server did not
answer. It may be down, or the address may be wrong", and the
registration carried on and finished behind it. One run left the account
`andb7564513` on relay1 with a published identity and a device while the
person was told the server was unreachable. The relay answered sign-up in
397 ms and the app's next call came 115 seconds later.

Reproduced with no bridge (`-e direct true`), so this was never a bridge
defect. The work now runs in its own `async`, the first 60 seconds still
report, as "Still waiting for the server" rather than as a failure, and a
late success is adopted instead of dropped. After 180 more seconds it
gives up and cancels the work, so a success nobody is waiting for cannot
surprise somebody who moved on.

Spanish for the new string is in values-es.

Two things the test learned to stop believing:

- An outgoing line in the thread is not delivery. The app renders a
  message as soon as it is composed, and a run that asserted only the
  text reported OK while the relay never saw a POST at all. Delivery is
  now the absence of both the `pending` and `undelivered` markers.
- `Obfs4Settings` on main takes `add`, not `save`, and returns
  `AddResult`. The branch this was first written against had the older
  single-line API.

InstrumentedSuitesCanSignUpTest now accepts `typeIfPresent("birth-year")`
as well as the direct call. relay1 does not ask for a birth year, so a
suite driving it has to fill that field only when the relay puts it on
screen. The rule it enforces is unchanged.

Gate: `:app:testDebugUnitTest` is 671 tests with 267 failures on this
machine, identical with and without this change, measured by stashing it
and re-running. 266 of those are
`IllegalArgumentException: Unsupported class file major version` from a
JDK 26 toolchain, which is this laptop rather than the repository. The
one real failure was the guard above, and it passes now: 3 tests, 0
failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:24:51 -04:00
Paul HittandClaude Opus 5 adf44db7ca On-device tests for the app over an obfs4 bridge, and what they found
ci / secret-sweep (pull_request) Successful in 12s
ci / vectors-head (pull_request) Successful in 3m24s
ci / image (pull_request) Successful in 7s
ci / unit-tests (pull_request) Failing after 5m57s
The 2026-09-28 answers to OTF said bridge users can reach the relay from
anywhere. flamenet-e2e now carries a real message through the live bridge
from Node, which proves the bridge and the relay. It does not touch this
code: Obfs4SocketFactory under the app's own OkHttp, the sign-up, the
device registration and the send.

Two tests, both opt-in, both needing a live bridge.

BridgeTransportProbeTest opens the tunnel by hand and speaks one request
through it. PASSES on an emulator, and the numbers are worth keeping:
one socket plus TLS plus a GET in 1.3 seconds, and four concurrent
tunnels in 1.7 seconds with four handshakes counted and HTTP 200 on all
four. The certificate inside the tunnel is relay1's own. Concurrency was
the first suspect and it is not the problem.

BridgeMessageTest drives the app: bridge in force first, handshake
counter asserted at zero, then sign-up, add a contact, send. It does NOT
pass yet, and the reason is not the bridge.

🔴 **The app strands the person on the sign-up screen with an account
that exists.** After a transient failure during sign-up the screen keeps
saying "The server did not answer. It may be down, or the address may be
wrong", and it never navigates, while the relay's log shows signup 200,
token 200, devices 200, identity 200, buddies 200 and groups 200. The
account `andb7564513` from one such run answers on
`/e2e/account/identity` with a published identity. The person is told the
server is unreachable; their account and device are registered.

Reproduced with `-e direct true`, which runs the same path with no
bridge, so this is an app defect and not a bridge defect. The control
exists for exactly this reason: without it the flakiness looks like a
flaky tunnel.

Two numbers behind it: the relay answered sign-up in 397 ms, and the
app's next call came 115 seconds later. `RelayClient.defaultHttpClient`
sets `callTimeout` to 25 seconds, so the first attempt times out, the UI
latches the error, and the retry that succeeds changes nothing on
screen.

Four things these tests cost, each a silent failure rather than an
error:
- The sign-up form's fields depend on what the relay says about its
  identity mode, so filling them before discovery lands types into
  fields that then vanish, and the submit button stays disabled.
- `toggle-register` is a toggle. Clicking it blindly put the test into
  sign-in mode, where it pressed a button labelled "Sign in" and read
  the refusal as "Your session expired".
- The contact row shows the display name, so a peer whose display name
  differs from its handle is invisible to a search for the handle.
- The handshake counter has to be zeroed after the sign-out, because
  signing out of the previous run is itself a request through the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 15:58:39 -04:00
extant2000git 416f2d8433 Merge pull request 'Android 0.32: merge the release branch into main, and move CI to Gitea Actions' (#191) from merge/0-32-unify into main
ci / image (push) Successful in 11s
ci / secret-sweep (push) Successful in 14s
ci / vectors-head (push) Successful in 5m6s
ci / unit-tests (push) Successful in 10m4s
All four Gitea Actions jobs green on a13adb66: secret-sweep, image, unit-tests (671 tests) and vectors-head. The tree compiles and the suites pass; the notification and padding merges still deserve a human read.
2026-10-14 15:12:40 +00:00
Paul HittandClaude Opus 5 a13adb66c4 ci: base64 from java.util, Bytes is internal to the engine
ci / vectors-head (pull_request) Successful in 3m25s
ci / image (pull_request) Successful in 13s
ci / secret-sweep (pull_request) Successful in 15s
ci / unit-tests (pull_request) Successful in 10m39s
StoryTakeDownTest reached for `io.flamenet.e2e.Bytes`, which is internal to
the engine module, so the app's test source set cannot see it. Both that
helper and the client's `Wire.b64` are `Base64.getEncoder().encodeToString`,
which is what the linked-device map is compared against, so the test uses it
directly, as the other app tests do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 11:00:41 -04:00
Paul HittandClaude Opus 5 bca26e2337 Four test expectations that the merge changed, each decided by the spec
ci / image (pull_request) Successful in 9s
ci / secret-sweep (pull_request) Successful in 11s
ci / vectors-head (pull_request) Failing after 4m46s
ci / unit-tests (pull_request) Failing after 6m1s
Each of these tests was written on the release branch against behaviour main
has since changed. The production behaviour stands and the expectation moves,
because in every case main's rule is the one the spec states.

InboundControlReachesTheThreadTest: a reaction and a pin were expected in
`absorb`'s return. Sections 6.9 and 6.19 say neither may notify, and main
applies both through `changedQuietly`, so the thread is redrawn and never
marked unread. The two tests now assert an empty unread set and the thread in
`takeQuietlyChanged`.

StoryTakeDownTest: the tablet was registered but never linked. Spec 17.3,
which main added, sends an own-account copy only to devices a person linked,
under the key they were linked with. A device signed in with the password is
a device of the account and nothing more. The test links it.

ASnoozedChatHoldsItsNotificationsTest read MessengerService for
`due(touched, ...)`. The service now takes quiet threads out first
(`val loud = touched - s.quietThreads`), per 14.5 and 17.2, and the test
checks both lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:54:18 -04:00
Paul HittandClaude Opus 5 52b291ffb2 Fix three more merge defects the compile found
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 9s
ci / vectors-head (pull_request) Successful in 4m55s
ci / unit-tests (pull_request) Failing after 8m17s
Session.kt declared `arrived` twice, once per side. Main's filter is the one
to keep: its own comment records that filtering on `isRenderable` here made
the reaction, edit and pin branches unreachable, so everything that arrived
was decrypted and then dropped. The release branch's declaration is gone and
the loop uses the broader list, which is what main's fix intended.

StoryFrame.kt was extracted from StoriesScreens and localized in the same
move, so it calls `stringResource(R.string.social_story_image_desc)` without
importing either name. Both imports added. The key is in values and values-es
already; parity is 860 keys per language.

SilentKindsTest pinned "bridge offer" to `Content.bridgeOffer(...)`, which
returned String on the release branch and String? on main, where a blank or
over-long line is refused (§24.4). That typed the whole map String? and the
two assertions stopped compiling. The test line is neither blank nor long, so
it is unwrapped at the fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:37:34 -04:00
Paul HittandClaude Opus 5 d1ddbba3de ci: name bash as the shell, dash has no pipefail
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 14s
ci / vectors-head (pull_request) Failing after 4m35s
ci / unit-tests (pull_request) Failing after 4m45s
The container job picked `sh -e {0}`, and sh on eclipse-temurin:jammy is
dash. Every `set -euo pipefail` step failed on line 2 with "Illegal option
-o pipefail" before running a command, so unit-tests and vectors-head went
red without compiling anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:11:36 -04:00
Paul HittandClaude Opus 5.5 465a192d7f ci: move CI to Gitea Actions
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 2m1s
ci / vectors-head (pull_request) Failing after 1m0s
ci / unit-tests (pull_request) Failing after 1m2s
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it. Kept:
secret sweep, the four-module unit run with --rerun-tasks against the
pinned vectors, the XML floors (595/405/68/515, 1580 total), vectors-head
(advisory).

Runs on mac1/mac2 inside a pinned toolchain image (ci/android-ci.Dockerfile:
Temurin 21.0.5, cmdline-tools 11076708, platform 35, build-tools 34.0.0 and
35.0.0, Node 22.11.0), never on pauls-imac. The `image` job pushes the tag
to git.extant2000.com when it is missing.

Vector and README links now point at git.extant2000.com/extant2000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:03:52 -04:00
Paul HittandClaude Opus 5 92776e6f8c Fix the merge: one stray brace, and the five vector paths the release CI dropped
Two real defects in 69e12af, both found by the pipeline rather than by reading.

client/Conversations.kt had an extra `}` on line 425. Writing out both
`sendGroupAttachment` (main) and `uploadAttachment` (the release branch) left
the closing brace of the function git had spliced away. The class body closed
at 425 instead of 3466, so the parser reported `companion object` at 3364 as a
syntax error 3000 lines from the cause.

.gitlab-ci.yml took the release branch's copy whole, which predates five
vector exports main had added: padding-v1, sync-merge-v1, handle-cert-v1,
links-v1 and bridge-offer-v1. BridgeLineVectorTest then failed on a missing
file, reading as a protocol bug. The two sides each added different vectors,
so the resolution is the union, in both the pinned job and vectors-head.
vectors-head runs :obfs4:test again for the same reason: it reads the
bridge-offer vectors from SPEC section 24.

All 15 vector files exist at the pinned e2e ref 4b4042f.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:03:34 -04:00
Paul HittandClaude Opus 5 69e12afb40 Merge release/android-0-32 into main: the 0.32 features meet the Spanish strings
Two 0.32 lines had diverged since 28 September. main carried the Spanish
localization (853 strings) and friend-to-friend bridge sharing;
release/android-0-32 carried 65 commits of stories, Today, the shared test
vectors, the shell peer and envelope wake parity. Neither was a superset, so
this is a real merge of 26 conflicting hunks across 15 files.

.e2e-ref now pins flamenet-e2e 4b4042f, the commit that unified the two spec
lines. Before that no single pin covered both sides. .gitlab-ci.yml takes the
release branch's version, as the owner chose.

Conflicts that were decisions, not text:

- Conversations.kt looked like two versions of uploadAttachment. It was not:
  that function exists only on the release branch, and git had paired its
  doc comment and parameter list with the body of main's sendGroupAttachment.
  Resolving side by side would have deleted one function. Both are written
  out whole, and the attachment padding of 23.7 and 23.9 is intact.
- MessengerService and Session: main suppressed alerts for this account's own
  device copies (14.5, 17.2); theirs added card, event and game notices with
  snooze handling. Session keeps the quiet set and the notice bookkeeping and
  returns touched + quiet, so quiet threads still count as unread, and the
  service subtracts them before anything is raised.
- MainActivity: contact links and hello links are different schemes, so both
  handlers stay. Hello runs first, because helloLinkIn matches only its own
  scheme and leaves the intent alone, while the contact handler consumes any
  VIEW data and would swallow every hello link.
- MessengerViewModel: fetchedBuddies is not in scope at that point, so the
  roster comes from the vault as theirs has it, with the status localized.

Nine string keys to match: seven new (the hello-link QR label, the card and
event forward notes, the Today tab and the three attach row labels) and two
reworded, because supervision now hides the Today tab. English and Spanish
both, so parity holds at 879 keys each side.

No Java on the machine this was merged on, so CI is the first compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 09:51:19 -04:00
Paul Hitt 842439b452 Merge branch 'i18n/es' into 'main'
Bridge sharing (friend to friend) and Spanish (draft, needs native review)

See merge request extant2000/flamenet-messenger-android!163
2026-10-10 15:08:37 +00:00
Paul HittandClaude Opus 5.5 0d62e81d1a Spanish (es) draft for the bridge screens
DRAFT, needs native review.

Rebased onto bridge/f2f-apps (ebdd2ac). NetworkSection.kt was taken from
the bridge branch whole and its text moved to resources again, so every
bridge behaviour stays as that branch wrote it: several lines with failover,
offers, sharing with a confirmation, scanning a line, and nothing said to go
around a saved bridge.

- 19 new resources: the bridge list, failover and sharing text, the sign-in
  "Connect through a bridge" toggle, the four "off while you use a bridge"
  notices (calls, wallet, link previews, and the no-bridge failure).
- statusLineText() mirrors the new statusLine() branches, including "none
  reached" and "N bridges saved"; the English function stays for its tests.
- describeFailure takes the no-bridge sentence from the view model;
  ChainRpc.Failure.OffWhileBridged maps to the wallet notice.
- ScanAddressDialog's default hint and refusal are resources.
- Obfs4Settings.AddResult and AroundTheBridge keep their English for the
  tests; the screens show resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:47 -04:00
Paul HittandClaude Opus 5.5 5dbf626f46 Spanish screens checked on the emulator; four more lines localized
DRAFT, needs native review.

An emulator run with the app locale set to es-MX, signed up on a local
relay, showed English in four places and one cut-off label:
- The sign-in line about which accounts a server keeps
  (SiteConfig.identityDescription) and the Network status line
  (statusLine): both now have a resource twin that the screen uses. The
  English functions stay because their tests call them without a Context.
- Message and reply-reminder notifications: the NotificationSettings
  instance methods read resources; the companion functions the tests pin
  are unchanged.
- The presence word ("offline") in the chat header, contact rows and
  contact detail now goes through presenceLabel().
- "Todos excepto" was cut to "Todos exc..." on the story audience row. It
  is "Todos menos" now, and "Todos" for "All contacts". "7 días" is still
  cut, exactly as "7 days" is in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:55 -04:00
Paul HittandClaude Opus 5.5 d735e96b0c Spanish (es) draft: UI text moved to resources, values-es added
DRAFT, needs native review. Nothing in values-es ships until the owner or a
Localization Lab volunteer has read it.

- 841 user-facing strings (823 strings, 18 plurals) moved from Compose and the
  view model into res/values/strings_{common,chat,core,main,settings,social}.xml.
  English is word for word what the screens said before.
- values-es mirrors every file key for key, neutral Latin American Spanish,
  "tú", terms from the /es/ site pages. docs/i18n/glossary-es.md lists them.
- UserFacingErrors.kt maps the client library's fixed-text exceptions to
  resources, so a relay refusal reads in the phone's language. Text that
  comes from the relay itself stays as sent.
- SpanishStringsTest: every key has Spanish, no orphans, identical
  placeholders, a draft marker in every file; short labels over 1.6x are
  listed, not failed.
- Source-reading tests read the English through EnglishStrings.withEnglish,
  which appends the text of every key a file references.

Left in Kotlin on purpose, because a unit test calls the function with no
Context and pins the English: describeFailure, linkOutcomeText,
NotificationSettings.messageText, SiteConfig.identityDescription,
NetworkSection.statusLine, registeredLine, draftLengthText, quotedPreview,
FileLevels, ReportReason labels, AppDiagnostics summaries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:51 -04:00
Paul HittandClaude Opus 5.5 ebdd2acb00 Vectors: pin flamenet-e2e 4b85000 (SPEC-only change on bridge/f2f-followups)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:43 -04:00
Paul HittandClaude Opus 5.5 0f2e8cc529 Fail over within one request when the server is an IP literal
OkHttp skips the resolver for an IP literal, so it had one route and gave
up after the first line; the next line waited for a later request. The
cross-platform run caught it: Android sign-up against a relay at an IP
address sat on a dead first line, because only one discovery probe is made.
An interceptor now retries on a connection-phase failure (refused, no
handshake, TLS failing in the tunnel), once per stored line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:01:14 -04:00
Paul HittandClaude Opus 5.5 ef197dd432 Vectors: pin flamenet-e2e 4e36d5a; hold Content.bridgeOffer to envelope_plain byte for byte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:25:28 -04:00
Paul HittandClaude Opus 5.5 57a969d3f2 Nothing goes around the bridge: previews, calls and the wallet are off while one is saved
SPEC §24.6: a client with bridges turned on must not connect directly.

- Off while a line is saved, saying why in plain words: link previews (the
  fetch returns nothing and connects nowhere; the setting is greyed with a
  note), calls (refused at place, at answer and where the peer connection is
  built; an incoming call is answered busy), and the wallet (the chain client
  refuses before connecting; the screen says so).
- Through the bridge now: the update check, which had its own client.
- The anonymous client keeps its own pool, which now empties with the rest
  when the bridges change (§24.7).
- The probe the app makes at launch goes to a saved bridge, checked by a
  test that fails when the bridges are not loaded at startup. With no bridge
  it is unchanged.
- The sign-in UI test saves a dead line first, so its view model never
  probes the production server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:18:56 -04:00
Paul HittandClaude Opus 5.5 9f4cb84009 Bridges: a screen before sign-in, several lines with failover, §24 receive rules
- "Connect through a bridge" on the sign-in and sign-up screen opens the
  bridge panel before any account exists: paste or scan a line, the live
  status, remove a line.
- Up to 8 stored lines (migrated from the one-line store), tried one at a
  time with the one that last worked first. OkHttp's own route loop does the
  trying: while lines are stored the resolver answers every name with one
  placeholder per line, so no name is looked up on the local network, and a
  refused connection, no handshake within 15 s, or TLS failing inside the
  tunnel moves to the next line. accounts.flamenet.io and the relay keep
  their own names through the tunnel, so the edge behind the bridge routes
  each to its real service. Every line failing never connects directly, and
  the error says no bridge could be reached.
- Offers: a gid, a line over 1024 bytes, or a line that is not a string is
  dropped; an oversized or blank line is never built. Ports are digits only.
- Held to bridge-offer-v1.json in :engine and :obfs4; .e2e-ref moves to
  flamenet-e2e c75b861 and CI exports FLAMENET_BRIDGE_VECTORS.
- Citations of §6.19 for the bridge offer now say §24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:56:30 -04:00
Paul Hitt 4c818e1e7e Merge branch 'release/0.32.0' into 'main'
FlameNet Messenger 0.32 (Android): padding, pairs 2-B, multi-device, sealed first contact, Unlisted and contact links

See merge request extant2000/flamenet-messenger-android!162
2026-10-09 19:40:01 +00:00
Paul HittandClaude Opus 5.5 6eb6b77329 Merge main (!161, the vault hotfix) into release/0.32.0
!161 carried this branch's two vault fixes to main, with the account-switch
test written against main's Vault (group names in place of the client
records 0.32 adds). Both added that test file; this keeps the 0.32 version,
which also checks the handle certificate record. Every other file merged
cleanly: the fixes are the same lines on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:30:51 -04:00
Paul Hitt ba9a94a85f Merge branch 'fix/account-switch-and-vault-race' into 'main'
Another account on the same install starts empty; two vault writes no longer crash

See merge request extant2000/flamenet-messenger-android!161
2026-10-09 19:30:35 +00:00
Paul HittandClaude Opus 5.5 605bd98e71 Vectors: pin flamenet-e2e 50fcc99, the merged 0.32 spec with §7's PQ number
e2e!66 merged privacy/sweep-2026-10 (the 0.32 SPEC and vectors this branch
was written against, 5a5b7e7), the Swift interop build list and §7's
post-quantum safety number. PublishedVectorsTest reads `safetyNumberPQ`.

Full suites with the vectors at 50fcc99: engine 759, client 573 (30 live
tests skipped), obfs4 75, app debug 393, app release 352, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:59 -04:00
Paul HittandClaude Opus 5.5 0cc85eb411 Contact links from the shipped relays open the app (verified app links)
SPEC 8.12: https://<relay>/add#... used to match only an unverified
*.flamenet.io filter, so Android asked which app to use, or opened the
browser. A second filter with android:autoVerify claims relay.flamenet.io
and relay1.flamenet.io for /add; the relay serves the matching
/.well-known/assetlinks.json (io.flamenet.messenger, the Messenger release
certificate's SHA-256). The wildcard filter stays for every other relay.
The link handler already reads the https form (MainActivity.openLinkIfAsked
to handleLinkIntent).

AContactLinkOpensTheAppTest: red with the old manifest, green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:58:56 -04:00
Paul HittandClaude Opus 5.5 51d496a698 Safety number: cover both post-quantum identities, as iOS and SPEC §7 do
Owner's decision, 2026-10-09: iOS's rule (audit H3) is the protocol. On
the iPhone-to-Android pair against a local relay the two screens showed
different numbers for the same accounts (iOS 09614...98887, Android
15508...63939): only iOS folded the ik_pq fingerprints in.

- Trust.safetyNumber folds SHA-256(ik_pq) of both sides in when this
  device holds both (SafetyNumber.compute with PQ, the engine already had
  it), the Ed25519 number otherwise. safetyNumberCoversPQ says which, and
  the screen says it in iOS's words.
- The side that never read the other's bundle (the one that answered a
  first message) had no pin for them, so its number could never match.
  Conversations.pinPeerPQIdentity reads the contact's bundles once, for
  devices whose IK_sig is already trusted, checks and pins their ik_pq
  (§20.5); the screen does it when the number is first shown.
- PublishedVectorsTest pins protocol-v1.json `safetyNumberPQ`
  (flamenet-e2e 841492d).

FirstContactTest "the safety number covers both post-quantum identities,
as on iOS": red (Ed25519 number), then green, including both screens
matching after the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:36:03 -04:00
Paul HittandClaude Opus 5.5 8842479bb8 Another account on the same install starts with an empty vault
Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.

AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.

AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:26:53 -04:00
Paul HittandClaude Opus 5.5 69a7bd72c6 Two writes of one vault record no longer kill the app
Found on the emulator, typing into a new conversation: the app died with
"IllegalStateException: could not commit the vault record" from
Vault.saveDraft on a worker thread. The draft is saved from a coroutine per
keystroke on the IO pool; two writes of one record staged into the same
`<name>.tmp`, the first rename took it, and the second had nothing to
rename and threw, uncaught. Main has the same code.

Each write now stages under its own name, and draft saves run one at a
time and in order, so an older draft cannot land after a newer one or
after the send that cleared it.

TwoWritesOfOneRecordDoNotCrashTest (400 writes from 8 threads): red with
the shared staging name, green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:26:31 -04:00
Paul HittandClaude Opus 5.5 d83cebfee2 Another account on the same install starts with an empty vault
Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.

AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.

AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:42:42 -04:00
Paul HittandClaude Opus 5.5 2ac1ede1fc Carry this account's handle certificate, never one kept from another
Found on the emulator against a local relay: an install that had been
signed in as andy1009, then iris1009, then luke1009 carried andy1009's
certificate (uid 1) as `hc` on luke's first message to an iPhone. The
iPhone refused it ("prelude carried a handle certificate that does not
verify") and showed luke as "Buddy 3". A plain sign-out keeps the vault,
and ownHandleCert reused any kept certificate that had not expired.

A kept certificate is now reused only when it verifies for this account;
otherwise a fresh one is fetched.

FirstContactTest "a certificate kept for another account is not carried as
mine": red, then green; 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:39 -04:00
Paul HittandClaude Opus 5.5 394ad7626d Two writes of one vault record no longer kill the app
Found on the emulator, typing into a new conversation: the app died with
"IllegalStateException: could not commit the vault record" from
Vault.saveDraft on a worker thread. The draft is saved from a coroutine per
keystroke on the IO pool; two writes of one record staged into the same
`<name>.tmp`, the first rename took it, and the second had nothing to
rename and threw, uncaught. Main has the same code.

Each write now stages under its own name, and draft saves run one at a
time and in order, so an older draft cannot land after a newer one or
after the send that cleared it.

TwoWritesOfOneRecordDoNotCrashTest (400 writes from 8 threads): red with
the shared staging name, green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:15:45 -04:00
Paul HittandClaude Opus 5.5 2827c39c73 Numeric linking: the offer shows at once, and the state follows the link
Found linking an emulator to an iPhone account by the numeric fallback
(17.3), against a local relay:

- The offer arrived and was held, but the banner that asks the person to
  compare the number reads held offers only on a refresh, and the poll
  refreshed only after a tick that touched a thread. A link offer touches
  none. The poll tick now refreshes when the held offers change.
- The existing device sends its snapshot right behind the offer. The new
  device, still waiting for the person, dropped it as traffic from a
  device it had not linked ("DROPPED sync_state from own device ... which
  is not linked"), so the confirmed link held no contacts, blocks or
  flags. acceptHeldLink now asks the offering device for its state
  (sync_state with want), which both apps answer with a snapshot.

History sent behind a numeric offer is still dropped; only the QR path
backfills history.

LinkingAndSyncTest "the numeric fallback still hands the new device the
account's state" and ALinkOfferShowsWithoutAMessageTest: red, then green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:34:11 -04:00
Paul HittandClaude Opus 5.5 0c1cf8b7ec A fresh install pins the relay key on first sight, so it can be introduced
Found on the emulator against a local relay (relay main plus the wave 0b
descriptor switches): a new Android account adding an iPhone account by
username failed with "The server's answer about that account could not be
verified, so nothing was sent." The handle certificate (14.5) is checked
against the pinned relay key, and Android pinned that key only when it
first verified a key-log head, which a fresh install had not done yet.
FakeRelay pinned every device by hand, so no test saw it.

Trust.relayKeyPinnedOnFirstSight pins the descriptor's key when nothing is
pinned, on the same RelayIdentityPin verdicts as the head check; a key
that changed still verifies nothing until the person accepts it. iOS pins
on first sight at start-up (checkRelayIdentity).

FirstContactTest: "a fresh install pins the relay key on first sight" red,
then green; "a changed relay key verifies no certificate" green both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:36:09 -04:00
Paul HittandClaude Opus 5.5 0258846b5d Tests: answer the relay descriptor outside the queue; follow 0.32's block
The full suites had not been run on this branch with --rerun-tasks. With
the vectors given (FLAMENET_*_VECTORS, flamenet-e2e 5a5b7e7):

- 20 client and 6 app tests queue exactly the answers their route needs on
  a MockWebServer. Since 0.32 a poll and a directory read first read the
  relay descriptor, which took the queued inbox and left the real read
  waiting for 10 seconds. NoDescriptor answers the descriptor with 404
  (every switch off, the behaviour these tests describe) outside the
  queue, and request counts skip it.
- SecondDeviceCallTest looked for `private fun sendPlaintext` and an
  `emptyList()` prelude; it is internal now and the preludes are
  `if (toSelf) null`.
- AcceptingKeysBringsTheMessagesBackTest counted forgetHeldEnvelopes in
  the view model; a block now drops them in Conversations.blockContact
  through Session.block.

engine 758/758, client 568 (30 live tests skipped, no relay), obfs4 75
(interop against lyrebird 3/3 via scripts/obfs4-interop.sh), app debug
386/386, app release 345/345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:44 -04:00
Paul HittandClaude Opus 5.5 af64b466da Anonymous reads from the first send, not the first poll
The relay's switches were applied only in poll(), so a send made before
the session's first poll read the recipient's devices and bundles from
GET /e2e/devices/{id} and /e2e/keys/{id}, naming the target in a URL, on a
relay that offers anonymous reads (spec 8.11). FirstContactTest "reads
about other people name nobody" caught it: it sends without polling and
was red on this branch. iOS reads the switch at each call.

directoryFor and bundlesFor now apply the switches first (cached, so one
descriptor read per session), and the avatar push reads devices through
directoryFor rather than the relay client directly.

New in FirstContactTest: a reusable link reaches the relay as standard
base64 SHA-256 of its 32 bytes, the link carries it as 43 characters of
unpadded base64url, and a sixth is refused and not kept, as
RoutesIntro.setContactLinks requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:44 -04:00
Paul HittandClaude Opus 5.5 6badd3e999 Contact links: one-time links on every relay
Spec 8.12: a one-time link carries a delivery token (21.4) the recipient
minted, and the first message presents it. No introduction is involved.
The Finding you section was hidden unless the relay advertised
`introductions`, which hid one-time links with it. iOS offers them on any
relay that takes delivery tokens.

contactLinkKindsOffered: one-time always, reusable where the relay keeps
them (`contactLinks`). The section shows on every relay.

OneTimeLinksNeedNoIntroductionTest: red (an empty list without
introductions), then green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:44 -04:00
Paul HittandClaude Opus 5.5 2c7fda2dc1 Web search: rows tall enough for a title, a host and a snippet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 1c3ce29f84 App: no smart cast across modules; read the Listed setting off the main thread
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 b96a63e227 Descriptor switches use the names iOS reads: introductions, anonymousReads
The relay does not advertise either yet (privacy/wave0-relay offers
padAttachments, perDeviceDigests and, on privacy/web-search, webSearch), so
the two apps have to agree on the names first. iOS 14c2c7ed reads
features.introductions and features.anonymousReads; Android now reads the
same keys. contactLinks, unlisted and webSearch already matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 bf5ad7074b App: Requests, stars and archives, linking, finding you, contact links, web search
The screens for part 2 (docs 60 and 61), on top of the client work:

- Requests (14.5, decision DC2): a stranger's conversation sits under
  Requests with Accept, Block and Delete, named by a verified certificate
  when there is one ("Name confirmed by the server") and otherwise by the
  relay's answer, marked unconfirmed. The app no longer adds strangers to
  the contact list on the person's behalf. Requests and copies from linked
  devices are quiet: they count as unread and never raise a notification.
- Pairs 2-B: acceptance and blocks seeded from the relay once, then held on
  the device; blocking is the local block, the batch revoked, the key
  rotated and pushed, and the relay's block, with an honest message when a
  part of it failed. Star and archive from a long press, starred first,
  archived under their own heading. Opening a chat moves its read mark,
  which the linked devices get; a thread read on another linked device stops
  showing unread here. An owned legacy group is sealed once a session.
- Sent-copies and history (17.2, 17.4) are filed into their threads as
  outgoing (or as the original sender's), once, never into an
  off-the-record thread.
- Your devices: which are linked, Unlink, Link this device (the QR code for
  ten minutes), Scan a device's code (confirmed before anything is sent),
  Link by number with the safety number on both screens, an optional
  history backfill, and why a linked device left the set (removed by you,
  retired by the server, or no longer listed). A held numeric offer shows
  on the new device until the person confirms the number there too.
- Finding you (8.12): Listed or Unlisted where the relay offers it, worded
  as "people can't find or add you by your username", never "secret"; the
  same choice at signup, Listed by default, with an honest message when the
  server cannot apply it. One-time and reusable contact links as QR codes,
  shared as the https form, removable.
- Contact links open from a scan, from another app (flamenet: and
  https://*.flamenet.io/add, not verified app links) or after sign-in if one
  arrived before it.
- Web search, its own screen behind features.webSearch.
- The export nudge (DA9): a single-device account with no export in 30 days.
- A send refused for having no sealed way to go says "Could not send.
  <name> needs to message you or send you a new link."

UI copy went through the avoid-ai-writing audit (two fixes). LinkingWordsTest
(2). Written while builds are paused for CI: the :app module has not been
compiled since 9322d1e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:45 -04:00
Paul HittandClaude Opus 5.5 020f974829 Client: a new contact device gets our presence token; web search; hourly log check
- Spec 8.9d: when a contact's device list gains a device (Trust records the
  addition), the next poll re-sends them our delivery-key prelude with the
  presence token we already issued, so a new phone of theirs restored
  without a backup can see our presence. Taken after the poll, never inside
  a send.
- Spec 17.3: the linked set is checked against the key log at most hourly
  from the poll.
- WebSearch and MessengerApi.webSearch for POST /api/flamenet/v1/search
  (relay branch privacy/web-search): the query in the body, never the URL;
  the relay's limits (1 to 200 characters, pages 1 to 3) checked before
  asking; an unavailable index kept apart from "nothing found"; a result
  that is not http or https dropped. Shown only with features.webSearch.

WebSearchTest (4). Written while builds are paused for CI; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 07dd805226 First contact without naming anybody: intros, certificates, links, anonymous reads
Client side of stages C2, C3, D2 and B3 (SPEC 14.5, 8.11, 8.12), each behind
the relay's descriptor switch for it, since none of the relay half is
deployed: features.intro, features.anonReads, features.contactLinks and
features.unlisted (a JSON true only). The relay branch privacy/wave0-relay
does not advertise these four yet; it must before any of this turns on.

- C2: HandleCert verification against the pinned relay key. This account's
  own certificate (GET /e2e/handle-cert, read bare or wrapped) rides as `hc`
  in the first message's prelude; a contact's is kept only if it verifies
  and names that sender. knownHandle says certified or a link's hint.
- C3: introduceByHandle (the intro challenge solved, then POST /e2e/intro)
  and introduceByLink (a one-time link's token presented as delivery_token,
  bundles read anonymously; a reusable link asks for an intro, which does
  not spend it; a link for another relay is refused). A send seals by token,
  then key, then intro. With features.intro (or sealed-only asked for),
  nothing goes out named: a refused token tries the key, a refused key tries
  ONE fresh intro by the link or handle the conversation started from (or
  the roster's handle), and otherwise NeedsIntroduction, sending nothing.
  Without the switch a first message still goes named, so 0.31 peers and
  today's relays keep working.
- D2: AnonymousRelay, a separate OkHttp client (own pool, no cookies, no
  Authorization, User-Agent "FlameNet", same obfs4 transport). With
  anonReads, directory and bundle reads about others go to /e2e/anon/* with
  the presence token they issued us as `cap`; our own devices come from
  GET /e2e/devices/me. presenceAnonymously asks in shuffled batches of at
  most eight, each after a random pause of up to 20 s.
- B3: createContactLink (one-time: a delivery token uploaded by hash among
  the others; reusable: POST /e2e/contact-links by hash, at most five),
  contactLinks, revokeContactLink; MessengerApi.discoverable and
  setDiscoverable for Listed and Unlisted.

FirstContactTest (12) against FakeRelay, which gained intros, certificates
signed by a relay key the devices pin, the intro challenge and contact
links. Written while builds are paused for CI; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 9cabe50b10 Backups carry the account state: acc, blocked, convFlags, read, dk, batches
Section 16.2.1's pending keys, written and read: who was accepted and who is
blocked, stars and archives, read marks (from the sync registers, their only
store), the account delivery key and the delivery-token batches issued.

A restore merges acc, blocked and convFlags as a union (it never unblocks
anybody), read as a maximum per thread, deliveryTokensIssued per contact with
this device's own entry winning, and adopts dk only onto a device with no
key of its own (16.1), unpublished so this device publishes it. What changes
the registers is queued for the linked devices.

BackupAccountStateTest (6) holds the round trip to the vector's `pending`
object exactly, and each merge rule. Written while builds are paused for
CI; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 ca2b63a553 Linking, sync registers and contact state on the device (17.3, 17.6, 8.9d)
Client side of stages A2 and A3 and the core of pairs 2-B:

- AccountSync keeps this account's registers (17.6). acc/, blocked/, conv/
  and read/ live only there: who was accepted, who is blocked, stars and
  archives and read marks are no longer the relay's to remember. Seeding
  from the relay happens once and is a union.
- SyncProjection mirrors the other families (roster, gname, gsecret, pin,
  audience, muted, pdk, tok, ptok/i, ptok/h, dk, link) onto the stores the
  app already had. A device compares what its stores say with the last look
  and turns each difference into an op; a change that arrived from a linked
  device is written and the look retaken, so it is never echoed, and a
  value that does not parse leaves the store alone, so it is never undone.
  Off-the-record pins never cross.
- flushSync at the end of every poll: up to 8 ops go as sync envelopes, a
  burst as one sync_state; read marks at most once per thread per 30 s. With
  nobody linked the queue is dropped. A week away sends a snapshot with
  want; 90 days away discards all but link/ first.
- Own-device traffic is sealed to the account under its delivery key.
  Everything but a link_offer counts only from a linked device; a link/ add
  is honoured only for a device the relay lists on this account under that
  key; a link/<self> rm clears this device's set.
- Linking: beginLink (the QR text, a 16-byte secret for ten minutes),
  linkDevice (relay must list the device under the shown key, the key log
  must not have retired it; then link_offer, a snapshot, optional history,
  and the op to the rest of the set), the numeric fallback held until the
  person confirms on the new device too, unlinkDevice (the device leaving
  is told), refreshLinkedSet (revoke / expire / missing, kept as notices).
  The new device adopts the linked set and the account delivery key.
- Sent-copies (17.2) for everything a person sends, one per group fan-out,
  never for off-the-record threads; history (17.4) arrives as Received.
- Token slices (21.4, A3): each of a contact's devices gets its own slice of
  the batch in its own prelude. The prelude can carry `hc` (14.5).
- Blocking is the local block, the batch revoked, the delivery key rotated
  and pushed to every other contact at once. A blocked person's messages are
  dropped on arrival. A stranger's message is marked a request; writing to
  somebody accepts them. A rekey_request drops sessions with that device and
  the batch issued to them.

FakeRelay: an in-memory relay with real mailboxes, sealed-submission
checks, contact links, intros and log entries. LinkingAndSyncTest (24), all
passing; client suite 551 with the two UrgentCallOfferTest source checks
updated for the new send signature (text may be null for a key push).
Not yet run: falsification of these tests, and the :app compile, both held
while builds are paused for CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 a5a9fef9e0 Engine: sync registers, link codes and handle certificates, held to the vectors
The pieces sections 17.6, 17.3, 8.12 and 14.5 must compute exactly as every
other engine does, before anything uses them:

- SyncRegisters: 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 more than a day ahead dropped; malformed ops ignored whole;
  unknown keys kept as ordinary registers), tombstones forgotten after 90
  days, snapshot registers merged one by one, chunking at 16 KiB.
- LinkCodes: the device-link code (flamenet:link?d=&k=&s=), both forms of a
  contact link (https://<relay>/add#... and flamenet:<fnid>@<relay>?...),
  base64url without padding, encodeURIComponent as the reference writes it.
- HandleCert: fnhandle-v1 verification against the pinned relay key, for this
  relay, for the account it is about, before exp; `|`, a wrong version and a
  uid given as text are refused before any signature check.

MultiDeviceVectorTest (10) 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.
Falsified: no tie rule for blocks, `|` allowed in a handle, a repeated key
accepted, a short secret accepted; each goes red.

.e2e-ref moves to flamenet-e2e 5a5b7e7 (privacy/sweep-2026-10), which carries
the three files; content-v1 and backup-payload-v1 changed only in their
pending sections, and the whole engine suite passes at the new pin (752).
CI exports the three new variables in unit-tests and vectors-head and gates
on them. That commit must be on the e2e remote before this branch's CI can
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 935062cfce App: compile and test what part 1 changed without a compiler
The SDK is back, so :app builds again: 358 unit tests passed before this
commit with no change needed. The three app-side changes from part 1 had no
test of their own:

- the composer's countdown text (draftLengthText, pulled out of the
  composable);
- describeFailure for each Padding.Failure, in the words iOS uses;
- when roster tags are published, and under which device id
  (Session.planRosterTags, pulled out of presenceFromTokens).

PaddingReachesTheScreenTest (6). Falsified: the Padding branch removed, the
device id sent on every relay, the countdown starting at 2,000, and an empty
first set published; each goes red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 2ba6dba70a Composer: a "/" costs one character, as on iOS
Content wrote strings through org.json's JSONObject.quote, and the two
org.json builds disagree: the phone's escapes every "/" as "\/" and writes
U+2000 to U+20FF as UTF-8, the JVM's (used by the tests) does the opposite.
So a URL cost twice on a phone what iOS counts for it, the composer reached
its limit sooner on Android, and the tests measured a writer no phone runs.

Content and Padding.composerCost now both use the engine's own writer
(Padding.Json.quote), which escapes only what JSON requires and is the same
on both. A "/" is one character, as iOS counts it. Receivers parse JSON and
read either form; the iOS vectors still parse.

New test: what the composer counts is exactly what a text adds to the
envelope, for random runs of quotes, slashes, control characters, dashes,
U+2028 and emoji. Falsified: composerCost back on JSONObject.quote, and
Content back on JSONObject.quote, each go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 49ef29aca7 A group message too long to send is refused whole, before any member has it
SPEC 23.7 says a refusal is surfaced and never swallowed, and comes before
anything is spent. Android checked each member on that member's own session,
so a text that fit one member's classic header and not another's post-quantum
first message reached the first member and was then reported as refused; the
sender's own copy was never written. iOS had the mirror of this (caught per
member, shown as delivered).

- Padding.refuseUnlessItFitsEveryHeader checks a body once against the
  largest header an envelope can carry, sealed, as iOS does. sendToGroup and
  sendGroupAttachment call it before any member (the caption before the
  upload too); the app layer's group fan-out (edits, forwards) through
  Conversations.refuseUnlessItFitsEveryMember.
- A Padding.Failure inside the per-device loop (and in the unsealed retry) is
  rethrown, not filed in Sent.failed: a group where every device failed that
  way read as "not delivered yet". Nothing for that recipient is posted.
- Edits and forwards that are refused for length now say so on screen; the
  edit puts the old wording back as before.

AGroupSendIsRefusedWholeTest (5): RED before the fix on three (the classic
member received the text; WrongLength came back as Sent.failed; the caption
case uploaded first). Falsified after: no preflight, rethrow removed, and the
pre-upload check removed each go red on their own test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 4974259e30 Per-device digests (SPEC 17.6, DA4a); a capped poll follows more (8.5)
When the relay descriptor says features.perDeviceDigests, this device
publishes its delivery-key digest once more, once per relay host, with a
vault flag set only when the relay accepts it, so a failure is tried again
on the next poll. Without it a device whose digest was overwritten under
the old one-per-account rule would never publish again, and contacts
holding its key would keep falling back to unsealed sending. No delivery
key yet means nothing to republish and no descriptor request. Roster tags
carry device_id on such a relay, so a set replaces this device's own and
not the account's; a change of relay behaviour alone republishes them.

A poll the relay cut short at its byte cap ("more": true, read strictly)
is followed at once from the new cursor without holding, up to 16 pages,
on the home relay and every extra mailbox. Padding makes the cap far
more likely to bite. Same bound as iOS.

Tests: PerDeviceDigestsAndPollMoreTest (9): republished once and only when
the relay says so; a failed republish retried; device_id only when asked
for; features read strictly; a page with more followed at once; a page
without more is the whole inbox; a relay that always says more cannot hold
the poll; only a JSON true counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00
Paul HittandClaude Opus 5.5 349bfb8b6d Own-device traffic to linked devices only (SPEC 17.3, A1)
Everything this device sent to its own account went to every device on
it, so a device signed in by anybody holding the password received story
copies, group names and keys, and the offer to take over a call.

The vault now keeps a linked-device set: device id to the base64 IK_sig it
was linked under. It is sealed like the rest of the vault and never
exported in a backup. It starts empty, and nothing in this release adds to
it: linking is stage A2. A send to this account goes only to devices in
the set, matched on id AND key, so a relay listing a linked id under
another key is not believed. The key is checked twice: against the
directory before anything is fetched, and again in guardIdentity on the
bundle the session is actually opened with, because the key route could
serve the same id under another key; that mismatch is refused. With no
linked device nothing is fetched, so no unlinked device's prekey is spent.

The one exception is by design and matches iOS: a call end saying
answered-elsewhere or declined-elsewhere still goes to every device of the
account, because the caller rang them all. Receiving, a HANDOFF or
HANDOFF_TAKEN from an own device that is not linked is dropped (SPEC 6.30).

Consequence until A2 ships, as on iOS: call handoff, story copies and group
names no longer reach a second device of the same account.

Tests: OwnDeviceTrafficGoesOnlyToLinkedDevicesTest (9): none linked
reaches nobody and fetches no bundle; the linked device and not the
password one; a linked id under another key in the directory, and on the
key route; the stop-ringing signal to every device; any other call signal
and group names linked-only; a handoff from an unlinked own device dropped,
from a linked one delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:20 -04:00