5 Commits

Author SHA1 Message Date
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
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
dd6b624cae
Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1

Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM
store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed,
well-scoped findings; deeper architectural/security items are tracked
separately.

- PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s
  150ms retry pause left the mic live and streaming for up to 120s, because
  cancel() no-op'd once `completed` was set. Bail after the sleep if the hold
  was released, and make cancel() always tear down a late-started capture.
- Bridge courier depositDrop reported success and burned the dedup slot before
  the drop was actually published (evicted/compose-fail = lying 📦 "carried"
  with no retry). Only consume publishedDropKeys on durable accept; add
  BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey).
- Blocked senders resurfaced via archived "heard here earlier" echoes, the one
  path that bypassed the live block filter — filter at seed time.
- A late optimistic .sent clobbered the router's .carried state; extend
  ConversationStore.shouldSkipStatusUpdate to a full precedence guard
  (sending < sent < carried < delivered < read).
- Read receipts were permanently burned when the router dropped them (marked
  sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only
  record as sent on a successful route, else retry on the next read scan.
- MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a
  peer that never reconnects sat on .sending until relaunch — run it in the
  120s bridge sweep.
- Sightings tally now rolls over at midnight while idle; wave notification
  action localized across all 29 locales; bridged anon#tag uses suffix(4) like
  everything else; makeThrowawayIdentity delegates to NostrIdentity.generate();
  .swiftlint.yml excludes .claude worktrees.
- Add regression tests: carried→sent no-downgrade, carried→delivered upgrade,
  evicted pending drop stays retryable.
- Bump MARKETING_VERSION to 1.7.1.

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

* Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage

The stricter no-downgrade guard (delivered/carried never regress to sent) broke
two things the earlier commit didn't catch locally (perf tests are skipped in
the default run, and Periphery runs only in CI):

- PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered
  assuming both directions apply; the delivered -> sent half is now correctly
  skipped, so the pass measured 0 updates. Alternate two delivered timestamps
  instead — every update is real, no downgrade.
- Routing the geoDM "not in a location channel" error into the thread removed
  the only caller of ChatPrivateConversationContext.addSystemMessage, leaving
  it (and its mock) dead per Periphery. Drop the protocol requirement, the mock
  impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is
  compile-time enforced: the context can no longer emit a public system line).

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

* Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge

- Read receipts: claim the receipt in sentReadReceipts synchronously
  before spawning the routing task (chat open runs two read scans in one
  MainActor stretch, so the async insert let every unread message route
  twice), and release the claim when the route fails so the
  retry-on-failed-route behavior is preserved.

- Delivery status: extend the no-downgrade guard so the `.sending`
  stamp a pre-handshake resend emits can no longer clobber
  carried/delivered/read (the 📦 indicator survived `.sent` but not
  `.sending`).

- Archived echoes: blocking a peer now purges their carried public
  messages from the gossip archive at block time (UnifiedPeerService
  and /block), while the fingerprint-to-peerID mapping is still known —
  the seed-time filter can't resolve offline non-favorite strangers and
  stays only as defense-in-depth. New Transport hook (default no-op) +
  GossipSyncManager.removePublicMessages with immediate persist.

- Bridge courier: an envelope that can't encode within the drop size
  caps fails identically on every attempt; consume the dedup slot so
  the 120s retry sweep stops re-running Noise sealing on it.

- MeshSightingsTracker: cache the day-key DateFormatter instead of
  building one per call.

Tests: double-markAsRead dedup + failed-route retry, carried→sending
no-downgrade matrix, block-time purge (manager + service wiring),
oversize-drop slot consumption.

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

* Also skip the sent→sending downgrade in the delivery-status guard

Codex review follow-up: sendPrivateMessage without an established Noise
session emits `.sending` asynchronously, so it can land after the
message already reached `.sent` and visibly walk "Sent" back to
"Sending...". Treat `.sending` as weaker than `.sent` too — the status
was already truthful. `.failed` → `.sending` stays allowed so a retry
after a real failure remains visible.

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

