1061 Commits

Author SHA1 Message Date
jack
eca147191c
Add 26 code-referenced keys to the catalog + a test that closes the gap (#1654)
LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.

- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
  literal String(localized:) keys and fails on any key absent from
  both catalogs. (The audit's original count of 17 was itself an
  undercount — its scan missed multi-line String( localized: calls;
  the test's regex does not.)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:03:23 +02:00
jack
dba67c1466
Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout (#1653)
* Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout

The file-local waitUntil defaulted to 3s — below the documented
minimumSettleTimeout floor — and the recent-chats geo-dedup wait
missed it on a loaded runner right after merge (the Combine hop
through receive(on: .main) was starved). Every caller waits for a
condition to become true, so green runs return immediately and never
pay the 30s deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix the real geo-dedup race: re-assert tracker state on each poll

The 30s deadline didn't help — the recent-chats geo-dedup wait wasn't
slow, it was permanently broken under parallel swift-test load: the
view model's own channel binding delivers its initial .mesh selection
asynchronously and resets the active participant geohash when it lands
(GeohashSubscriptionManager → setActiveParticipantGeohash(nil)),
wiping the test's setup so the dedup could never happen. Reproduced
locally with `swift test --parallel`; standalone runs never hit it.

The wait now re-asserts setActiveGeohash + recordParticipant on every
poll (both idempotent) — the same self-healing pattern the geohash
timeline test already uses for singleton interference. Interference
heals on the next poll; a genuine dedup failure still times out.
6× full parallel runs green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:12:50 +02:00
jack
5b592c8bae
Add a persistent connectivity banner and stop the radar lying (#1597)
* Add a persistent connectivity banner and stop the radar lying

Two "the app looks fine while it isn't" problems from the UX audit:

- Bluetooth-off had exactly one signal: a dismissible alert. Once
  dismissed, the app looked completely normal — empty timeline, radar
  sweeping "searching for people" — while the radio was off. The radar
  animation now requires a radio that can actually scan, and a
  persistent red banner under the header carries the state instead:
  off (tap → settings), denied (tap → settings), or unsupported
  (mesh unavailable; location channels still work).
- A Tor bootstrap stall was only ever announced inside geohash
  timelines (addGeohashOnlySystemMessage), so someone sitting in #mesh
  or a DM whose messages route over Nostr got silence and hanging
  "sent" states. The stall now also drives a chrome-level torBlocked
  flag and the same banner: "tor can't connect — internet features are
  paused. mesh still works."

The banner renders nothing when everything is healthy, and .unknown/
.resetting radio states deliberately stay quiet — a false "bluetooth is
off" flash at launch would be the same kind of lie. Priority and
quiet-start behavior are pinned by ConnectivityIssueTests. 4 new
strings, all 30 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: banner in the sheet, real fix targets, tor-clear tests

- The people/DM sheet covers the root header, so the connectivity
  banner vanished exactly where people sit longest. It now mirrors
  into the sheet via a top safe-area inset over both the people list
  and the DM view.
- "tap to fix" for powered-off Bluetooth opened the macOS privacy
  permission pane, where an already-authorized person can't fix
  anything. New SystemSettings.bluetoothPower routes to the Bluetooth
  pane; denied still goes to the privacy anchor. The one-shot alert's
  settings button routes by state the same way.
- Tests pin that torBlocked clears on tor-ready and on preference
  change (no stale "tor can't connect" after toggling tor off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:47:53 +02:00
jack
66fdea52ae
Make the panic wipe confirmable and its outcome visible (#1588)
* Make the panic wipe confirmable and its outcome visible

The triple-tap panic wipe fired instantly with no confirmation, on the
same 18pt logo whose single tap opens App Info — while the far less
destructive triple-tap-to-clear-chat 40pt below it asks first, and the
Settings-pane panic button has always confirmed. A fumbled tap on the
app's only help affordance could permanently destroy identity, keys,
and messages.

- Triple-tap now raises the same confirmation dialog the Settings pane
  uses (one extra tap under duress; zero accidental wipes).
- A successful wipe leaves a "all data wiped — new identity created"
  system message: in duress, "did it work?" must not be a guess — the
  natural response to uncertainty is tapping again.
- A wipe that does not commit (panicRecoveryBlocked) was previously
  invisible: the app looked wiped, sat silently offline, and keys could
  still be on disk. It now shows a persistent red banner under the
  header saying data may remain and a relaunch retries.
- Settings/privacy copy no longer says "instantly" (all 30 locales).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: dismiss sheets on wipe; host the dialog durably

- Codex P1: the App Info danger-zone wipe left its sheet presented, so
  the failed-wipe banner (and the success message) rendered underneath
  an opaque modal. panicClearAllData now dismisses all chrome-owned
  presentation first; pinned by a new architecture test.
- Review nit: the confirmation dialog moved from the logo Text to the
  ContentView header stack, so a covered/removed header can't take a
  pending dialog down with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:31:45 +02:00
jack
c6cb4210e4
Add a recent-chats section so DM conversations can't become unreachable (#1598)
* Add a recent-chats section so DM conversations can't become unreachable

The largest IA hole from the design/UX audit: a direct conversation
with someone who went offline (and isn't a mutual favorite) had no row
anywhere in the UI. The roster filters to connected/reachable/mutual-
favorite, the header envelope only exists while unread, and /msg can't
resolve offline strangers — read a DM from a passerby, close the
sheet, and the thread was still in memory with no way back to it.

- PeerListModel now derives RecentChatRow entries from the
  ConversationStore's direct conversations: people absent from the
  rosters above, newest activity first, unread flag, deduplicated by
  fingerprint across ephemeral/stable peer-ID mirrors (newest wins),
  groups and blocked peers excluded.
- A "chats" section (same header shape as #mesh/groups) renders them
  in the people sheet on both mesh and location channels — geoDMs from
  a channel since left stay reachable too. Tapping reopens the DM via
  the existing startConversation path; the section renders nothing
  when empty.
- Rebuilds are driven by direct-conversation store changes only, so
  public-timeline traffic doesn't churn the sheet.

2 new strings, all 30 locales. New architecture test pins that an
offline stranger's thread gets a row while group threads don't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: geo dedup and geoDM names in recent chats

- A person still visible in the geohash roster could also get a recent
  chat row (the builder only subtracted mesh rows). Visible geohash
  pubkeys now join the identity set via their PeerID(nostr_:) form, so
  the absent-from-rosters contract holds on location channels too.
- GeoDM rows went through resolveNickname, which only consults mesh
  identity sources and rendered nostr_… keys as an opaque anon
  fallback. They now resolve through geohashDisplayName (Nostr key
  mapping, bare-key fallback).

Both pinned in the recent-chats architecture test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 07:58:18 +02:00
jack
4f30433406
Remove the public-channel screenshot broadcast (#1596)
* Remove the public-channel screenshot broadcast

Taking a screenshot used to announce "* nickname took a screenshot *"
to the entire active public channel — a mesh broadcast, or on geohash
channels a mined ephemeral event published to the 5 nearest Nostr
relays, permanently timestamping that this nickname was present and
active at that place. Documenting something (or someone) is a core use
of a protest app; it must not out the person doing it.

- Public channels (mesh and geohash) no longer send anything on
  screenshot. The mined-event helper is deleted and the lifecycle
  context trimmed of its now-unused requirements.
- A geohash timeline screenshot instead raises the existing local
  location-privacy warning alert (same one the channel sheet uses) —
  warn the person, tell no one.
- DM screenshot notices remain, and the local "you took a screenshot"
  echo is now honest: it only appears when the notice actually went to
  the peer (it used to render even when no established session existed
  and nothing was sent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: pin the screenshot routing table

The geohash warn path lived only in AppRuntime with no coverage. The
decision is now a pure nonisolated table
(AppRuntime.resolveScreenshotResponse) pinned by tests: location sheet
or geohash timeline → local privacy alert (nothing sent), App Info →
nothing, mesh/DM → chat layer (where public channels stay silent).

Mesh deliberately gets no local alert: a mesh screenshot reveals no
place and triggers no send, so there is nothing actionable to warn
about — alerting on every screenshot would train people to dismiss the
one alert that matters. Rationale now documented on the response enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 07:57:25 +02:00
jack
4122a4dacd
Stop claiming encryption and privacy properties that aren't live (#1595)
* Stop claiming encryption and privacy properties that aren't live

Two of the app's most safety-critical signals could show more security
than actually existed:

1. getEncryptionStatus was sticky-optimistic: any peer whose fingerprint
   was ever persisted resolved to noiseSecured/noiseVerified even when
   the live Noise session was .none, .handshaking, or .failed. After a
   cold launch or a handshake failure the DM header showed a solid lock
   and the composer caption claimed "end-to-end encrypted" with no
   secure session in existence. The status now reflects only the live
   session; a remembered fingerprint decides what an established
   session upgrades to (verified vs secured), nothing more. Peers
   reachable only over Nostr keep the encrypted caption — gift-wrapped
   delivery is end-to-end without a Noise session.

2. Privacy copy told three lies (all 30 locales fixed):
   - "ephemeral identity — new peer ID generated regularly": the peer
     ID is stable until a panic wipe (rotation is spec-only). Now
     "local-only identity — your identity is a keypair created on this
     device; panic wipe replaces it instantly."
   - "no servers, accounts, or data collection": location channels and
     internet delivery go through Nostr relays. Now "no accounts, no
     phone numbers, no data collection."
   - location channels: "your IP address is hidden by routing all
     traffic over tor" — tor is a toggle. The description now says tor
     is the default, and the sheet shows the existing red tor-off
     warning at the point of joining whenever the toggle is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: gate nostr availability on a stored recipient key

- Codex P2 / review: a mutual favorite with a nil peerNostrPublicKey
  still reported .nostrAvailable, so the DM caption could claim
  "end-to-end encrypted" with neither a Noise session nor a usable
  Nostr key. BitchatPeer.connectionState (and the header fallback in
  PrivateConversationModel) now require a stored recipient key —
  mirroring NostrTransport's reachability rule. Regression test:
  mutual + nil key → .offline; key present → .nostrAvailable.
- Copy: "local-only identity" → "device-local identity" (all 30
  locales) — once a fingerprint or QR is shared it isn't "only" local;
  device-local says where the keys live without over-claiming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 07:56:33 +02:00
jack
6ef3945179
Deflake gift-wrap tests: settle deadlines, not latency budgets (#1651)
* Deflake gift-wrap tests: settle deadlines, not latency budgets

The gift-wrap round-trip tests failed three CI runs this week
(handleGiftWrap_privateMessageStoresConversationAndMapping,
handleGiftWrap_deliveredAckUpdatesExistingMessage,
handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with
the same signature: the async NIP-17 unwrap missed a 5s wait on a
loaded runner. 40 local iterations of both suites pass clean — the
failures are scheduler starvation, exactly the class
TestConstants.settleTimeout documents.

Every positive wait in ChatViewModelExtensionsTests and
ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the
explicit 5.0s literals on the gift-wrap waits, the longTimeout media
waits, and the bare-default channel-switch waits. All are
expected-true waits, so passing runs return immediately and never pay
the deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake SimulatedMesh TTL budget: settle discovery before the baseline

publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its
frame baseline after a single 2s advance, but discovery is not quiet
by then: every first-seen peer schedules an afterglow re-announce at a
random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can
cascade another relay round. Whether that traffic lands before or
after the snapshot depends on the draw — CI measured the "single
message" at 14 and 18 frames against a budget of 12.

The test now advances until the mesh goes a full window with no new
frames before taking the baseline, so the budget only ever measures
the message under test. 30 local iterations green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 07:56:12 +02:00
jack
f0249d9cd7
Make the Periphery scan blocking (#1648)
The job shipped with its own promotion condition — "drop
continue-on-error once the baseline proves stable" — and the baseline
has now been stable across a month of merges since it landed July 8
(#1410). Known macOS-scan false positives stay suppressed by the
committed baseline; --strict already fails the step on NEW dead code,
so the only change is that the failure now blocks instead of annotating.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:39:03 +02:00
jack
6f961638f2
Timestamp sanity windows: future-dated QR codes and implausible Nostr DM rumors (#1647)
Two re-assessment findings:

- verifyScannedQR checked only staleness (now - ts > maxAge), so a
  future-dated timestamp bought a QR a longer validity window than a
  fresh one. The freshness check is now symmetric.

- Inbound Nostr DMs had no client-side created_at validation; the age
  bound relied entirely on relays honoring the subscription's `since`
  filter. Both gift-wrap decrypt paths now drop rumors outside
  [now - lookback - skew, now + skew]. The inner rumor timestamp is the
  sender's true send time (only the outer gift wrap is randomized per
  NIP-17), so the window mirrors exactly what an honest relay already
  guarantees — a dishonest relay can no longer inject stale or
  future-dated DMs. Existing mitigations (persistent gift-wrap dedup,
  relay-side since) are unchanged; this closes the malicious-relay gap.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:39:00 +02:00
jack
2a9fb4d53f
Make SwiftLint blocking in CI now that the violation backlog is zero (#1646)
* Make SwiftLint blocking in CI now that the violation backlog is zero

The lint job has been advisory since #1361, with the backlog tracked in
#1088. That backlog is now down to 28 violations, so this clears it and
flips the job to enforcing instead of adding baseline machinery:

- Fix all remaining violations: closure parameters/capture lists joined
  onto their opening-brace line (BLEService, NoiseSessionManager,
  ChatMediaTransferCoordinator, BLENoisePacketHandlerTests), one
  trailing comma, one opening-brace spacing (both via swiftlint --fix).
- Move the two periphery:ignore comments above the doc blocks they were
  orphaning; verified with a periphery --strict scan that the commands
  still register (no new findings vs baseline).
- Disable the todo rule: the single TODO cites tracked issue #1434 and
  is a deliberate marker, not lint debt.
- Exclude .device-lab from local runs (untracked; CI never sees it).
- CI: drop continue-on-error and the "(advisory)" label; run with
  --strict so any new warning fails the job.

Full SwiftPM suite green (2,004 tests / 216 suites) after the
formatting changes; swiftlint lint --strict exits 0 with the same
0.65.0 the CI container pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix is_disjoint violation introduced by #1542

The strict lint gate caught this on its first run: the mention-suggestion
key handler used modifierFlags.intersection([...]).isEmpty, which is
exactly the pattern isDisjoint(with:) expresses directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:12:30 +02:00
jack
9c84d4ce4f
Fail closed on unverifiable handshake peer IDs + check SecRandomCopyBytes results (#1645)
Two hardening fixes from the repo evaluation:

- NoiseSessionManager.authenticatedRemoteKey returned true for peer IDs
  that are neither 16-hex wire IDs nor full Noise-key IDs — an
  accept-any-key fallback kept for test harnesses. It now fails closed;
  the Noise/integration/E2E tests that relied on it address peers by
  key-derived wire IDs instead (the pattern NoiseCoverageTests already
  used), and a new regression test pins the rejection.

- Four SecRandomCopyBytes call sites discarded the return status. The
  two verification nonces now fail their operation on error, the Nostr
  device identity seed uses CryptoKit key generation (cannot fail, and
  can no longer silently persist an all-zero seed), and BIP-340 aux
  randomness throws on failure like the adjacent nonce path.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:12:27 +02:00
Taksh Kothari
1f59e814f9
Keyboard navigation for @-mention suggestions (#1542)
* Tab and arrow keys for mention autocomplete

Highlight the selected suggestion and let Tab accept it. Up/down move
the selection when the mention panel is open; Tab otherwise still
cycles focus.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Retrigger CI after flaky GeoRelayDirectory iOS test

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: navigate mention suggestions with the same macOS key monitor as commands

Arrow keys never reach SwiftUI while the composer field editor has
focus, so adopt the NSEvent local monitor from #1504. Align accept
keys (Return or Tab), add Escape to dismiss, and match highlight opacity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix dead key monitor: gate on live state, not a value-captured Bool

A synthetic-event harness against the extracted modifier showed the
realistic path broken: the panel is hidden when the composer appears, so
onChange(of: isActive) ran on the previous render's modifier value and
installed a monitor whose closure had captured isActive == false — it
passed every key through forever. Arrows/Tab/Escape never worked on a
real Mac.

Install the monitor once for the view's lifetime and gate each event on
an isActive closure that reads the reference-typed model live. Same fix
applies to the iOS onKeyPress guards for consistency. Harness now passes
all paths including deactivate/reactivate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:51:31 +02:00
Taksh Kothari
0152344554
feat: verification seal on private message sender rows (#1573)
* feat: show verification seal on private message sender rows

DM-only filled seal next to verified peers' names closes the remaining
UI gap from #1439 without dressing public mesh timelines.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: skip in-string verified ✓ on private rows with SF Symbol seal

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add verified_sender catalog entry (30 locales) + live seal repaint on verify

The accessibility label existed only in code, so VoiceOver read english
in 29 locales; the coverage test can't see source-only keys. Also forward
peerIdentityStore.$verifiedFingerprints into objectWillChange so toggling
verification repaints rows in an open DM instead of waiting for the next
unrelated invalidation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:51:28 +02:00
Taksh Kothari
681c180060
feat: share location channel invites via the system share sheet (#1513)
* feat: share location channel invites via the system share sheet

Adds text-first geohash invites (deep link + App Store URL) from
channel rows and the active-channel header, with an OpSec warning
for neighborhood-or-finer cells.

Closes #1497

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: add Localizable.xcstrings entries for channel share copy

Cover all six new share/done keys across the 30-locale catalog so
LocalizationCoverageTests and non-English builds stop falling back
to English defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI after unrelated VoiceRecorder flake

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 10:23:41 +01:00
Taksh Kothari
c66015d029
feat: local alias field on the fingerprint sheet (#1507)
* feat: let users set a local alias on the fingerprint sheet

Wire the existing localPetname field to a write path so peers can be
renamed locally; read paths already prefer the alias when present.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: make local aliases visible and localize the fingerprint field

Give local petnames display precedence over announced nicknames across
peer rows, DM headers, and resolveNickname; rebuild peer state after a
save so lists update immediately. Load the alias draft when the
fingerprint arrives, and add the three local-alias strings across all
30 locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: use macOS 13-compatible onChange for fingerprint alias sync

The two-parameter onChange API requires macOS 14; match the rest of the
views so release and Periphery builds stay green on the 13.0 deployment target.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 10:23:14 +01:00
Rod Bahmanyari
68edb34469
Location channels: locale-derived one-tap quick join (#1444)
The channels sheet grows a "quick join" row: one tap into the region-level
geohash channel around the device region's main population center — the
same path as typing the geohash and teleporting, no location access used.

Review of the first cut (a curated "heavily censored countries" roster)
called out that a hand-picked list invites inclusion disputes, goes stale
with politics, and is App Store / geopolitical exposure. The suggestion now
derives from the device locale under one neutral rule for every country:
ISO region → the 2-char geohash cell of the main population center (the
largest metro, not always the capital: US → New York, TR → Istanbul).
219 regions covered; the twelve cells the earlier roster shipped were
independently verified in review and reproduce unchanged.

The caption is deliberately blunt, per the same review: the cell belongs
to the main population center — it is not "your country's channel" and
not your location; the channel is public and well-known, so assume it is
watched; quick join is discovery — it hides nothing and bypasses nothing.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:22:26 +01:00
Felix-Ayush
2c9a4e07c6
Localize hardcoded macOS image picker copy. (#1478)
MacImagePickerView used English string literals, so it skipped the string catalog and localization coverage. Move the user-facing strings into Localizable.xcstrings for all supported locales.

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-08-01 10:03:52 +01:00
Felix-Ayush
8f9489790d
Add macOS camera QR scanning for peer verification (#1477)
* Add macOS camera QR scanning for peer verification.

Mac verification previously only supported paste/validate. Reuse the same AVCapture metadata pipeline as iOS, keep paste as a fallback, and extend the scanner smoke test to macOS.

* Grant sandboxed macOS camera access for QR scanning.

NSCameraUsageDescription alone is not enough under App Sandbox. Add com.apple.security.device.camera so the new macOS AVCapture QR path can open the camera after user permission.

* Check camera auth before capture input for QR scanning.

Consult AVCaptureDevice.authorizationStatus before creating AVCaptureDeviceInput so smoke tests and cold launches do not trigger TCC prompts, gate the smoke test on prior authorization, and show a one-line hint when the camera is unavailable.
2026-08-01 10:03:48 +01:00
jack
948d6a85b9
Peer ID rotation: working primitives + spec for iOS/Android review (#1487)
* docs: specify peer ID rotation for cross-platform review

Draft protocol spec for review by both iOS and Android before any
implementation. Nothing here is implemented; this is the artifact to
agree on, since the change is a wire revision neither platform can ship
alone.

The headline correction, because it is easy to get wrong: rotating the
peer ID alone accomplishes nothing. The announce carries the Noise static
key, the Ed25519 signing key and the nickname in cleartext, so a rotated
ID is re-linked to the same device on its first announce. Rotation and
announce confidentiality have to land together.

The second thing an implementer needs to know up front is that
peerID == SHA-256(noiseStaticKey)[0..8] is not a convention, it is the
mechanism that makes peer IDs unforgeable, enforced in the announce
preflight and again at handshake completion. Making IDs independent of
the key fails both checks for every peer, so a replacement binding has to
ship in the same change. The spec proposes one: an Ed25519 proof over
(context, epoch, rotating ID, static key) carried inside the completed
Noise session via the existing AuthenticatedPeerStatePacket, checked
against a pinned signing key — strictly stronger than today's
self-signed announce.

Design summary: hour-epoch IDs derived from private key material via
HKDF+HMAC so no observer can predict or link them; pairwise recognition
tags from the X25519 shared secret so mutual favourites still recognise
each other with no handshake, padded to fixed slots so the tag count does
not leak how many favourites someone has; strangers discovered by
handshake-first-identify-second over Noise XX, whose static keys are
already encrypted on the wire. Nickname moves inside the session and the
neighbour list is dropped rather than rotated.

Includes a verified impact inventory separating what breaks hard (the
handshake check, the announce preflight, the disk outbox keyed by peer ID,
private-media stable IDs and their deletion tombstones, the initiator
tie-break, fingerprint-prefix lookups) from what degrades gracefully and
what is already safe because it keys on fingerprints or Noise keys.

Rollout uses the two mechanisms already proven in this repo: a
PeerCapabilities bit (11 is next; 10 is burned) with
capabilitiesWereExplicitlyAdvertised to tell an old client from a new one
with the bit off, and observed-version gating as used for source routing.

Two findings surfaced while writing this and are recorded in the spec.
CourierEnvelope.recipientTag is HMAC keyed on the recipient's *public*
static key, and since that key is broadcast in cleartext today, any
observer in radio range can compute a peer's courier tags for any day —
so the whitepaper's "cannot link it across days" does not currently hold,
and the pattern must not be copied. And NoiseEncryptionService's
buildAnnounceSignature/verifyAnnounceSignature/canonicalAnnounceBytes are
present but production-dead, called only from tests; the binding above
deliberately uses a different context string so the two can never be
confused.

Eight open questions are left explicitly unresolved, including the
rotation period, whether unsigned v2 announces are an acceptable posture,
and whether Android's decoder tolerates trailing bytes the way iOS's does
(which decides whether padding coverage can ship ungated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Implement the peer ID rotation primitives

Code is a better thing to argue with than prose, so the spec now has a
working, tested base under it. Every number and context string is a
concrete proposal you can reject by changing one function and watching a
test vector move.

What is implemented:

- PeerIDRotation: hour epochs with a ±1 matching window, the rotation
  secret from the Noise static *private* key, per-epoch peer IDs, pairwise
  recognition keys and tags from an X25519 shared secret, the fixed-width
  tag block with CSPRNG padding and constant-time matching, and the
  canonical bytes for the identity binding.
- AnnounceV2Packet (announceV2 = 0x05): TLV wire format carrying an epoch,
  a 64-byte tag block, capabilities and an optional bridge cell — and
  nothing else. No nickname, no public keys, no neighbour list. Rejects a
  wrong-width tag block on both encode and decode, since a short block
  would disclose how many mutual favourites someone has, and rejects
  non-canonical capability encodings the way AuthenticatedPeerStatePacket
  does. Unknown TLVs are skipped for forward compatibility.
- 37 tests, three of which are hex vectors cross-checked against an
  independent implementation written from the spec alone (Python
  hmac/hashlib, HKDF extract-then-expand, empty salt) and matching byte
  for byte. That is the property Android needs: the document is sufficient
  to reproduce the numbers without reading this code.

What is deliberately NOT implemented: nothing emits a v2 announce, and
BLEService parses the type and explicitly ignores it. Consuming presence
needs both the replacement identity binding and a decision on how
unverified presence appears in the peer list, and accepting it now would
put unauthenticated entries in front of people.

Adding the message type forced three policy decisions, all reviewable:

- Not gossip-synced. Syncing presence would defeat the point — a device
  never in radio range could collect tag blocks, turning a local beacon
  into a network-wide one.
- Not padded. At ~75 bytes the smallest bucket would triple the airtime of
  the most frequent packet in the protocol; the format is already
  near-constant width, and fixing the capability and geohash field widths
  would be cheaper than padding.
- Parsed but ignored on receive, as above.

Notably the v2 announce is *smaller* than v1 (~75 vs ~229 bytes): dropping
two 32-byte keys, the neighbour list and the signature more than pays for
64 bytes of tags, so unlinkability here costs less airtime rather than
more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Mark the rotation primitives periphery:ignore

The dead-code scan correctly flagged both new types as unused, which they
intentionally are: they exist to be reviewed and argued with before the
protocol change they belong to can ship.

Annotated in place rather than added to .periphery.baseline.json so the
reason sits next to the code and disappears with it, following the
existing convention in MessageRouter. Both notes say to delete the
annotation once the mesh starts using the type.

`periphery scan --strict` locally: no unused code detected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix two P1 flaws in the recognition tag design (Codex #1487)

Both findings are correct and both were real. This is the argument for
shipping code next to the prose: neither was obvious in the design text.

**Tags were symmetric, which leaked the social graph.** `HMAC(K_AB, epoch)`
produces the same 8 bytes for both parties, so an observer who saw one
value in two different announces would learn those two devices are mutual
favourites, and could link their two rotating IDs to each other — handing
over exactly the graph the design exists to hide, plus a cross-epoch
correlation handle. Tags are now directional: the MAC covers the ordered
sender and recipient static public keys, so A→B and B→A differ. Both
parties can still compute both directions because both hold both keys.

**Tags were replayable under any ID.** A tag depending only on
(pair, epoch) could be lifted from a recorded announce and replayed in a
fresh announce under an attacker-chosen ID; the recipient would match and
treat that ID as the favourite, and since epoch-1 is accepted it would
keep working into the next period. The MAC now covers the announced peer
ID, which reduces this to replaying the victim's own presence.

That residual is unfixable while announces are unsigned, so the spec now
states plainly that recognition is a hint only: presence may be populated,
but routing a DM or showing a verified badge must wait for a handshake
whose static key equals the favourite that produced the match. O4 is
rewritten around that, with the two alternatives named (per-epoch
ephemeral signing key, or a freshness nonce echoed by the recipient).

Tests: two regression cases named for the findings, plus a
wrong-direction-does-not-match case so the directional fix cannot silently
become cosmetic. The vector table now gives both directions, because their
difference is the security property — an implementation that produces one
value for both has reintroduced the flaw. Recomputed independently in
Python from the spec and matched byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: record that padding changes are cross-platform coordinated

O7 began as a question about whether Android tolerates trailing bytes.
The firmer answer, found while attempting the padding fix unilaterally:
toBinaryDataForSigning encodes with padding enabled, so the padding bytes
are inside the signed material for every signed packet. Changing the
algorithm changes the signed byte stream and breaks verification against
any peer that has not made the identical change.

So both outstanding padding fixes — coverage beyond Noise frames, and the
gap where a frame needing over 255 bytes of padding ships unpadded — are
wire changes requiring both platforms, not local cleanups. O7 now says so,
and names the two things to settle.

Also updates the related-work section: dropping the neighbour list and
randomizing origin TTL did turn out to be unilateral and have landed
separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the review findings on the rotation spec

**P1 — the binding proof was replayable onto another session.** The §4.5
verifier checklist omitted the check that the proof's noiseStaticPublicKey
equals the remote static key the Noise session actually established. The
proof is a self-contained signed blob with nothing tying it to the session
it arrives on, so a peer M that had seen A's proof could replay it verbatim
inside M's own session with B; B would verify A's signature, see a
well-formed binding, and on first contact TOFU-pin A's signing key against
M's fingerprint. Added as the first item in the checklist, with the attack
written out, because "signed" and "bound to this conversation" are
different properties and the difference is easy to lose in a bullet list.

**announceV2 is 0x2C, not 0x05.** 0x05 only looks free. It has been
recycled twice — announce, then bulkTransferResponse, then fragmentStart
until #446 — so an old peer could still map it to a fragment header and
misparse presence as a partial message. Values above voiceFrame = 0x29
have only ever been allocated forward, and 0x2A/0x2B belong to the courier
spray-ack work, leaving 0x2C. Confirmed never used anywhere in this
repository's history.

**Outbound priority is now stated, not inherited.** announceV2 fell through
to `default: .high`. High is the right answer — presence is small,
time-bounded to its epoch and useless once stale — but for a type nothing
emits yet, a fall-through means the choice gets made without anyone seeing
it.

**Reverted unexplained pbxproj churn.** Xcode had rewritten resource-phase
ordering and dropped a share-extension entitlements membership exception;
none of it belongs in this PR. The file now matches main byte for byte.

**O7 said "payloads" where the arithmetic is over encoded frames.** The
241-256 / 497-768 / 1009-1792 ranges are what `pad` receives, which is the
whole encoded packet, not the payload alone.

**Added O9: a seized device recomputes every past peer ID.** K_rot is
long-lived, so peerID_e is computable for any epoch by whoever holds it —
someone who seizes a phone, or pulls the static key from a backup, can go
back over historical radio captures and identify which were this device.
Rotation defends against the passive observer, not against later key
compromise. A hash ratchet would give forward secrecy for the ID stream at
the cost of state that must survive restarts, tolerate clock jumps, and
resynchronise after a gap — a real trade rather than an obvious win, so it
is written down as a question rather than silently adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: rotation capability bit is 14 now — 11-13 claimed by in-flight work

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:41:15 +01:00
jack
9edb7c26ef
Silence the four release-build warnings (#1583)
All four surfaced in the 1.7.1 RC window and are behavior-neutral:

- sendPacket(to:) discarded sendPacketDirected's Bool through generic
  onEngine, tripping unused-result (from #1547's engine-domain flip).
- Both _test_drain*Pipeline helpers captured non-Sendable self in
  @Sendable dispatch closures; they only need the queue, which is
  Sendable — capture that instead.
- removeEphemeralSession returned removeValue's result out of the
  barrier closure, tripping unused-result on sync(flags:execute:).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
v1.7.1
2026-07-31 14:29:47 +01:00
jack
6f32363774
Deflake VoiceRecorderTests: replace timed semaphores with async events (#1572)
waitUntilActivationBegan hardcoded a 5-second DispatchSemaphore timeout
— below the 10s house floor and invisible to TestTimingHygieneTests
(it's a semaphore wait, not a helper-timeout parameter). On a starved
runner the window expired before the recorder's session-acquire task
was scheduled, failing cancelWhileSessionAcquireIsInFlightNeverCreates-
ARecorder — 7 sightings, including three in the last two days (#1506
and #1528 merge runs, #1550's PR run).

The fix is extracted verbatim from #1107 (mmalmi), which carries it but
is blocked on a V3 rebase: both test gates (activation and padding)
drop their DispatchSemaphore + timeout for an untimed async-event wait
(VoiceRecorderAsyncEvent), so there is no timing constant left to
starve — the test framework's own timeout is the backstop. Extracting
it unblocks CI now; #1107's rebase will see this file already matching
its branch.

Verified (count-checked via xcresulttool): 7/7 VoiceRecorderTests on
the iOS simulator, and 7/7 x 5 consecutive runs under 16x CPU
oversubscription.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:50:24 +01:00
Vincenzo Palazzo
59a9f628df
test: pin that unknown file TLVs are skipped, not fatal (#1550)
`BitchatFilePacket.decode` skips tags it does not recognise
(`case nil: continue`), which is what keeps the TLV list a floor rather
than a ceiling: a field the sender considered optional costs the receiver
that field, not the whole file.

Nothing pinned it. The behaviour is load-bearing for any peer, version or
third-party client that adds a field this build has not seen, and it is
also where the two implementations diverge — the Android decoder returns
null on an unknown tag, which is why `PrivateMediaMessageIdentity` has to
derive its receipt key from fields already on the wire instead of adding
one. Worth a test on the side that gets it right so it cannot quietly
drift into the strict behaviour.

Two cases, both hand-built so they do not depend on our own encoder:
an unknown TLV between MIME_TYPE and CONTENT (where an encoder appending
content last would put it), and one trailing CONTENT. Changing
`case nil: continue` to `return nil` fails both.

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-07-31 11:50:21 +01:00
heyaim
7b39d72bec
Give media the explicit file-protection class other stores use (#1552)
Media payload writes used .atomic alone and inherited the container
default; the courier store, outbox, gossip archive, and receipt index
all state their protection class at the write site. Media now follows
the same convention: until-first-user-authentication on payload writes
and on every site that creates a media directory (the store's helpers,
live captures, the outgoing writers, and the files/ root creators), so
recordings that save as they go inherit it. A best-effort launch
migration stamps files written by older builds, applying only to items
at the container default or weaker so it can never downgrade, running
detached after the retention sweep from #1484. On stock devices the
container default already yields this class, so behavior does not
change; the protection is now stated in the code instead of inherited.

Full iOS suite green; macOS builds; swiftlint adds no violations.

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-07-31 11:40:02 +01:00
Taksh Kothari
3a75567f5c
fix: stop EnvironmentObject crash in the people sheet (#1567)
* fix: re-inject environment objects into the people sheet

Sheets hosting a NavigationStack can drop inherited EnvironmentObjects on
some iOS versions, crashing ContentPeopleListView / MessageListView (#1558).

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: note people-sheet environment contract in smoke mount

Make the #1558 regression visible next to the ContentView / people-sheet
smoke mounts so a future env-object trim is harder to miss.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-07-31 11:04:55 +01:00
jack
f269617004
Fix the retire↔reconnect oscillation: redundant-link survivor is the newest connection (#1566)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine

The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.

What changed shape:

- Receive path is sans-I/O: bleQueue decodes frames and hands
  (packet, linkID) up through ingestDecodedPacket (panic lifecycle
  captured at the handoff); attributeAndHandlePacket resolves the
  sender binding, rejects spoofed senders, applies raw-announce
  binding, and records ingress on the engine. Per-link frame order is
  preserved end to end (both queues serial), which supersedes the old
  batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
  retirement, binding flip, reconnect decision, and rotated-identity
  retirement run straight-line; only CoreBluetooth cancels hop to
  bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
  the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
  that used it now asserts the atomicity directly (a paused engine
  wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
  writeOrEnqueueIfAccepted) is checked on the engine, serialized
  against rebinds by construction; only physical admission
  (updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
  in the delegates) + retirePeripheralLinkIdentity (engine hop with
  survivor repair reading liveness via readLinkState). A binding can
  briefly outlive its physical link; liveness queries join against the
  physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
  mesh.sync sits above the engine in the sync order (production engine
  code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
  maintenance tick.

No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught

SimulatedMesh wires real CoreBluetooth-free BLEService engines
edge-to-edge through the outbound packet tap and _test_ingestFrame
(the production attribution path the B2 flip created), with per-edge
synthetic link IDs and manual-scheduler time. Five multi-node tests
run in ~40ms with no wall-clock waits:

- announce exchange binds simulated links and connects peers
- Noise sessions establish end-to-end (real crypto, both directions)
- a public message relays across a line topology inside a TTL/frame
  budget (storm bound asserted)
- an 8x duplicate flood delivers exactly once
- a panic rotation rebinds the survivor's link exactly once and stays
  — the scenario that previously needed two phones and log archaeology

Fidelity boundary (documented in the harness): no physical links, so
fanout planning and backpressure are not exercised; attribution,
binding, dedup, TTL, relay decisions, and sessions are the real
engine code.

The simulator found a real bug on its first run: the forced-announce
throttle's lastSent survived a panic, so a rotation within
bleForceAnnounceMinIntervalSeconds of the last announce silently
swallowed the new identity's announce — leaving it invisible to the
mesh until the next maintenance cycle. Today's device test only
passed because the previous announce happened to be minutes old.
BLEAnnounceThrottle gains reset(), called from the panic slot so the
rotated identity owes no throttle debt; pinned by a unit test and the
mesh rotation test.

New DEBUG seams: _test_ingestFrame (production ingress attribution),
_test_forceAnnounce, _test_fenceEngine.

1,980 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files

The upward half of the link-layer port is now a type. BLELinkEvent
enumerates everything the bleQueue link layer tells the engine:
frameDecoded plus the four physical lifecycle transitions
(peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded,
allCentralLinksEnded). Every bleQueue→engine crossing goes through
emitLinkEvent into one engine consumer (handleLinkEvent) — the
scattered messageQueue.async identity hops in the delegates collapse
into event emission, and the engine-side retirement/bookkeeping logic
now lives in one switch.

The CoreBluetooth delegate extensions move to their own files as
physical bookkeeping plus event emission:
- BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate +
  CBPeripheralDelegate)
- BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate
  + write accumulation)
BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain
members the role files share flip private→internal; the queue contract
is enforced by the existing DEBUG traps and grep guards, not access
control. (Two of the flips — isAppActive, logBluetoothStatus — only
surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code.
Verified with a local iOS simulator build.)

The simulated mesh now drives lifecycle events through the identical
enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers
drop → identity retirement → last-link peer bookkeeping → re-announce
heal, entirely through the port. New seam _test_resetAnnounceThrottle
models elapsed wall-clock for the throttle (deliberately separate from
_test_forceAnnounce so the panic-rotation test keeps its regression
value: the production panic path must do its own reset). The panic
test's containment re-announces reset throttles explicitly so those
assertions exercise real delivered announces instead of silently
throttled ones. noiseSessionEstablishesEndToEnd gains a bounded
scheduler-time settle loop after a one-in-many parallel-suite flake
(no wall-clock waits).

Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a
formal handle(event)->[Effect] system and further engine-domain file
splits — both would flip the engine's private state to internal for
cosmetic file counts; the effect formalization rides future feature-
module extractions instead.

1,981 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Baseline logBluetoothStatus for the macOS Periphery scan

Its callers are all inside #if os(iOS) (willRestoreState in both role
files plus the app-state handlers), so the macOS-scheme scan sees the
now-internal declaration with zero callers — the same class as the
baselined candidateCount. Verified 1-USR diff; the previously private
mangled variant was already baselined, which is why the pre-split scan
never flagged it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix #1538: release stale bindings on rotation instead of leaving a ghost

With two live links to one phone, a panic rotation healed only the link
the verified announce arrived on. The second link kept its binding to
the retired identity, so that dead ID stayed in the peer list — and was
kept alive by the NEW identity's own traffic, since a bound link
attributes non-announce frames to its bound peer. It only healed when
the stale link physically dropped.

The issue proposed exempting the containment rule via retiredBy[X] = Y
so the second link could rebind. Two problems: the exemption's stated
precondition (X removed by retireRotatedPeer) can never hold in this
scenario — the retire is gated on X having no remaining links, which is
false precisely because the stale link exists — and it would loosen a
security rule to fix a liveness bug.

Instead the rotation now RELEASES every link still bound to the
rotated-away identity (unbind + retire that link's Noise proof) and
retires the identity. No containment rule changes: unbinding is
strictly less trusting than any binding, and it is correct under both
readings of a second link bound to the retired ID — same physical
device (the field case), or one link is a spoofer holding a forged
binding, since a peer ID is a Noise-key fingerprint and two devices
cannot both legitimately own it. Released links reconverge through the
ordinary unbound-link path: the next raw direct announce binds them to
whoever they actually carry.

Reproduced and fixed under the slice-4 simulator, which is why this
lands as tests rather than another two-phone session:
- duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks fails
  without the fix (ghost in both knownPeers and getConnectedPeers,
  duplicate link still bound to the dead ID)
- replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim pins the
  #1401 containment rule against exactly the attack this fix had to
  avoid re-opening, with a positive control proving the refusal is the
  containment check and not duplicate suppression

Harness gains connectDuplicateLinks (two links to one peer, modelled in
the central role — the links we cannot cancel, and the only role whose
bindings a CB-free harness can form), silence (range loss without a
link event, so a packet can be captured that the far side never saw),
and emittedPackets (the attacker's capture buffer).

Residual, documented at the fix: an attacker who binds their own link
to X by replaying X's raw announce can drive a rebind there and so
evict X's registry entry; X's next announce restores it, and the
per-link rebind cooldown bounds the rate. This is the same class of
capability the containment already accepts, not a new one.

1,983 tests green, Periphery clean, iOS simulator build clean.

Closes #1538

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix the retire↔reconnect oscillation: redundant-link survivor is the newest connection

Field-observed July 31 on main: with a restored old-address link and a
fresh-address duplicate to the same phone, redundant-link consolidation
kept choosing the restored link as survivor (it carried the announce
ingress and the binding) and cancelling the fresh one — which the radio
promptly rediscovered and reconnected, because the fresh link sits on
the BLE address the peer still advertises. Retire, reconnect, repeat at
the retirement cooldown (~1/min) until the ingress happened to flip.
Battery and airtime noise on every restore-with-duplicates.

BLERedundantLinkPolicy now prefers the most recently CONNECTED
candidate. Only the newest connection lives on the currently advertised
address; the older-address link cannot return once cancelled, so
consolidation converges on the first pass. BLEPeripheralLinkState gains
lastConnectedAt (set by markConnected; nil for restored links, whose
connect predates the process — exactly the 'stale address' signal).

Security note: physical connect recency is a signal an announce replay
cannot nominate, unlike the previous ingress-link preference — the
announce anchors (ingress, then most recently bound) are demoted to
tie-breakers and the fallback for all-restored links. Writability still
trumps everything: a newest link mid-service-rediscovery is never kept
over a writable duplicate. Containment (bound-links-only, one
retirement per peer per cooldown, peer keeps a live link) unchanged.

Six policy tests pin the new order, including the field scenario
(restored link holding both announce anchors loses to the fresh
connection) and the legacy fallback.

1,988 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Defer consolidation while the newest connection is still mid-discovery

Codex P2 on #1566: a fresh duplicate that has connected but not yet
finished service discovery was excluded from the writable candidate
set, so the policy kept the older writable (restored) link and
cancelled the freshly advertised connection — recreating the
retire↔reconnect oscillation inside the discovery window. Now, when
the physically newest connection is not writable yet while a writable
duplicate exists, consolidation defers to a later announce instead of
guessing. Also documents that RSSI is deliberately not a policy input
(Chessing234's rule-pinning ask) and pins the defer window, the
restored-anchor variant, the co-newest writable tie, and the
all-unwritable recency path with tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:30:51 +01:00
jack
5780405dce
Fix the SimulatedMesh announce-loss flake (#1564)
SimulatedMesh.addNode installed the outbound tap one statement after
setNickname, but setNickname force-announces asynchronously on the
engine. When a starved runner let that slot run inside the gap, the
announce was emitted invisibly while still stamping the wall-clock
announce throttle, and announceAll's forced announce — arriving well
inside the 0.15s forced minimum interval — was swallowed. No discovery
traffic ever reached the mesh, so bindings stayed nil and peer lists
empty: the exact 4-issue signature that failed three main runs and one
PR run on July 30.

Reproduced deterministically by forcing the ordering with a 5ms sleep
after setNickname: all 8 SimulatedMesh tests fail on the old harness
and pass on the fixed one.

Fixes: install the tap before setNickname so an early nickname announce
is captured instead of lost; reset each node's throttle in announceAll
so wall-clock throttle debt can never swallow the discovery round
(forceAnnounce(from:) deliberately keeps no-reset — the panic-rotation
tests pin the production reset behavior through it); and take the lock
around addNode's array appends, which could race the tap reading
`emitted` on an earlier node's engine.

Verified: suite green normally, 8/8 tests x 6 runs under 16x CPU
oversubscription, and 8/8 under the adversarial forced ordering —
all count-verified via xcresulttool (an earlier single-test
-only-testing filter silently matched zero tests, so every result
here was re-checked against reported test counts).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:21:49 +01:00
jack
1d0dc58221
Make the completion-grace restart test deterministic (#1563)
immediateLegacyRestartDuringCompletionGrace injected a 0.03s initiator
completion grace period and needed the restart initiation to arrive
inside it. Constructing the restarted service (keypair generation) sits
between starting that clock and processing the message, so on a starved
CI runner the window expired first, the initiation was processed as a
legitimate fresh handshake, and the nil-expectations cascaded — the
most-sighted flake in CI (7 runs across #1502, #1477, #883, #1364, and
main).

The test now injects a grace period no test run can outlive, so the
in-grace suppression and the duplicate-initiation coalescing are decided
deterministically, and fires the deferred recovery through a DEBUG hook
on NoiseSessionManager instead of waiting out the real timer. The hook
cancels the scheduled work item before requesting recovery, so the
converged-once assertion cannot double-fire either.

Verified (count-checked via xcresulttool): the full 30-test
NoiseEncryptionServiceTests suite green on the iOS simulator, and
30/30 x 5 consecutive runs under 16x CPU oversubscription (a 6th run
was lost to a simulator app-launch refusal under load — no tests
executed). The old test did not reproduce locally in 2 suite runs
under the same load; the starvation needs the slow 2-core CI runner,
so the diagnosis rests on the mechanism plus the identical assertion
signature in all seven CI sightings.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:21:46 +01:00
krish rathi
6414a59851
fix(ble): don't spend the fragment scheduler's slot budget on blocked requests (#1530)
reservePendingStarts() decremented availableSlots for every dequeued pending
transfer before checking whether it would actually be admitted. A request
blocked because its transferId is already active (a resend of in-flight
content sitting at the front of the queue) still consumed a slot even
though it was deferred back into the queue rather than started -- so a
single blocked front-of-queue item could zero out the budget and end the
loop before ever reaching a later, unrelated, genuinely startable pending
transfer. That transfer then sat starved until some other transfer
happened to complete and trigger another pass, rather than starting
immediately when real capacity was already available.

Move the decrement to the point where a transfer is actually admitted into
activeTransfers, so only genuine starts spend the budget.
2026-07-30 18:14:57 +01:00
Vidit Kulshrestha
6c8499a603
Normalize nicknames to Unicode NFC at storage and comparison boundaries (#1502)
* Replace try! regex construction with a non-trapping SafeRegex helper

MessageFormattingEngine and MessageDeduplicationService compiled eight
bundled regex literals with try!, so a bad pattern would crash the app
at startup - in the middle of the message-render path (#645).

Add SafeRegex.compile: it compiles the pattern normally, and on failure
logs through SecureLogger and returns a never-matching regex ('(?!)'),
so a broken pattern degrades that one formatting feature instead of
trapping. Pattern properties stay non-optional, so no call-site churn
across ChatMessageFormatter, MessageTextHelpers, and
ChatComposerCoordinator.

The compile-time guarantee try! provided moves into tests: each
production pattern is asserted to compile and match a known-good sample,
so a typo in a pattern now fails CI instead of crashing users.

Part of #645 (the remaining try! sites; NoiseSessionManager's
force-unwrap is addressed separately in #1456).

* Normalize nicknames to Unicode NFC at storage and comparison boundaries

A nickname containing an accent can arrive in two canonically equivalent
but bytewise different forms: precomposed (U+00E9) or decomposed
(e + U+0301), depending on the keyboard and platform that produced it.
Nicknames were stored and compared without normalization, so visually
identical names silently failed to match: mentions of your own name did
not highlight or notify, /msg and /block could not resolve the peer,
autocomplete skipped candidates, and geohash DM resolution failed (#214).

Fix by canonicalizing to NFC (String.normalizedNickname) at every
boundary where a nickname enters storage - own nickname (ChatViewModel
didSet, alongside the existing trim), verified announce ingest
(BLEPeerRegistry), geohash presence (LocationPresenceStore), and
InputValidator.validateNickname - and by normalizing both sides at
comparison sites that can still see pre-normalization data (persisted
favorites, message-content mentions): peer resolution in
UnifiedPeerService and ChatPeerIdentityCoordinator, the three mention
checks, and autocomplete prefix matching.

The wire codec (AnnouncementPacket) is deliberately untouched: announces
are signature-verified against raw bytes, so canonicalization happens at
the storage layer, never during parsing.

Fixes #214
2026-07-30 18:14:53 +01:00
Taksh Kothari
e2bd13a7f2
chore: fix receive typo and refresh relay count in README (#1510)
Correct a confirmation label typo in the public-chat E2E suite and bump
the README relay-network claim to match the current GPS relay list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:56:52 +01:00
Taksh Kothari
e7f4ef0912
fix: show verified seal next to sender names in chat (#1506)
Surface fingerprint verification in the message timeline so a verified
contact is distinguishable from an impersonator without opening the
fingerprint sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:56:48 +01:00
Vidit Kulshrestha
4ef5558d7b
Replace try! regex construction with a non-trapping SafeRegex helper (#1501)
MessageFormattingEngine and MessageDeduplicationService compiled eight
bundled regex literals with try!, so a bad pattern would crash the app
at startup - in the middle of the message-render path (#645).

Add SafeRegex.compile: it compiles the pattern normally, and on failure
logs through SecureLogger and returns a never-matching regex ('(?!)'),
so a broken pattern degrades that one formatting feature instead of
trapping. Pattern properties stay non-optional, so no call-site churn
across ChatMessageFormatter, MessageTextHelpers, and
ChatComposerCoordinator.

The compile-time guarantee try! provided moves into tests: each
production pattern is asserted to compile and match a known-good sample,
so a typo in a pattern now fails CI instead of crashing users.

Part of #645 (the remaining try! sites; NoiseSessionManager's
force-unwrap is addressed separately in #1456).
2026-07-30 17:56:45 +01:00
Vidit Kulshrestha
81837d7202
Make DeliveryStatus non-optional with an explicit .notSentYet state (#1503)
BitchatMessage.deliveryStatus was Optional, with nil implicitly meaning
'no tracking' for public messages. Every consumer had to branch on the
absent case, ranking needed an optional-aware helper, and the UI treated
nil as an invisible state (#644).

Model delivery as a total state machine instead:
- New DeliveryStatus.notSentYet: created but not yet handed to any
  transport. Public messages initialize to it; private messages keep
  their historical .sending default.
- BitchatMessage.deliveryStatus becomes non-optional. Archives written
  while the field was optional decode with the absent key mapped to
  .notSentYet. The wire format is untouched (toBinaryPayload never
  carried the field).
- deliveryStatusRank drops its optional parameter; .notSentYet ranks
  below .failed, preserving the existing dedup preference order.
- Conversation.shouldSkipStatusUpdate treats a write back to .notSentYet
  as a downgrade and skips it.
- The status indicator renders exactly as before: .notSentYet draws
  nothing in message rows (the state nil used to represent), and
  DeliveryStatusView gains a glyph and description for it only so the
  view stays total.

Tests: initialization defaults, legacy-archive decoding, round-trip,
the extended rank order, and the new downgrade rule.

Fixes #644
2026-07-30 17:56:42 +01:00
Taksh Kothari
ab835e58c9
Don't suggest blocked people in @-mentions (#1543)
* Keep blocked peers out of @-mention suggestions

Blocked mesh nicknames and blocked geohash pubkeys no longer show up
in the composer autocomplete list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix blocked-mention test resetting private(set) state

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:56:39 +01:00
Kudala Bharani Kumar Reddy
e8f95e9a88
Fix built-in relay actor isolation (#1528) 2026-07-30 17:56:35 +01:00
Jozef Koval
b49400ff0c
Fix $$ escaping that broke every Xcode just recipe (#1525) 2026-07-30 17:56:32 +01:00
jack
e2b409e466
Fix #1538: release stale bindings on rotation instead of leaving a ghost identity (#1554)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine

The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.

What changed shape:

- Receive path is sans-I/O: bleQueue decodes frames and hands
  (packet, linkID) up through ingestDecodedPacket (panic lifecycle
  captured at the handoff); attributeAndHandlePacket resolves the
  sender binding, rejects spoofed senders, applies raw-announce
  binding, and records ingress on the engine. Per-link frame order is
  preserved end to end (both queues serial), which supersedes the old
  batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
  retirement, binding flip, reconnect decision, and rotated-identity
  retirement run straight-line; only CoreBluetooth cancels hop to
  bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
  the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
  that used it now asserts the atomicity directly (a paused engine
  wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
  writeOrEnqueueIfAccepted) is checked on the engine, serialized
  against rebinds by construction; only physical admission
  (updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
  in the delegates) + retirePeripheralLinkIdentity (engine hop with
  survivor repair reading liveness via readLinkState). A binding can
  briefly outlive its physical link; liveness queries join against the
  physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
  mesh.sync sits above the engine in the sync order (production engine
  code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
  maintenance tick.

No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught

SimulatedMesh wires real CoreBluetooth-free BLEService engines
edge-to-edge through the outbound packet tap and _test_ingestFrame
(the production attribution path the B2 flip created), with per-edge
synthetic link IDs and manual-scheduler time. Five multi-node tests
run in ~40ms with no wall-clock waits:

- announce exchange binds simulated links and connects peers
- Noise sessions establish end-to-end (real crypto, both directions)
- a public message relays across a line topology inside a TTL/frame
  budget (storm bound asserted)
- an 8x duplicate flood delivers exactly once
- a panic rotation rebinds the survivor's link exactly once and stays
  — the scenario that previously needed two phones and log archaeology

Fidelity boundary (documented in the harness): no physical links, so
fanout planning and backpressure are not exercised; attribution,
binding, dedup, TTL, relay decisions, and sessions are the real
engine code.

The simulator found a real bug on its first run: the forced-announce
throttle's lastSent survived a panic, so a rotation within
bleForceAnnounceMinIntervalSeconds of the last announce silently
swallowed the new identity's announce — leaving it invisible to the
mesh until the next maintenance cycle. Today's device test only
passed because the previous announce happened to be minutes old.
BLEAnnounceThrottle gains reset(), called from the panic slot so the
rotated identity owes no throttle debt; pinned by a unit test and the
mesh rotation test.

New DEBUG seams: _test_ingestFrame (production ingress attribution),
_test_forceAnnounce, _test_fenceEngine.

1,980 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files

The upward half of the link-layer port is now a type. BLELinkEvent
enumerates everything the bleQueue link layer tells the engine:
frameDecoded plus the four physical lifecycle transitions
(peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded,
allCentralLinksEnded). Every bleQueue→engine crossing goes through
emitLinkEvent into one engine consumer (handleLinkEvent) — the
scattered messageQueue.async identity hops in the delegates collapse
into event emission, and the engine-side retirement/bookkeeping logic
now lives in one switch.

The CoreBluetooth delegate extensions move to their own files as
physical bookkeeping plus event emission:
- BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate +
  CBPeripheralDelegate)
- BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate
  + write accumulation)
BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain
members the role files share flip private→internal; the queue contract
is enforced by the existing DEBUG traps and grep guards, not access
control. (Two of the flips — isAppActive, logBluetoothStatus — only
surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code.
Verified with a local iOS simulator build.)

The simulated mesh now drives lifecycle events through the identical
enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers
drop → identity retirement → last-link peer bookkeeping → re-announce
heal, entirely through the port. New seam _test_resetAnnounceThrottle
models elapsed wall-clock for the throttle (deliberately separate from
_test_forceAnnounce so the panic-rotation test keeps its regression
value: the production panic path must do its own reset). The panic
test's containment re-announces reset throttles explicitly so those
assertions exercise real delivered announces instead of silently
throttled ones. noiseSessionEstablishesEndToEnd gains a bounded
scheduler-time settle loop after a one-in-many parallel-suite flake
(no wall-clock waits).

Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a
formal handle(event)->[Effect] system and further engine-domain file
splits — both would flip the engine's private state to internal for
cosmetic file counts; the effect formalization rides future feature-
module extractions instead.

1,981 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Baseline logBluetoothStatus for the macOS Periphery scan

Its callers are all inside #if os(iOS) (willRestoreState in both role
files plus the app-state handlers), so the macOS-scheme scan sees the
now-internal declaration with zero callers — the same class as the
baselined candidateCount. Verified 1-USR diff; the previously private
mangled variant was already baselined, which is why the pre-split scan
never flagged it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix #1538: release stale bindings on rotation instead of leaving a ghost

With two live links to one phone, a panic rotation healed only the link
the verified announce arrived on. The second link kept its binding to
the retired identity, so that dead ID stayed in the peer list — and was
kept alive by the NEW identity's own traffic, since a bound link
attributes non-announce frames to its bound peer. It only healed when
the stale link physically dropped.

The issue proposed exempting the containment rule via retiredBy[X] = Y
so the second link could rebind. Two problems: the exemption's stated
precondition (X removed by retireRotatedPeer) can never hold in this
scenario — the retire is gated on X having no remaining links, which is
false precisely because the stale link exists — and it would loosen a
security rule to fix a liveness bug.

Instead the rotation now RELEASES every link still bound to the
rotated-away identity (unbind + retire that link's Noise proof) and
retires the identity. No containment rule changes: unbinding is
strictly less trusting than any binding, and it is correct under both
readings of a second link bound to the retired ID — same physical
device (the field case), or one link is a spoofer holding a forged
binding, since a peer ID is a Noise-key fingerprint and two devices
cannot both legitimately own it. Released links reconverge through the
ordinary unbound-link path: the next raw direct announce binds them to
whoever they actually carry.

Reproduced and fixed under the slice-4 simulator, which is why this
lands as tests rather than another two-phone session:
- duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks fails
  without the fix (ghost in both knownPeers and getConnectedPeers,
  duplicate link still bound to the dead ID)
- replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim pins the
  #1401 containment rule against exactly the attack this fix had to
  avoid re-opening, with a positive control proving the refusal is the
  containment check and not duplicate suppression

Harness gains connectDuplicateLinks (two links to one peer, modelled in
the central role — the links we cannot cancel, and the only role whose
bindings a CB-free harness can form), silence (range loss without a
link event, so a packet can be captured that the far side never saw),
and emittedPackets (the attacker's capture buffer).

Residual, documented at the fix: an attacker who binds their own link
to X by replaying X's raw announce can drive a rebind there and so
evict X's registry entry; X's next announce restores it, and the
per-link rebind cooldown bounds the rate. This is the same class of
capability the containment already accepts, not a new one.

1,983 tests green, Periphery clean, iOS simulator build clean.

Closes #1538

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:21:59 +01:00
jack
4226f01503
Link layer slice 5: BLELinkEvent — the port has a name, the delegates have their own files (#1551)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine

The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.

What changed shape:

- Receive path is sans-I/O: bleQueue decodes frames and hands
  (packet, linkID) up through ingestDecodedPacket (panic lifecycle
  captured at the handoff); attributeAndHandlePacket resolves the
  sender binding, rejects spoofed senders, applies raw-announce
  binding, and records ingress on the engine. Per-link frame order is
  preserved end to end (both queues serial), which supersedes the old
  batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
  retirement, binding flip, reconnect decision, and rotated-identity
  retirement run straight-line; only CoreBluetooth cancels hop to
  bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
  the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
  that used it now asserts the atomicity directly (a paused engine
  wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
  writeOrEnqueueIfAccepted) is checked on the engine, serialized
  against rebinds by construction; only physical admission
  (updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
  in the delegates) + retirePeripheralLinkIdentity (engine hop with
  survivor repair reading liveness via readLinkState). A binding can
  briefly outlive its physical link; liveness queries join against the
  physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
  mesh.sync sits above the engine in the sync order (production engine
  code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
  maintenance tick.

No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught

SimulatedMesh wires real CoreBluetooth-free BLEService engines
edge-to-edge through the outbound packet tap and _test_ingestFrame
(the production attribution path the B2 flip created), with per-edge
synthetic link IDs and manual-scheduler time. Five multi-node tests
run in ~40ms with no wall-clock waits:

- announce exchange binds simulated links and connects peers
- Noise sessions establish end-to-end (real crypto, both directions)
- a public message relays across a line topology inside a TTL/frame
  budget (storm bound asserted)
- an 8x duplicate flood delivers exactly once
- a panic rotation rebinds the survivor's link exactly once and stays
  — the scenario that previously needed two phones and log archaeology

Fidelity boundary (documented in the harness): no physical links, so
fanout planning and backpressure are not exercised; attribution,
binding, dedup, TTL, relay decisions, and sessions are the real
engine code.

The simulator found a real bug on its first run: the forced-announce
throttle's lastSent survived a panic, so a rotation within
bleForceAnnounceMinIntervalSeconds of the last announce silently
swallowed the new identity's announce — leaving it invisible to the
mesh until the next maintenance cycle. Today's device test only
passed because the previous announce happened to be minutes old.
BLEAnnounceThrottle gains reset(), called from the panic slot so the
rotated identity owes no throttle debt; pinned by a unit test and the
mesh rotation test.

New DEBUG seams: _test_ingestFrame (production ingress attribution),
_test_forceAnnounce, _test_fenceEngine.

1,980 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files

The upward half of the link-layer port is now a type. BLELinkEvent
enumerates everything the bleQueue link layer tells the engine:
frameDecoded plus the four physical lifecycle transitions
(peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded,
allCentralLinksEnded). Every bleQueue→engine crossing goes through
emitLinkEvent into one engine consumer (handleLinkEvent) — the
scattered messageQueue.async identity hops in the delegates collapse
into event emission, and the engine-side retirement/bookkeeping logic
now lives in one switch.

The CoreBluetooth delegate extensions move to their own files as
physical bookkeeping plus event emission:
- BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate +
  CBPeripheralDelegate)
- BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate
  + write accumulation)
BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain
members the role files share flip private→internal; the queue contract
is enforced by the existing DEBUG traps and grep guards, not access
control. (Two of the flips — isAppActive, logBluetoothStatus — only
surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code.
Verified with a local iOS simulator build.)

The simulated mesh now drives lifecycle events through the identical
enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers
drop → identity retirement → last-link peer bookkeeping → re-announce
heal, entirely through the port. New seam _test_resetAnnounceThrottle
models elapsed wall-clock for the throttle (deliberately separate from
_test_forceAnnounce so the panic-rotation test keeps its regression
value: the production panic path must do its own reset). The panic
test's containment re-announces reset throttles explicitly so those
assertions exercise real delivered announces instead of silently
throttled ones. noiseSessionEstablishesEndToEnd gains a bounded
scheduler-time settle loop after a one-in-many parallel-suite flake
(no wall-clock waits).

Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a
formal handle(event)->[Effect] system and further engine-domain file
splits — both would flip the engine's private state to internal for
cosmetic file counts; the effect formalization rides future feature-
module extractions instead.

1,981 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Baseline logBluetoothStatus for the macOS Periphery scan

Its callers are all inside #if os(iOS) (willRestoreState in both role
files plus the app-state handlers), so the macOS-scheme scan sees the
now-internal declaration with zero callers — the same class as the
baselined candidateCount. Verified 1-USR diff; the previously private
mangled variant was already baselined, which is why the pre-split scan
never flagged it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:27:34 +01:00
jack
cdebdd9347
Link layer slice 4: deterministic multi-node mesh simulation (and the panic-announce bug it caught) (#1548)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine

The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.

What changed shape:

- Receive path is sans-I/O: bleQueue decodes frames and hands
  (packet, linkID) up through ingestDecodedPacket (panic lifecycle
  captured at the handoff); attributeAndHandlePacket resolves the
  sender binding, rejects spoofed senders, applies raw-announce
  binding, and records ingress on the engine. Per-link frame order is
  preserved end to end (both queues serial), which supersedes the old
  batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
  retirement, binding flip, reconnect decision, and rotated-identity
  retirement run straight-line; only CoreBluetooth cancels hop to
  bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
  the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
  that used it now asserts the atomicity directly (a paused engine
  wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
  writeOrEnqueueIfAccepted) is checked on the engine, serialized
  against rebinds by construction; only physical admission
  (updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
  in the delegates) + retirePeripheralLinkIdentity (engine hop with
  survivor repair reading liveness via readLinkState). A binding can
  briefly outlive its physical link; liveness queries join against the
  physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
  mesh.sync sits above the engine in the sync order (production engine
  code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
  maintenance tick.

No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught

SimulatedMesh wires real CoreBluetooth-free BLEService engines
edge-to-edge through the outbound packet tap and _test_ingestFrame
(the production attribution path the B2 flip created), with per-edge
synthetic link IDs and manual-scheduler time. Five multi-node tests
run in ~40ms with no wall-clock waits:

- announce exchange binds simulated links and connects peers
- Noise sessions establish end-to-end (real crypto, both directions)
- a public message relays across a line topology inside a TTL/frame
  budget (storm bound asserted)
- an 8x duplicate flood delivers exactly once
- a panic rotation rebinds the survivor's link exactly once and stays
  — the scenario that previously needed two phones and log archaeology

Fidelity boundary (documented in the harness): no physical links, so
fanout planning and backpressure are not exercised; attribution,
binding, dedup, TTL, relay decisions, and sessions are the real
engine code.

The simulator found a real bug on its first run: the forced-announce
throttle's lastSent survived a panic, so a rotation within
bleForceAnnounceMinIntervalSeconds of the last announce silently
swallowed the new identity's announce — leaving it invisible to the
mesh until the next maintenance cycle. Today's device test only
passed because the previous announce happened to be minutes old.
BLEAnnounceThrottle gains reset(), called from the panic slot so the
rotated identity owes no throttle debt; pinned by a unit test and the
mesh rotation test.

New DEBUG seams: _test_ingestFrame (production ingress attribution),
_test_forceAnnounce, _test_fenceEngine.

1,980 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 01:14:19 +01:00
jack
2f5b56ce57
Link layer slice 3: bindings and link-auth become engine-owned (the option-B domain flip) (#1547)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine

The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.

What changed shape:

- Receive path is sans-I/O: bleQueue decodes frames and hands
  (packet, linkID) up through ingestDecodedPacket (panic lifecycle
  captured at the handoff); attributeAndHandlePacket resolves the
  sender binding, rejects spoofed senders, applies raw-announce
  binding, and records ingress on the engine. Per-link frame order is
  preserved end to end (both queues serial), which supersedes the old
  batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
  retirement, binding flip, reconnect decision, and rotated-identity
  retirement run straight-line; only CoreBluetooth cancels hop to
  bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
  the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
  that used it now asserts the atomicity directly (a paused engine
  wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
  writeOrEnqueueIfAccepted) is checked on the engine, serialized
  against rebinds by construction; only physical admission
  (updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
  in the delegates) + retirePeripheralLinkIdentity (engine hop with
  survivor repair reading liveness via readLinkState). A binding can
  briefly outlive its physical link; liveness queries join against the
  physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
  mesh.sync sits above the engine in the sync order (production engine
  code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
  maintenance tick.

No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:23:32 +01:00
jack
a0b7985cbe
Link layer slice 2: cohere link-auth state and split bindings from the physical store (#1540)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:19:13 +01:00
jack
2c22b117b2
Extract the central-role radio policy into BLERadioController (#1539)
First slice of the link layer: discovery admission, the connection
budget and queue, connect timeouts, wake-on-proximity background
connects, scan duty-cycling, RSSI adaptation, and the advertising
payload move out of BLEService into a bleQueue-confined controller
(~400 lines). It makes no peer decisions and owns no bindings or
security state: it shares the bleQueue-confined link-state store for
admission reads, and when a connect attempt dies it asks its delegate
to retire the transport bookkeeping — which also factors the
four-times-repeated teardown sequence (write backpressure, link-auth
proof, reconnect epoch, link-state entry) into one
tearDownPeripheralLink helper.

The three-method delegate (panic suspended, app active, tear down) is
the radio's entire dependency on the transport; the CoreBluetooth
delegate methods in BLEService shrink toward pure event forwarding
ahead of the LinkEvent/LinkCommand port.

candidateCount joins the Periphery baseline like the rest of the
status-capture path: its only callers are iOS-gated, invisible to the
macOS scheme scan.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:45:49 +01:00
jack
d39467f7d3
Defer alert-binding dismissal writes out of the view update (#1537)
SwiftUI invokes an alert Binding's setter inside the current view
update when the alert dismisses because its get re-evaluated (a
scenePhase change while the Bluetooth-off or voice-error alert is up).
Both root alert bindings wrote their @Published backing state
synchronously from that setter — the 'Publishing changes from within
view updates is not allowed' undefined-behavior warning, reproduced on
device by launching with Bluetooth off and backgrounding. Defer the
write one main-actor hop.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:09:09 +01:00
jack
c6b7096b2f
BLE transport architecture V3: one engine domain, capability ports, feature-owned state (#1498)
* Make peer registry and local announce state lock-backed

The main actor answered isPeerConnected/peerNickname/currentPeerSnapshots
and flipped runtime capability bits by blocking on collectionsQueue behind
whatever transport work was in flight. Peer state now lives in a
lock-backed BLEPeerRegistryStore (every registry mutation is a single
whole-transition method, so readers never observe a torn state), and the
runtime capability bits move into BLELocalIdentityStateStore next to the
identity they ride announces with. No transport entry point called from
the main actor blocks on a transport queue for peer state anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move BLE link egress/ingress buffers to bleQueue ownership

pendingPeripheralWrites, pendingNotifications, and pendingWriteBuffers
were collectionsQueue-guarded, but every producer and drain already runs
on bleQueue next to the CoreBluetooth objects they feed — each access
paid a cross-queue barrier for state that never leaves the radio thread,
and the notification drain even invoked peripheralManager.updateValue
from the collections queue. They are now bleQueue-confined like the link
state store: CB delegate callbacks and drains touch them directly, and
the few engine-side entry points hop to bleQueue (the direction the
transport's sync-edge order already allows). This clears most
bleQueue-to-collectionsQueue sync edges ahead of merging the collections
queue into the message queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop bleQueue maintenance and status paths from blocking on collectionsQueue

The traffic-burst tracker becomes a lock-backed monitor (written by the
receive pipeline, read by scan-duty adaptation and announce pacing on
bleQueue), the status-log peer summary and topology refresh read the
already lock-backed registry directly, and the stalled-fragment reap
moves to an async collections hop with the gossip resync request inside
it. bleQueue no longer sync-waits on the collections queue anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unify the message and collections queues into one serial engine queue

The old model ran a concurrent message queue over a second concurrent
collections queue whose barrier flags served as the real mutual
exclusion — every field carried an ownership comment, and correctness
lived in per-site discipline. The message queue is now a single serial
engine queue that owns all mesh protocol state; the collections queue,
its 98 sync/async hops, and every barrier flag are gone. Cross-thread
callers go through onEngine, which documents and (in debug) enforces
the transport's sync-edge order: main and test threads may block on the
engine, the engine may block on bleQueue and the crypto/identity
queues, and nothing may block the other way.

The debug trap caught two latent inversions the leaf-lock structure had
been masking: the verified-announce rebind path re-resolved the ingress
link through the engine from inside its bleQueue critical section (it
now receives the already-resolved link), and the noise
session-generation closures sync-re-entered the engine from the noise
manager's queue while their own engine slot was blocked on it (they now
touch engine state directly, which the held slot makes exclusive).

BLE throughput is orders of magnitude below what one serial queue
sustains; the full suite runs at identical speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Wire gateway/bridge/panic features to capability ports, not BLEService

App wiring discovered mesh-only features by casting the Transport to
the concrete BLEService class in nine places. Those surfaces are now
three capability protocols — BluetoothStateReporting,
PanicResettingTransport, and MeshBridgingTransport — discovered with
as? like any optional capability, so the bootstrapper, panic flow, and
lifecycle coordinator no longer name the concrete transport at all. A
future second mesh transport picks up gateway/bridge wiring and the
panic lifecycle by conforming, and the remaining Transport god-protocol
requirements can migrate to the same pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract mesh-ping diagnostics state into a pure engine-confined tracker

First slice of the feature-module direction: BLEMeshPingTracker owns the
outstanding-probe map and the per-link inbound response budget as pure
state (register/resolve/expire/reset), so the security invariants — a
pong only resolves against the probed peer, the budget keys on the
ingress link because claimed senders are forgeable, panic reset drops
probes and budget together — are now unit-tested without queues or
radios. The transport keeps only packet I/O, timers, and main-actor
delivery around it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document the V3 transport architecture and remaining roadmap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resolve the pass-6 review findings and the proof-timeout drain defect

Periphery: the registry store's unused forwarders are gone (the struct
method stays — it has direct tests). F1: refreshPeerIdentity,
deliverBridgedEnvelope, and the three panic fences route through
onEngine, so every sync entry onto the engine now carries the bleQueue
trap. F2: the registry-store ownership comments state the real writer
set (engine plus the two bleQueue link-drop paths). F7:
BLEQueueContractTests pins the contract — only onEngine may sync-enter
the engine, transport code never sync-dispatches to main, and the
collections queue stays deleted — with a queue-contract-ok waiver for
the two sanctioned lines.

The real defect behind the timeoutRestoredSession CI flake: a
timeout-restore parks the outbound queues until the convergence retry,
but the capability-proof watchdog armed at the original authentication
kept draining them when it fired — encrypting the parked traffic under
restored keys the counterpart may have discarded, the exact silent loss
the defer path exists to prevent. Deferred peers are now tracked and
the watchdog drain respects the same rule; the test fires the watchdog
deterministically inside the deferred window instead of losing that
race only on stalled runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract private-media session state into a lock-backed store

The six generation-keyed maps plus the convergence-deferral set move
out of BLEService into BLEPrivateMediaSessionStore, each transition one
whole method under a leaf lock with direct unit tests (generation
rotation rejects mismatched waiters, stale proofs cannot classify a
replacement session, expiry requires the live deadline identity, clears
rebase waiters onto a nil-generation deadline, peer-state sends are
once per generation per kind).

Being a leaf lock also simplifies two contracts: the send policy is now
answered entirely from locks (the main actor no longer sync-enters the
engine for it), and the noise-manager critical sections call ordinary
store methods instead of relying on the held-engine-slot direct-access
subtlety.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split the mesh-only Transport surface into capability protocols

Transport kept ~50 requirements that only the BLE mesh implements —
files/private media, voice, courier, groups, board, diagnostics,
verification, archive — held together by an extension of inert
defaults, so every call site compiled against a surface most transports
faked. Those are now eight capability protocols (MeshFileTransferring,
MeshVoiceStreaming, MeshCourierTransporting, MeshGroupMessaging,
MeshBoardBroadcasting, MeshDiagnosing, MeshVerifying,
MeshPublicArchiving) discovered with as?, joining the bridging/panic
ports from the previous pass. Consumers resolve the capability they
need; where the old defaults encoded a safe floor the caller keeps it
explicitly (private-media policy degrades to blockedDowngrade). The
inert-defaults extension is deleted, along with the never-implemented
acceptPendingFile/declinePendingFile pair. NostrTransport is untouched
— it only ever implemented the core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update the V3 doc for the completed feature-peeling and Transport split

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the dead three-argument sendFilePrivate overload

Every production caller goes through the allowLegacyFallback variant;
the short form only existed as a Transport-era forwarding default.
Tests that used it on the concrete service now state the fallback
decision explicitly, which is the point of the parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Decide the link-auth boundary: bindings become engine-owned

The atomicity that keeps link-auth on bleQueue exists to stop a binding
from changing between a security check and its action; once every
rebind is an engine operation, the engine's serial slot gives the same
guarantee, the stolen-link residual is unchanged (directed payloads are
Noise ciphertext), and the receive path lands in its sans-I/O shape —
the link layer reports bytes-plus-linkID and the engine resolves the
sender. Records the extraction order too: the binding-free radio half
first (after #1521 lands — it collides in the scanPlan region), then
bindings, then the delegates behind the port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix two bleQueue-to-engine sync edges the queue merge created

The collections-to-engine conversion turned two formerly leaf-lock
sync calls into onEngine calls reachable from bleQueue, where the
debug trap (correctly) aborts: flushDirectedSpool runs from bleQueue
maintenance and now hops to the engine asynchronously, and ingress
recording — which must answer the duplicate gate on bleQueue the
moment a frame decodes — moves to a lock-backed BLEIngressLinkStore
read by the engine's relay and routing decisions.

Unit suites never hit either path (no CoreBluetooth managers means no
maintenance timer and no live receive path); the iOS simulator job
boots the real app as its test host, which is exactly where the
maintenance trap fired. The ingress one would have trapped a real
device on its first received packet — worth a device pass before
release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route all deferred engine work through an injectable scheduler

Relay jitter, announce delays, the ping and capability-proof deadlines,
notification retry backoff, and fragment pacing all reached the engine
through raw messageQueue.asyncAfter with product constants as deadlines
— the hidden-elapsed-deadline flake class that the test-timing hygiene
rules exist to contain, testable only by racing the wall clock.
BLEEngineScheduling is now the transport's single source of engine
delay: production is a thin veneer over the engine queue, tests inject
a manually advanced clock whose advance() returns only after the
released work has finished on the engine. The queue-contract test pins
the seam (no raw messageQueue.asyncAfter), and the ping deadline gets
the pattern's proof: the real 10s constant asserted in milliseconds —
must not fire early, fires exactly once at the deadline, stays consumed
after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert the armed deadline count in the injected-clock ping test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:03:20 +01:00
Oleksandr Kravchuk
eadd3a20c1
Add Play Store link to README (#1524) 2026-07-28 21:21:33 +01:00
jack
0152196ac2
Deflake CI, and make the flake class unrepeatable (#1491)
* Deflake two iOS-sim CI tests with unbounded timing assumptions

Both failed on main-adjacent CI runs during this session's PRs. Neither
was a product bug; both asserted things about real time that a loaded
runner is under no obligation to honour.

**NetworkReachabilityGateTests: a wall-clock upper bound.**
test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit slept 500ms for
real, then asserted total elapsed time was under 1.4s to prove a duplicate
mid-window had not restarted the 1.0s debounce. One CI run took 3.75s. No
wall-clock bound can separate "deadline preserved" from "runner is slow",
because Task.sleep and the asyncAfter flush are both real time and neither
is bounded above.

The deadline property was already covered deterministically one level
down: test_debounce_duplicateObservationsPreservePendingDeadline drives
ReachabilityDebounce with injected timestamps and checks pendingRemaining
directly. So the monitor test now asserts only what needs a real monitor —
that a duplicate still yields exactly one committed false through the
debounce — with an injected clock for the arithmetic and a generous
liveness budget. Renamed to say what it actually checks. No coverage lost,
and it runs in 0.14s instead of ~3.8s because the real sleep is gone.

**NoiseEncryptionServiceTests: injected timeouts that also arm during
setup.** #1483 diagnosed and fixed exactly this in the quarantine-restore
test, but two sibling tests kept the shape. Their injected
ordinaryResponderHandshakeTimeout (0.04 and 0.06) also arms during the
establishSessions setup handshake, where bob is the responder — so a
preempted runner fires it mid-setup, tears down the half-open responder,
and message 3 gets answered as a fresh initiation. The CI failure named
setup's own `#expect(finalMessage == nil)` seeing a 96-byte message 2,
which is precisely the signature #1483 recorded.

Raised both to 1.0s, matching #1483's remedy: the scenarios still need the
responder timeout to fire, and it still does, just with room for setup to
complete first. Thin 1-second waitUntil budgets in the file now use the
shared TestConstants.longTimeout; every one of them backs a positive
assertion, so waitUntil still returns the moment the condition holds and
nothing gets slower in the passing case.

No assertion weakened in either file. Verified 3x sequentially and 3x with
all 18 cores saturated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Raise two more starvation-prone test deadlines

Both surfaced on the CI runs for this PR and #1487, both in tests neither
PR touches, both the same shape as the two already fixed here: a deadline
sized for the work rather than for a runner executing many suites at once.

VoiceNotePlaybackControllerTests waited 5s for a @MainActor Task that
playback schedules for the session acquire and its failure path. When that
Task is not scheduled in time the helper reports *two* failures — the wait,
and the `!isPlaying` the un-run failure path has not reset yet — which
reads like a playback bug rather than a starved scheduler. That is exactly
what CI showed.

GeoRelayDirectoryTests waited 10s for work the directory runs in
Task.detached(priority: .utility); utility priority competes with every
other suite. The retry-scheduling case timed out at exactly 10.06s with the
retry never scheduled, reading like a missing retry rather than a starved
background task.

Raised to 30s each with the reasoning recorded at the helper. Both helpers
return as soon as their condition holds, so nothing slows down when tests
pass — only the genuine-failure case takes longer to report.

Verified 3x with all cores saturated: both suites clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the flake class unrepeatable, not just fixed

Raising four deadlines fixed the four tests that happened to fire. The
class was still there: fourteen separate waitUntil helpers, most defaulting
to 1.0s, plus wait call sites with literal budgets. The fifth instance
would have landed the same way, on someone else's unrelated PR.

The rule, now written down in TestConstants.settleTimeout: **a wait
deadline is not a latency budget.** It exists so a genuine hang eventually
fails the suite, so size it for the worst-case scheduler, never for how
long the operation should take. Waits return as soon as their condition
holds, so a generous deadline is free in the passing case and only extends
genuine failures.

- Every wait helper now defaults to TestConstants.settleTimeout (30s), and
  the literal wait call sites below the floor were converted too — sixteen
  sites across ten files.
- TestTimingHygieneTests enforces it by scanning the test sources: wait
  defaults and wait call sites must be at least minimumSettleTimeout, and
  no test may assert an upper bound on elapsed wall-clock time (the
  assertion that started this, which cannot separate correct behaviour from
  a slow machine).
- Both rules waive per line with "test-timing-ok: <reason>", accepted on
  the line or in the comment block above it so the reason has room to be a
  sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests
  asserting a promotion has *not* completed within 50ms, where a long
  deadline would only make the suite slow while still passing.

Injected production timeouts are deliberately not matched — the Noise
handshake timeouts are the behaviour under test, and short values are
correct there.

Verified the guard actually fails: a canary file with both banned shapes
was flagged with file and line, and the suite went green again once it was
removed. Full suite passes with all 18 cores saturated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the guard's blind spot: named short timeouts

A fifth flake landed on CI while the first guard was in review —
GossipSyncBoardTests timing out at 1.03s — and the guard did not catch it,
because the deadline was `TestConstants.shortTimeout` rather than a
literal. A literals-only scan cannot see a short value behind a symbol,
which is the more common way it is written: 81 wait sites used
shortTimeout (1s) or defaultTimeout (5s), every one of them below the
floor.

Rather than guess which of those were safe to raise, all 81 were converted
and the suite timed. Runtime went 15s -> 72s, which located the genuine
negative waits precisely: ten tests that assert something does *not*
happen and therefore always run their deadline out. Measurement instead of
a heuristic, since a mis-guess in either direction is invisible — too
short reintroduces the flake, too long silently costs a minute a run.

Those 28 sites now use `TestConstants.negativeWaitWindow`, a named
constant whose doc explains the inverted reasoning: for a negative wait,
starvation can only make the assertion *more* likely to hold, so short is
correct, and the name states the polarity instead of leaving a bare
literal that reads like the mistake. Suite is back to 15.3s.

The guard now also rejects `timeout: TestConstants.shortTimeout` and
`defaultTimeout`, while accepting `negativeWaitWindow` by name.

Re-verified with a canary carrying every banned shape — literal default,
both named constants, and an elapsed-time upper bound. All four were
flagged with file and line; the suite went green again on removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Delete shortTimeout now that nothing may use it

The hygiene guard bans `TestConstants.shortTimeout` at every wait site
and the last users were converted, so Periphery correctly flagged the
constant itself as dead and failed CI. Remove it from both TestConstants
copies; the banned-name entry stays so the symbol cannot quietly return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:45:20 +01:00
jack
14e7b428d9
Add a real security policy (#1489)
* Add a real security policy

Two drive-by template PRs (#1118, #1482) tried to fill this gap with
unedited boilerplate. This is the actual policy: private vulnerability
reporting (now enabled on the repo) as the channel, honest expectations
for a volunteer project, and a scope section that separates the
properties the app promises from the documented design behaviors that
keep getting reported as vulnerabilities.

Closes #1081

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review: name the Nostr envelope format precisely

'Gift-wrapped' reads as NIP-59, and the scope section is exactly where
a researcher calibrates expectations. Say what it is: bitchat's own
private-envelope scheme, explicitly not NIP-17/44/59.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:33:59 +02:00
jack
c671e3df66
Keep the composer focused after sending with the return key (#1490)
* Keep the composer focused after sending with the return key

On iOS, return sends and then drops the keyboard, so every message in a
back-and-forth costs an extra tap to reopen it. sendMessage() is the
onSubmit handler; reasserting the FocusState there keeps the keyboard up
between messages. macOS already refocuses on appear and is unaffected.

Fixes #457

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review: refocus only on return-key submission

The reassert moves from sendMessage() into the TextField's onSubmit, so
the send button no longer reopens a deliberately dismissed keyboard on
iOS and never moves focus on macOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:19:12 +02:00
jack
132120a88e
Close the three holes the #1486 review found (#1488)
* Fix the three follow-ups from the #1486 review

Three defects shipped with the censorship-resilience merge, all
confirmed against main:

1. Source-manifest verification silently accepted added files.
   shasum -c checks only the files the manifest lists, and the Xcode
   project compiles every source file present in the tree — so a
   hostile mirror could pass verification by adding a file rather than
   modifying one. The manifest header and VERIFYING-A-BUILD.md now
   require the completeness check (git status --porcelain, or a path
   diff for tarballs) alongside the hash check.

2. A relay removed while Tor was bootstrapping reconnected anyway.
   dropRelays never subtracted from pendingTorConnectionURLs, and a
   custom relay passes the allow-list filter, so draining the pending
   queue resurrected a relay someone had explicitly deleted.

3. Turning Tor off mid-bootstrap read as 'network may be blocking tor'.
   shutdownCompletely left the detached 75s poll loop running, which
   then stamped bootstrapDidStall over the clean shutdown state; and
   the stall handler guarded on torEnforced, which is compile-time true
   in release, instead of the runtime preference. The poll loop is now
   generation-fenced (shutdown, dormancy, and restart each invalidate
   it) and the handler consults persistedTorPreference().

Both app-side fixes carry regression tests proven to fail pre-fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review: ignored files and manifest placement

git status --porcelain omits ignored paths, and .gitignore covers
build/ — a planted bitchat/build/Evil.swift would compile via the
synchronized group while the documented check stayed silent. The
checkout check now uses --ignored.

The downloaded manifest also has to live outside the tree, or it trips
the completeness checks itself; the doc now says so and references it
at /tmp throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:01:38 +02:00