Status: implemented (#547 through
#655; the SWIP-60 transport adapter is the one
part still pending). Resolves #277 by making the
Safari iframe a full participant, and unifies cross-tab/cross-device propagation on every
browser. Builds on the account model in Account-State.md and the write
coordination in BatchWriteCoordinator.md /
Postage-Batch-Partitioning.md. The porter-facing
walkthrough is on the docs site (docs-site/src/content/docs/account-bus.mdx); this file is
the design record and the place for the reasoning.
This is the state the bus replaced. Safari's Intelligent Tracking Prevention partitions all
client-side storage of a third-party iframe by top-level site. The SwarmID proxy iframe embedded
in a dApp therefore cannot see the trusted domain's first-party localStorage. Before the bus,
the connect popup handed over only the appSecret via postMessage, and every state-mutating
operation was disabled in a partitioned iframe (ensureCanUpload) because a change made there
could never be synchronized back.
Two distinct problems hid in "synchronized back":
- P1 — account-data propagation. Changes to account data (app secrets and revocations, account name, settings, the postage stamp list — never the private key/seed) must propagate between SwarmID tabs, dApp proxy iframes, and devices. Swarm feed sync covers synced accounts, but local (non-synced) accounts need a channel too, so Swarm cannot be the only transport.
- P2 — batch-utilization coordination. Uploads consume stamp slots; the partition-lease protocol coordinates writers that cannot talk to each other, so its timeouts are conservative (~10 s for a late device to acquire a partition). Treating every Safari dApp partition as a separate device would multiply that contention. The lease protocol had to stay (it is the offline-safe fallback) but live contexts deserved a fast path.
Constraint carried through the whole design: Swarm's upcoming pub-sub (SWIP-60) must be able to plug in later without an architecture change.
Full option matrix in the appendix; the load-bearing facts:
- There is no browser-native fix. Safari's Storage Access API unlocks unpartitioned
cookies only; the non-cookie extension (
requestStorageAccess({localStorage: true})) is shipped in Chrome 125+ but has no WebKit position or implementation through Safari 26.x (WebKit/standards-positions#262). FedCM is unimplemented in Safari; Partitioned Popins was withdrawn (Nov 2025). Not plannable. document.hasStorageAccess()does not answer "am I partitioned". Measured 2026-08-31 in thechromium-partitionedrig: it returnstrueinside a frame whoselocalStorageis genuinely partitioned. It is cookie-scoped, and Chrome partitions storage separately from cookies, so a frame can hold unpartitioned cookies and a partitioned store at once. Anything that needs the storage answer has to ask about storage — see the marker probe inlib/src/utils/storage-probe.ts(#613).- ITP partitions storage, not the network. A partitioned iframe can open HTTPS/WSS to anything — the Bee node, a signaling server, another peer. Every network-based design is Safari-safe.
- The key material is the actual blocker, not the partition. The per-device-feed sync of
Account-State.mdalready handles "another storage area that folds in later". The partitioned iframe could not join only because the popup handshake never sent thederivationKey, so it could not derive the backup key that owns the sync feeds. - WebRTC works between the contexts we have, but only while both are alive. DataChannels
are available in Safari cross-origin iframes (only
getUserMediais permission-gated), and same-device loopback needs no STUN/TURN. There is no store-and-forward — which is acceptable precisely because durable truth stays in storage (below). - SWIP-60 is real-time-only and pre-release. Draft spec (Aug 2026), bee
PR #5435 and bee-js
PR #1151 open, in no release; history
delivery is explicitly deferred to a future SWIP. Its
FEED_TOPICcohort binding makes an ordinary sequence feed the pub-sub topic (id = keccak256(topic ‖ index)), so a feed-based design gets broker push later with zero data-model change. - No third-party networks. Public Nostr relays / MQTT brokers / Waku would work technically, but none offers guarantees appropriate for this application, and a dependency on an unrelated decentralized network is unacceptable. A small centralized service we run is fine — ideally used only for WebRTC signaling, with data flowing peer-to-peer.
A pluggable real-time message bus between all live contexts of one account: dApp proxy iframes (across partitions and dApps), SwarmID tabs, and the same account's contexts on other devices. The topic is derived per account; envelopes are end-to-end encrypted with an account-derived key and validated with Zod schemas like every other message boundary in the codebase.
Durable truth does not move. First-party localStorage (and, for synced accounts, the Swarm
feeds of Account-State.md) remain the stores of record. The bus carries
only deltas and coordination between live peers — which is why WebRTC's
"both-peers-online-or-nothing" property is acceptable by design rather than a flaw.
One interface (BusTransport, lib/src/bus/account-bus.ts), three transports:
| Transport | Scope | Status |
|---|---|---|
BroadcastChannel |
same origin + same partition (SwarmID tab↔tab, same-dApp Safari tabs) | shipped; generalizes the old BroadcastChannel("swarm-id-utilization") channel |
| WebRTC DataChannel mesh, self-hosted signaling | across partitions, dApps, and devices | shipped and deployed: signaling/, reached at wss://swarm-id.snaha.net/bus (.do/) |
SWIP-60 (pubsubConnect, FEED_TOPIC binding) |
synced accounts, once bee/bee-js release it | future adapter behind the same interface |
The signaling WebSocket doubles as an encrypted-blob relay for peer pairs WebRTC cannot connect (restrictive NATs — there is no TURN). Same service, no extra infrastructure; the server forwards to exactly one named peer, never to a room, and stores nothing.
Who attaches what: a proxy iframe attaches both the local transport and (when a signaling
URL is configured) the signaling transport, a few ticks after an auth event, because the
topic is derived from the account's key (ensureAccountBusTransports, swarm-id-proxy.ts).
The SwarmID tab attaches the signaling transport alone (ui/src/lib/stores/account-bus.ts):
everything a BroadcastChannel would reach from there already converges through storage
events, and the partitioned iframe — the one context that cannot — is only reachable through a
server round trip.
Both the topic and the envelope key come from the account's derivationKey through separate
HMAC contexts (deriveBusContext, lib/src/bus/bus-context.ts), so they are derivable by
exactly the contexts that hold the account, and the topic is unlinkable to the account id.
newcomer signaling server existing peer
│ ── WSS connect ───────────────────▶ │ │
│ ── {join, topic} ─────────────────▶ │ (topic in the first frame, │
│ ◀── {welcome, peerId, peers[]} ──── │ never in the URL: #577) │
│ │ ── {peer-joined, peerId} ────────▶ │
│ ── {signal, to, offer} ───────────▶ │ ──────────────────────────────────▶ │
│ ◀────────────────────────────────── │ ◀── {signal, to, answer} ────────── │
│ ◀──── ICE candidates both ways, relayed the same way ────────────────────▶ │
│ │
│ ═══════════ RTCDataChannel "bus": encrypted envelopes, direct ══════════ │
│ │
│ ── {relay, to, ciphertext} ───────▶ │ ── {relay, from, ciphertext} ────▶ │
│ (fallback per peer, whenever no channel is open to that peer) │
- Join. The socket names its room in the first frame; the server answers
welcomewith the ids of the peers already there and tells thempeer-joined. A room is aMapof sockets and nothing else; it is dropped when the last peer leaves. - Upgrade. The newcomer initiates WebRTC toward every peer in
welcome(so there is no offer glare); SDP and ICE travel assignalframes addressed to one peer. No STUN/TURN is configured: same-device loopback and LAN peers connect via host candidates, everything else stays on the relay. - Publish.
SignalingTransport.publishencrypts the message once (AES-GCM, the bus key) and then delivers per peer: over that peer's DataChannel if it is open, else as arelayframe the server forwards as-is. A context alone in its room sends nothing. The server sees a room name, connection timing, and ciphertext. - Receive. Every transport hands raw messages to the one
AccountBus, which validates them againstBusMessageSchema(lib/src/bus/messages.ts) and drops what does not parse — so a peer on an older bundle simply never sees a message kind it predates. - Leave. The server pings every 30 s and terminates a socket that has not answered by the
next tick, so a half-open peer (backgrounded mobile Safari, a laptop lid) is announced as
peer-leftwithin about a minute. The client'sclose()drains in-flight publishes first, so a teardown announcement made in the closing tick still reaches the wire. - No mailbox. A message reaches the peers that are in the room right now. That is the whole design: the bus makes live peers converge fast; it does not carry anything to a context that is closed (see Known gaps).
Server limits (signaling/src/server.ts, all deliberately global rather than per-IP because
the service sits behind the platform ingress): 500 connections, 200 rooms, 24 peers per room, a
per-socket message budget sized from the WebRTC negotiation cost, a 64 KiB payload cap, and a
join timeout for a socket that never names a room. 1008 closes are permanent for a topic;
everything transient closes 1013 and the client backs off with jitter.
- Account deltas (P1). The wire shape is the existing portable projection
(
serializeSyncedAccount,lib/src/utils/storage-managers.ts) minus the per-context session material (appSecret,connectedUntil— stripped on send and on receive), and merging reuses the LWW fold rules oflib/src/sync/merge-snapshot.ts— a delta received over the bus merges exactly like a device-state feed payload, metadata scalars included on their per-field clocks (#610). Revocation notices ride the same channel so a live iframe drops its session immediately. - Presence. Every context beats
{ presence, accountId, fromDeviceId }on joining the room and every 20 s (lib/src/bus/presence.ts); a receiver stamps its own clock and forgets a device unheard for three minutes — three beats at the once-a-minute cadence a hidden tab's timers are throttled to, so a backgrounded device does not flap out of the live set. A clean departure does not wait for that window: the server sendspeer-leftwhen a socket closes, its reaper included, and the device that socket carried is dropped at once (#572).peer-leftis a transport signal, not a bus message kind — nothing publishes it, and nothing may. A leave message of our own is not an option: a publish encrypts before it sends, and a page being torn down never gets back to the send. Ageing is the backstop for what the room cannot see — a peer the server has not yet reaped, and a device heard only over the local transport. This is the liveness signal: a peer beating in the room is alive by construction, at no Swarm cost, so the partition rival set (knownDeviceIds) is the live set unioned with the registry's recent sign-ins — the latter only as a bootstrap for a context with no bus. Nothing durable is written. AlastSeenAtriding the account snapshot was considered and rejected: every publish would be a byte change to fold and re-persist, and an unpartitioned proxy answers every storage event with a delta and a stamped feed write, so it loops without settling, even on one device. Stale devices are therefore ignored at read, never tombstoned; the roster grows per device, not per time, and needs no prune. - Coordination (P2). Lease negotiation/handover requests and utilization deltas. Live
peers release or transfer a partition sub-second; if nobody answers, the existing
conservative Swarm lease protocol (
BatchWriteCoordinator.md) proceeds unchanged as the safety net. Utilization deltas are lane-scoped: the buckets carry the writer's per-partition counterj, not an absolute slot (slot = partitionCount + partition + partitionCount·j), so the message names its lane and a receiver folds it in only when the lane matches. Since leases are exclusive, this means a delta is shared between contexts of the same holder and dropped everywhere else — the bus widens its reach, not the set of contexts a counter is comparable across. They are publishedlocalOnly: every remote peer is a different device on a different lane, so a remote copy would be dropped at the receive guard after paying for encryption and a frame.
With the bus in place the partitioned iframe is a first-class writer:
-
The connect popup sends the full
AuthData— anaccountfield carrying the synced-account projection (serializeSyncedAccount:derivationKey; no vault, no app secrets) plusnetworkSettings. (Sending only derived keys was considered and rejected: the encrypted on-Swarm account state contains thederivationKeyanyway, so holding the derived keys is equivalent — derived-only would be defense-in-depth theater.) -
What the partitioned session is allowed to hold (#578). The stamp collection is narrowed to the batches this app's own pointers name — its
postageStampBatchIDoverride and the account default it falls through to (stampsReachableByApp) — at the handover AND after everyaccount-deltafold, since the publisher is an unpartitioned context that sends the whole collection. A session can only ever spend the stampresolveStampForApppicks, so every other signer key it held was exposure with no use.The
derivationKeystays, and that is the trade-off worth stating rather than leaving implicit: the bus derives its topic and envelope key from it (deriveBusContext), and the partition lease derives the Swarm encryption key and the lock-SOC signer from it (deriveSwarmEncryptionKey→backup-key). A partitioned session cannot write without it. So this is least privilege, not a boundary being closed — the iframe is on the SwarmID origin, and an unpartitioned proxy reads the same material out of shared storage. What changed with #547 is that a partitioned session went from holding no credentials at all to holding real ones, in a context embedded by an arbitrary dApp page; narrowing the collection reduces what it holds at rest.It does not bound what script execution in that iframe would yield. The session keeps the
derivationKey, so it can derive the envelope key and read the room — and everyaccount-deltaa publisher sends carries the whole stamp collection, signer keys included, because the publisher is unpartitioned and does not know which app each receiver is. Anything running there can wait for one message. Narrowing that too means publishing per-app deltas, which needs a per-app room; not in scope here, and worth stating so the next reader does not mistake this for a boundary. -
handlePopupMessagehydrates the projection into an in-memory account view (partitionAccount) and keeps the handover itself underpartitionSessionStorageKey(parentOrigin)— one per dApp origin, since every same-site dApp shares the store (#671) — so a reload restores the session instead of re-running the popup (#635). Its own record, not the accounts document:LocalAccountSchemaV1quarantines vault-less records and the hydrated view carries inert vault placeholders, so it could not be stored as an account. The deadline is the sameconnectedUntilthe unpartitioned path enforces out of shared storage — 30 days by default — and a restored session reads the account's published state once, in the background, to catch a revoke made while the tab was closed, which reaches it no other way. That read is a revocation check, not a fold: everything else still arrives over the bus, and a fold that lands re-persists the record so the next reload does not undo it. The iframe keeps its ownswarm-id-device-idand lease cache — it is a device, named after the dApp it belongs to in the roster (#643), and its liveness comes from presence like every other device's; nothing expires a device (see Message kinds). -
What that session PUBLISHES, and why a partial view is safe to publish (#579). A partitioned session writes its own device-state feed from the same hydrated view — narrowed to this app's reachable stamps, and only as current as the last
account-deltait folded. It is safe because the read fold (foldAccount) unions every device's view per id: a publisher states what it holds and never what it lacks, so the stamps the narrowing removed are supplied by the first-party context's own feed and cannot be deleted by their absence here. What it does hold cannot revive anything either — collections merge per entry on tombstone clocks, so a peer'srevokedAt/deletedAtbeats the connect-time copy, and the scalars ride per-field clocks (accountNameAt,defaultStampAt,settingsAt), so a stale rename loses rather than clobbers. Pinned by tests inlib/src/sync/device-state.test.ts("a stale, narrowed publisher"). Since #568 the view is not frozen for the session either: a delta that lands folds in and is re-persisted, so what it publishes is the handover plus every overlap since. -
On successful hydration
uploadModeflips touser-stampandensureCanUploadpasses;storagePartitionedstays surfaced inConnectionInfofor UI messaging. The download-only session that used to exist for a handover without an account projection is gone (#642): the projection is required, and that requirement is the check.
- The socket now buys something even for a lone dApp tab: presence is what makes this device a rival to every other context of the account, and what tells it who its own rivals are. Any gating of the signaling attach (#581) has to be weighed against that.
- On Safari, during normal dApp use the iframe is often the only live context — so correctness must never depend on the bus, only latency does. That is exactly the split between durable stores (unchanged) and bus (fast path).
- One code path everywhere: Chrome/Firefox cross-tab propagation moves onto the same bus (BroadcastChannel transport) instead of bespoke storage-event plumbing, and cross-device propagation gets faster on every browser. Safari stops being a special case.
- The socket buys something even for a lone dApp tab: presence is what makes this device a rival to every other context of the account, and what tells it who its own rivals are. Any gating of the signaling attach (#581) has to be weighed against that.
- The bus interface is the SWIP-60 seam. Its brokered per-topic model maps 1:1; adopting it is a transport adapter, not a redesign.
Each landed as its own PR chain, in this order:
-
Bus core + BroadcastChannel transport (
lib/src/bus/, #547): theBusTransportinterface, the encrypted envelope, account-topic derivation, andaccount-deltawired into the existing storage-event plumbing (handleAccountStorageChange). Zero new infrastructure. -
Signaling service + WebRTC transport (#547, hardened in #573/#575/#577): a bespoke
wsserver (signaling/, chosen over Trystero'sws-relaybecause the per-peer relay fallback is not in Trystero's protocol and the bespoke server stays dependency-light), the DataChannel mesh transport, and the relay fallback. Deployed as thebus-signalingservice in.do/swarm-id-app.yamlatwss://swarm-id.snaha.net/bus, with the URL baked into the UI build asPUBLIC_BUS_SIGNALING_URL;pnpm devruns it on port 5520. -
Safari write enablement (#547, #578, #635, #642, #643, #644): the widened
AuthDataandsendSecretToOpener, hydration inhandlePopupMessage, the persisted partition session, roster naming for partition devices, the tombstone that survives a poll, and the removal of the download-only session. -
Bus-accelerated leases (#547, #576, #582, #593): a slot-waiting acquire broadcasts
lease-requesteach poll round (onSlotWaitdep) with a fresh 8-hexrequestId; an idle live holder yields via the normal idle-yield release path (yieldForPeer, guarded byPEER_YIELD_MIN_IDLE_MSand the in-flight upload count, under the write lock) and answerslease-released, which wakes the waiter's poll sleep (notifySlotMaybeFree) — handover in ~one bus round-trip instead of the 10 sLEASE_REFRESH_MSpoll. The Swarm lock-SOC protocol is untouched as the authority and offline fallback.Scoped to the batch, like the lock it front-runs (#589). Every lease message carries a
batchId, and a receiver drops one naming a batch it does not serve. A lane is(batchId, partition)— the lock, intent and occupancy addresses all hash the batch — so a holder on batch X and a waiter on batch Y contend for nothing, and answering across batches would drop a lease that helps neither. The bus must be neither broader nor narrower than the lock: while lanes were account-wide, the same argument said these messages must NOT carry a batch, and filtering on one would have suppressed legitimate yields.Exactly one holder answers (#576). A waiter needs one slot, but the request names no partition, so every idle holder used to release at once and whoever lost the re-race got "Uploads are unavailable". Every holder that would presently yield (
canYieldForPeer) derives the same permutation of the partitions from therequestIdand waits its own rank in it —rank = (requestId₁₆ + partition) % partitionCount,PEER_YIELD_RANK_STEP_MSapart. Rank 0 answers immediately, so a single-holder handover is not slowed at all.The winner publishes
lease-claimbefore it starts releasing, and later ranks stand down on that: releasing is two stamped Swarm writes, so a signal sent on completion would reach them only after they had all begun releasing too.lease-releasedstill carries therequestIdand also stands holders down, covering a missed claim. If the winner then declines (an upload slipped in), the round goes unanswered and the next one draws a fresh id.requestIdis optional on the wire — a peer on an older bundle is answered the way it always was — but a present one must be^[0-9a-f]{8}$, since it is parsed straight into the rank seed.The waiter's wake is sticky for one round (#582): the answer usually lands during the
acquire()at the top of the poll loop — it is a reply to the request that loop broadcast one line earlier — where there is no sleep to wake and the scan it interrupts read the state from before the release. The flag lives on a per-wait state object, so a wait left running detached byensureLease's timeout race cannot clear the live one's, and it skips at most one sleep per wait (each round re-broadcasts, so an unconditional skip would be one acquire per bus round-trip). -
The SwarmID tab as a bus peer (#608, #610, #631): it publishes a delta on every account mutation, and folds a peer's into shared storage (
ui/src/lib/stores/account-delta.ts). The second half is what reaches a device's unpartitioned contexts at all: they converge throughstorageevents, and no storage event crosses a device boundary, so before this a revoke on device A never reached device B's proxy iframe. The fold is the one beside it for the Swarm read path — the shared LWW primitives, thenapplyRefreshed, whoseskipSyncis also the echo guard: a change folded FROM a peer is never published back at it (the proxy states the same rule assource === "bus").The fold is scoped to the room's own account: a delta naming a different one is dropped, as it is in the proxy. The topic is derived from the account key, so this is belt and braces — but without it a peer holding one account's room keys could write into another account co-resident on the device.
One echo does survive, bounded: with a dApp tab open, the fold's storage write reaches an unpartitioned proxy iframe as a
storageevent, and that iframe republishes the merged snapshot (schedulePublish("change")). It terminates after that one round trip — LWW converges, and an identical-bytessetItemfires no further storage event — so it is a trailing confirmation of the merge, not a loop. This is also why nothing on the bus may stamp a fresh clock into the snapshot on every publish: it would turn that one round trip into a loop (see Presence). -
Presence (#655): the heartbeat above, in the proxy and the SwarmID tab, feeding the rival set and the dev device list's Online badge.
- SWIP-60 transport adapter, once bee/bee-js release it (#571).
- Replay protection (#604): envelopes carry no nonce or timestamp. The lease paths are bounded by their own guards; a replayed delta or presence is inert or merely refreshes what was already true.
- Gating the signaling attach (#581) — see Why this shape.
Done:
- Unit (Vitest, TDD): envelope encryption/schema, "delta merged over bus ≡ delta merged from feed payload", lease fast-path state machine and rank election, lane-scoped utilization deltas, partitioned hydration and the persisted session, presence (the tracker; the proxy beats on join and per interval and stops on destroy; a peer's beat enters the rival set and ages out; the SwarmID tab beats and lists a peer). The relay tests run the real signaling server; WebRTC is faked.
- Playwright e2e over the real signaling server (
ui/tests/bus-propagation.test.ts, #569): a genuinely partitioned proxy iframe — the demo browsed under a loopback literal while the proxy origin stayslocalhost, so the two are cross-site — reached by an app removal published from the SwarmID tab, by a rename made on another device (the metadata scalars fold on their per-field clocks since #610), and by a partitioned connect whose upload round-trips; and a change on one device landing in another device's stored account. The partition itself is asserted first, because Playwright's default chromium args turn third-party storage partitioning off and a suite that skips that check proves nothing. - Presence on the local rig (2026-09-02, two browser contexts on one account): each device shows the other online 25 s after it joins (the first interval beat plus the dev list's 5 s tick) and drops it when the presence window lapses after its tab closes — 60 s when this was measured, three minutes since the window widened to survive a throttled background tab.
- Handover latency against the local cluster and the dev signaling server
(
scripts/bus-handover-latency.ts, #660; 2026-09-02, K=2, 2 s intent window). Three simulated devices on a two-partition batch, a fresh account per row: a free partition costs C 5.3 s (the floor, intent round included), a bus handover 9.0 s, a holder's idle tick 39.4 s, and a lapsed lock with nobody refreshing 48.5 s. The bus itself is the 1.4 s from C's call to the holder'slease-released— two stamped release writes, over the relay, since Node has noRTCPeerConnection; the remaining ~7.5 s is the ordinary cold claim. A one-off script, not suite coverage: it runs on demand, never on commit. - Real Safari (iOS 18.7 / Safari 26.6, against the DO deployment, #584): ITP partitions the
iframe, the connect popup's
window.openerpostMessagereaches it, the hydrated view builds a working stamper, a chunk uploads and reads back byte-identical, and the device id holds across a reload. A private window passes the same five checks, with its own device id, discarded when the window closes.
Not yet written:
- Lease handover with a signaling server in the live suite — sub-second with both peers live,
timeout fallback with one peer killed. Extends
lib/test/live/multi-device-acquire-upload.test.tsandthree-device-acquire-handoff.test.ts, not the Playwright rig. - Whether a dormant account's partitioned storage survives ITP's ~30-day window (#664) — two loads in one sitting say nothing about it. Nothing in the bus depends on the answer now that stale devices are ignored rather than removed.
- A mixed-version window means two holders of one lane. The lock, intent and occupancy
addresses derive from the batch since #589, so an
old bundle and a new one compute different addresses for the same
(batch, partition)and each reads the other's lane as free — two writers in one slot set, not a stale read. Both sides deploy together, which is what makes that window short rather than absent; it is the same deploy-in-step property #588 tracks for the channel rename, and the first change here where the cost of getting it wrong is a corrupted lane rather than a missed message. - A revocation made while a dApp partition is closed is not pushed to it: the room has no mailbox. Since #635 the popup re-handshake is no longer what catches it either — a restored session reads the account's published state once instead, and ends itself on a tombstone. Two holes remain, both fail-open by choice (failing closed would make every offline load a logout): a session restored while the gateway is unreachable stays up, and an account that has never published — no drive, so no feed to read — has nothing to check against. Either way the revoke waits for the next live overlap on the bus.
- That check asks only whether the connection ENDED — a missing entry, a
revokedAt(Remove), or adisconnectedAtnewer than the connection the session came from (Disconnect). A rotated signer key or moved default stamp made while the tab was closed is not adopted on restore; the session comes up on the handed-over view and corrects at the next live overlap. Changes that arrive while it IS live are folded and re-persisted, so they survive a reload. - Network settings do not reach a partitioned session at all. The Bee node and RPC URLs are
browser-local (
swarm-id-network-settings), not account state, so they ride the connect handover and nothing refreshes them: a node changed in the first-party SwarmID tab is picked up at the next connect. Deliberately out of scope for the bus — the settings are per-browser, and a peer's copy would be as meaningless as its session deadline. The proxy'sstoragelistener for that key is skipped while partitioned, since the store it would re-read is the partition's own and holds nothing: loading it would silently reset the session to the public gateway and the default RPC (#580). - Without TURN, WebRTC will not connect across restrictive NATs; those pairs fall back to the signaling-server relay.
- A newcomer's join-time presence beat is dropped (its socket has no peers yet), so remote peers first count it at its first interval beat, up to 20 s later. Its own view of the room fills in the same way. The registry's 30-minute sign-in window covers the gap for the rival set.
- A departure is only as prompt as the socket's close. A tab closed normally produces one at
once; a crashed peer or a dropped network waits for the server's ping cycle to reap the
socket, and the presence window covers it until then. The lock is not reclaimed any
faster either way: releasing it is a stamped SOC write the dying page cannot finish, so
LEASE_TTL_MSremains the mechanism for a partition a closed tab was holding. - The same promptness cuts the other way on a reconnect: a peer whose socket closes cleanly
and comes back (a
1013backoff, a blip the client sees before the server does) is dropped at once and is missing from every rival set until its rejoin beat, so a claim made in that window sees no rival and skips the intent round. This is a new way to flap out of the live set, which the three-minute window was sized to avoid; it is safe because the occupancy beacon on the refresh tick backstops a peer that is invisible right now (partition-lease.ts). - Safari (ITP) deletes script-writable storage for sites without first-party user
interaction. The window is 30 operational days by default since a Feb 2024 WebKit
change (
DataRemovalFrequency); the widely-cited 7 days now applies only to sites reached cross-site with unfiltered link decoration from a classified domain (webkit.org's docs predate the change). Impact here: partitioned iframe storage is re-seeded per popup handshake (harmless), and every Safari connect is a first-party click in the popup that resets the trusted domain's clock. The residual risk is a dormant local account's vault (encryptedSeed, the only copy of the seed) after >30 operational days with no connect; synced accounts restore from Swarm.navigator.storage.persist()is NOT a mitigation (checked against WebKit source, 2026-08): WebKit grants it only to origins already exempt from ITP deletion (home-screen/Dock web apps, app-bound/MDM domains — exemption is the grant's precondition, not its effect;fb634d8), it returnsfalseunconditionally in cross-origin (partitioned) iframes, and the ITP deletion path never consults the persisted marker (it only shields quota-pressure eviction). The only real escape hatches: recent first-party interaction, or installing the trusted domain as a home-screen/Dock web app.
| Option | Verdict |
|---|---|
| Storage Access API (non-cookie extension), FedCM, Partitioned Popins | No Safari implementation or timeline; cookie-only SAA also demands a prior first-party visit + gesture + prompt. Dead end. |
| "Safari iframe as a device" over Swarm feeds alone | Sound for synced accounts and kept as the durable layer, but excludes local accounts (P1) and inherits the slow lease UX unaccelerated (P2). |
| Third-party store-and-forward relays (Nostr NIP-78, public MQTT, Waku) | Right semantics, wrong dependency: no guarantees from unrelated networks (Nostr), test-only ToS (public MQTT), heavy/pre-1.0/mid-rebrand (Waku → Logos). Rejected. |
| Popup performs all writes (Coinbase keys.coinbase.com model) | Keeps keys first-party but costs a visible popup per upload session, fights popup blockers/gesture rules, and iOS popup lifetimes are fragile. |
| PSS / GSOC subscribe in the browser | Receiving requires operating a full node in the mined neighborhood; GSOC is a doorbell (last message only), not a mailbox. Revisit as a doorbell once we run infrastructure. |
| WebRTC as the primary channel | No store-and-forward, so unusable alone; adopted instead as the live fast path over durable stores — which is this design. |
| A retained-message mailbox on the signaling server | Considered in the original design so a change could reach a closed partition; never built. The restore-time revocation read (#635) covers the case that mattered. |