* Retain Codable properties in Periphery scan (same fix as #1421)

The noiseKey assign-only false positive fired persistently on this branch
(twice, including a rerun) despite the baselined USR. Byte-identical to the
fix on fix/announce-replay-link-steal so the branches merge cleanly in
either order.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:44:02 +02:00
jack
55d51ba084
DMs to unreachable peers use store-and-forward instead of failing instantly (#1415)
* DMs to unreachable peers route through store-and-forward; drop stale reachability gates

Field-found: sendPrivateMessage pre-judged reachability and marked the
message failed without ever calling the router, so the retained outbox,
courier deposits, and bridge drops never ran — a DM composed after a
peer's reachability window lapsed was dead on arrival while an identical
one sent a minute earlier delivered. Messages now always route; a live
path earns "sent", everything else stays "sending" until the router
reports carried/delivered or expires it as failed.

A sweep for sibling gates found and fixed:
- startPrivateChat refused offline non-mutual favorites ("mutual favorite
  required for offline messaging") — store-and-forward only needs the
  recipient's noise key, so the gate and its string are gone.
- markPrivateMessagesAsRead skipped the whole receipt pass for peers
  without a stored Nostr key, starving mesh-connected non-favorites; the
  router picks the transport now (sentReadReceipts dedups the parallel
  PrivateChatManager path).
- DM/geoDM blocked notices and the unknown-group error posted to the
  active public timeline; they now land in the thread they're about via
  addLocalPrivateSystemMessage.
- Banned copy: literal "user" fallbacks in code become "anon" (the
  default-nickname convention), and es/fr/it/pt translations of four keys
  still saying usuario/utilisateur/utente/usuário now say person. Orphaned
  keys removed (system.dm.unreachable, content.delivery.reason.unreachable,
  system.chat.requires_favorite).

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

* Deflake BLEServiceCoreTests: longTimeout for positive waits

CI flaked on duplicatePacket_isDeduped: the first message landed after the
5s wait expired on a loaded runner (the test's later count==1 assertions
passed, proving late delivery, not lost delivery). All four positive waits
in the file now use TestConstants.longTimeout, which exists for exactly
this — waitUntil returns as soon as the condition holds, so passing runs
never pay the longer ceiling.

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

* Fix read-receipt test contamination; baseline Periphery noiseKey flake

Two CI failures, both environmental:

- markReadReceiptSent_returnsFalseOnSecondCall flaked because every test
  ChatViewModel shared one per-process read-receipts scratch suite; the
  lifecycle test persists the same "read-1" ID (a path the receipt-gate
  removal now reaches), and test order decided who saw whose state. Each
  instance now gets its own UUID-suffixed suite.

- Periphery intermittently misses the loadFromDisk read of
  PrekeyBundleStore.StoredBundle.noiseKey and fails --strict with a false
  "assign-only" finding (recurrence of a known flake). Its USR is
  baselined; an in-source periphery:ignore can't work because strict mode
  flags it as superfluous on runs where the indexer gets it right.

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-08 22:42:39 +02:00
jack
ef848857b7
Remove dead code found by full Periphery audit; add scan config + advisory CI (#1410)
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so
platform-specific code is never touched), with test targets indexed and
the share extension built. 277 dead declarations removed or demoted:
dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed-
feature remnants (autocomplete command suggestions, back-swipe tuning,
MediaSendError, GeohashParticipantTracker), unused Tor dormancy
bindings, assign-only properties, unused parameters (renamed to _), and
redundant public accessibility. 13 orphaned localization keys deleted
across all 29 locales (old pre-#1392 location-notes UI, app_info
warnings).

Two real tests were flagged as unused because they never ran: Swift
Testing methods missing @Test (NostrProtocolTests.
testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests.
testAssemblesCompressedLargeFrame). Re-armed both; they pass.

Deliberately kept, now recorded in .periphery.baseline.json: iOS-only
code invisible to the CI macOS scan, C FFI signatures, keep-alive
NWPathMonitor reference, InboundEventKey.eventID (dedup semantics),
wifiBulk capability bit (reserved for Wi-Fi bulk work, used by
BitFoundation package tests), and the String secureClear cluster
(exercised by package tests).

New: .periphery.yml config and an advisory Dead Code CI job (mirrors
the SwiftLint precedent from #1361) that fails on findings not in the
committed baseline.

Verified: full macOS app suite, BitFoundation (119) and BitLogger (13)
package tests green; periphery scan --strict exits clean.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:24:19 +02:00