Multi-Device Postage Batches
This page is the definitive, implementation-level specification of how Swarm ID lets
several devices that share one account write to a single mutable postage batch at
the same time without corrupting each other’s data. It is written so the scheme can be
re-implemented on another platform (native mobile, desktop, a different language) and
interoperate with the reference TypeScript implementation in @snaha/swarm-id.
Everything here is derived purely from Swarm primitives (chunks, single-owner chunks, epoch feeds) and a handful of deterministic key derivations. This layer is the record everything else defers to, and it needs no server, no coordinator, and no shared mutable state other than what lives on Swarm itself. The contexts of one account that are open at the same time also share the account bus, which shortens every wait described below to about one round trip; it never replaces the protocol here, and a port can leave it out.
Two independent systems
Section titled “Two independent systems”The account shares one batch across devices. Two distinct problems arise, solved by two independent mechanisms:
- Partition lease — prevents two devices from stamping the same
(bucket, slot)of the batch (which would make one chunk silently overwrite the other on the network). Each active device leases a partition of every bucket’s slot space and only ever stamps slots in its own lane. - Account-state sync — keeps the account (connected apps, postage stamps, the device list, settings) converging across devices. Each device writes its own feed, and a reader folds every device’s latest view into one account.
They are independent: account-state sync would be needed even with one batch per device, and the partition lease would be needed even if account state never changed. A device that holds a partition lease is also the device permitted to publish account state (the “partition gate”, below). The account bus cuts across both: it carries a snapshot to live peers ahead of the feed, and lets a waiting device ask a live holder for its partition instead of polling for it.
Key derivation (the interop root)
Section titled “Key derivation (the interop root)”All cross-device addressing derives from the account’s derivation key (derivationKey),
a 32-byte secret stored on the account and held by every device. Two devices interoperate
if and only if they derive the same keys from the same derivation key. Where the
derivation key itself comes from is on Key Derivation.
The primitive is deriveSecret(key, context):
deriveSecret(key, context) = hex( HMAC-SHA256( bytes_from_hex(key), utf8(context) ) )Note the asymmetry: key is interpreted as a hex string decoded to bytes, context
is encoded as UTF-8. From this:
swarmEncryptionKey = deriveSecret(derivationKey, "swarm-encryption") // 32 bytesbackupKey = deriveSecret(swarmEncryptionKey, "backup-key") // 32 bytesbackupSigner= the secp256k1 private key whose scalar isbackupKey. Its Ethereum address (backupSigner.publicKey().address()) is the owner of every feed and every lock SOC in this document. Because the owner is per-account, the same identifier/topic across two different accounts still produces different chunk addresses — domain separation comes from the owner, not from baking the account ID into hashes.swarmEncryptionKeyencrypts the lock-SOC payloads, the intent beacons, and the account feeds, and seeds the per-utilization-chunk key derivation.
Per-device identity:
deviceId— a per-install UUID (crypto.randomUUID()), stored under the localStorage keyswarm-id-device-id. Stable for the life of the install; not derived from the account, so each device has a distinct one.tiebreaker=hex( keccak256(utf8(deviceId))[0:8] )— the first 8 bytes of the keccak256 of the device ID, as hex. Used to deterministically order two writers that share a millisecond timestamp. Two distinct device IDs collide with probability 2⁻⁶⁴.
Batch slot layout & partitions
Section titled “Batch slot layout & partitions”A Swarm postage batch of depth D has:
NUM_BUCKETS= 65 536 buckets (2^16,BUCKET_DEPTH = 16).2^(D-16)slots per bucket (calculateMaxSlotsPerBucket).
A chunk’s bucket is the first two bytes of its 32-byte address, big-endian:
bucket(addr) = (addr[0] << 8) | addr[1] // 0 .. 65535The slot space of every bucket is divided into PARTITION_COUNT = 2 interleaved lanes.
The lowest PARTITION_COUNT slots are reserved (not data):
- Slot index
p(forpin[0, PARTITION_COUNT)) is reserved for partitionp’s lock SOC and counter (utilization) chunks. - Data slots begin at
DATA_COUNTER_START = PARTITION_COUNT.
The physical slot for partition p’s j-th data chunk in a bucket
(dataSlot(p, j, K), with K = PARTITION_COUNT):
slot = K + p + K · j // j = 0, 1, 2, … (per-bucket, per-partition data counter)So with K = 2: partition 0 writes data to slots {2, 4, 6, …}, partition 1 to
{3, 5, 7, …}, and slots {0, 1} are the two partitions’ reserved slots. The lanes
never collide, so two devices on different partitions can stamp the same bucket
concurrently.
Per-partition per-bucket capacity (partitionCapacity):
capacity = floor( 2^(D-16) / K ) − 1 // −1 for the reserved slotDepth D |
slots/bucket | per-partition data slots/bucket (K=2) |
|---|---|---|
| 20 | 16 | 7 |
| 22 | 64 | 31 |
| 24 | 256 | 127 |
| 28 | 4 096 | 2 047 |
Legacy single-device accounts use partitionCount = 1 (reserved slot 0, data from slot 1
onward) and skip the lease entirely.
Utilization tracking
Section titled “Utilization tracking”Each device tracks, per bucket, how many data chunks its partition has written —
the j counter above. This is held in memory as a Uint32Array(65536) (dataCounters),
always uint32 in memory regardless of how it is serialized.
On-disk / on-wire codec
Section titled “On-disk / on-wire codec”The counter array is serialized into fixed CHUNK_SIZE = 4096-byte chunks. The per-bucket
counter is uint16 when it always fits (D ≤ 31, max 2^15), else uint32
(D ≥ 32). This sets the chunk layout (getChunkLayout):
| Codec | Applies when | bucketsPerChunk |
numUtilizationChunks |
|---|---|---|---|
| uint16 | D ≤ 31 |
2 048 | 32 |
| uint32 | D ≥ 32 |
1 024 | 64 |
Counters are little-endian in the serialized chunk. Chunk i covers buckets
[i·bucketsPerChunk, (i+1)·bucketsPerChunk).
Per-chunk encryption key
Section titled “Per-chunk encryption key”Utilization chunks are uploaded as encrypted content-addressed chunks. The key for
chunk i is deterministic so re-saves dedup:
chunkKey = HMAC-SHA256( swarmEncryptionKey, utf8("swarm-id-util-chunk-v1") ‖ batchId ‖ uint32_be(chunkIndex) ‖ uint32_be(nonce))nonce starts at 0 and is incremented only when the resulting encrypted address would
land in the same bucket as another utilization chunk in the same save (collisions are
vanishingly rare — ≈0.008 bumps per save with 32 chunks over 65 536 buckets). The nonce
that produced a chunk is cached so an unchanged plaintext reuses the same key and the
upload is skipped.
Local cache
Section titled “Local cache”The reference implementation caches counter chunks in an IndexedDB store
(UtilizationStoreDB, DB name swarm-utilization-store), keyed by (batchId hex, chunkIndex),
storing { data, contentHash, nonce, lastAccess }. A port may use any local KV store; the
cache is an optimization, not part of the wire protocol. What a device resumes from across
devices is the published partition state (below), not the cache.
Lock SOCs
Section titled “Lock SOCs”A single-owner chunk (SOC) per (batch, partition) is the cross-device authority for “who
holds this lane right now.” All of an account’s devices share the backupSigner, so any of
them can write any lane’s lock SOC; concurrent writers are ordered by a fencing token.
The batch id is part of the identifier, not just the partition. A lane’s slot space is computed
within a batch, and the state riding on it is published per batch, so keying the lock on the
partition alone made lane p of batch X and lane p of batch Y the same chunk address — two
coordinators that share no slot would take turns anyway. Per-app stamp overrides put two live
coordinators on two batches without any multi-batch feature, so this is reachable in normal use
(#589).
identifier = keccak256( utf8("swarm-id-partition-lock-v1:" + batchIdHex + ":" + partition) )owner = backupSigner addressSOC address = keccak256( identifier ‖ owner ) // 32 bytesencryption = swarmEncryptionKeyPayload (JSON, ~150 bytes, encrypted):
{ "holderDeviceId": "uuid-or-empty-string", "generation": { "timestampMs": 1730000000000, "tiebreaker": "a1b2c3d4e5f6a7b8" }, "acquiredAt": 1730000000000, "leasedUntil": 1730000030000}holderDeviceId— the current holder’s device ID, or the sentinelNO_HOLDER_DEVICE_ID = ""(empty string) after an explicit release.generation— the fencing token.compareGenerations(a, b)orders bytimestampMsfirst, then bytiebreakerlexicographically. The higher generation always wins.
Release protocol (generation-fenced)
Section titled “Release protocol (generation-fenced)”A release is an action on a specific claim, not on the partition: the sentinel carries the generation of the claim it releases — never a fresh one — so any later claim (a successor on the same device, or a peer) is logically newer than the release. Before writing, the releaser re-reads the lock and skips the sentinel when the lock no longer carries the claim being released:
- an existing sentinel (any generation) — already released; rewriting would mint a fresh postage stamp that could clobber a claim landing in between;
- a foreign claim, live or expired — never clobber a peer (expired already reads as takeable);
- our own claim with a newer generation — a successor re-acquired while the release was publishing.
Our own claim at a non-newer generation writes (<=, not ==): a stale read returning an
older refresh of the same holdership must not suppress the release. This matters because
Bee replaces a same-address SOC whenever the new chunk’s postage stamp timestamp is
newer — without the fence, a detached release outliving a same-device re-acquire would
deterministically clobber the successor’s claim and peers would read the partition as
free (turning one writer into two). In the reference implementation the sign-out release
is additionally serialized under the same origin-wide write lock the acquire path uses,
so its sentinel’s stamp is always minted before a successor’s claim stamp. A holder that
nevertheless sights a sentinel on its own partition re-asserts its claim at the next
upload or refresh tick.
Residual (accepted): a releaser whose node serves a stale view (e.g. a caching gateway
that never re-fetches) can still write its fenced sentinel over a peer claim it cannot
see; recovery is the displaced peer’s refresh re-assert, bounded by LEASE_REFRESH_MS.
Acquire / refresh protocol
Section titled “Acquire / refresh protocol”A SOC has no compare-and-swap; ordering comes from the generation fence plus a guard
window PARTITION_LOCK_GUARD_MS = 2000 ms:
- Read the lock SOC.
- If a live foreign holder exists (
holderDeviceIdis neither us nor empty, andleasedUntil > now) → returnblocked, write nothing. - Otherwise write our claim (
holderDeviceId = us, freshgeneration,leasedUntil = now + LEASE_TTL_MS). - Wait
guardMs, then re-read. - If our generation is still the latest visible →
acquired. If a higher generation appeared during the guard →lost-race(a peer beat us; fall back to read-only). - If the verify-read fails to return anything (transient Bee error / propagation lag) →
optimistically
acquired— we observed no live foreign holder before writing, so a failed read is not proof of a race; the periodic refresh reconciles and only demotes on a confirmed live foreign holder.
Reserved-slot routing
Section titled “Reserved-slot routing”The lock SOC’s chunk lands in some bucket (bucket(SOC address)). The stamper routes the
overstamp to that bucket’s reserved slot = the partition index, so the tight heartbeat
cadence never consumes data-slot budget and the SOC always occupies the same slot. The
counter (utilization) chunks for a partition are routed the same way.
Intent beacons
Section titled “Intent beacons”The lock SOC has one fixed address, and a Bee node that has once served it may keep serving the cached copy. Two devices on different gateways that both find a partition free can then both claim it without ever seeing each other. The intent beacon closes that gap: before binding a free partition, a device writes a small SOC at an address that rotates every epoch and that only it writes, then reads every other known device’s address for the same epoch:
identifier = keccak256( utf8("swarm-id-partition-intent-v1:" + partition + ":" + deviceId + ":" + epochBucket) )owner = backupSigner addressencryption = swarmEncryptionKeyepochBucket = floor( now / INTENT_EPOCH_MS )A read of an address the node has never served cannot come from its cache, so it goes to
the network, where the rival’s write already sits. Contenders order themselves by
generation; only the lowest binds the lock, the rest step back. A read that times out
counts as “no intent”, so one unreachable device cannot block everyone else. A second,
device-independent occupancy beacon (swarm-id-partition-occupancy-v1) says whether the
partition is in use at all, for a device whose list of peers is incomplete.
Both beacons are stamped into the contended partition’s reserved slot, so they can never
share a (bucket, slot) with user data.
Partition state
Section titled “Partition state”Each (batchId, partition) has its own published counter: the holder’s per-bucket data
counter. The holder publishes it on every upload and on release; the next holder reads it on
acquire and resumes from exactly that counter, so a hand-off never re-uses a slot.
A write is reported complete only after the partition state that covers its slots is published. An upload that was still in flight when a holder disappeared was therefore never acknowledged, and the next holder may safely overwrite its slots.
topic = keccak256( utf8("swarm-id-partition-state-v1") ‖ batchId ‖ uint32_be(partition) )owner = backupSigner addressThe counter is not a single blob. It is split into the same numUtilizationChunks counter
chunks, each uploaded with a random encryption key to the partition’s reserved slot,
yielding a 64-byte encrypted reference (address ‖ key). A single reference chunk
concatenates those references (N · 64 ≤ 4096 bytes, one chunk even at N = 64). A taking
over device follows pointer → reference chunk → counter chunks, decrypting each from its
embedded key.
The state pointer
Section titled “The state pointer”The reference chunk’s address is published in a state-pointer SOC whose identifier rotates every epoch:
identifier = keccak256( topic ‖ uint64_be( floor(now / STATE_POINTER_EPOCH_MS) ) )owner = backupSigner addressSTATE_POINTER_EPOCH_MS = 30 000A reader computes the address directly, with no feed walk, and the rotating address cannot
be served from a gateway’s stale cache. The holder rewrites the pointer into the current
epoch on every refresh, so an idle but live holder keeps a fresh resume point. A taking-over
device reads the current and previous epoch around now; for a holder that is gone, it reads
every epoch in the span leasedUntil − LEASE_TTL_MS … leasedUntil plus a little slack, all at
once, so the lookup costs about one read timeout however long ago the holder stopped.
No pointer found and a clean lock means there is no state: resume from zero. No pointer found but an unreadable lock means fail safe: read-only, retry later, never seed a zero counter.
Wire format (byte layout)
Section titled “Wire format (byte layout)”The format is binary and is versioned by the topic domain
(swarm-id-partition-state-v1); there is no in-blob version byte. A future v2 publishes
under a v2 topic and never collides with v1 readers.
Reference chunk — exactly N × 64 bytes, where
N = numUtilizationChunks(batchDepth):
| Offset | Size | Content |
|---|---|---|
i · 64 |
32 | Swarm address of counter chunk i, or all zeros if never written |
i · 64 + 32 |
32 | Decryption key of counter chunk i (random, generated per publish) |
A counter chunk that is entirely zero is not uploaded; its entry in the reference chunk is all zeros and the reader treats it as a zero chunk.
Counter chunk i — exactly 4096 bytes (CHUNK_SIZE): bucketsPerChunk consecutive
per-bucket counters, little-endian, covering buckets
[i · bucketsPerChunk, (i + 1) · bucketsPerChunk). The counter width depends on the batch
depth (getChunkLayout):
| Batch depth | Counter width | bucketsPerChunk |
N (chunks) |
|---|---|---|---|
≤ 31 (UINT16_COUNTER_MAX_DEPTH) |
uint16 LE |
2048 | 32 |
≥ 32 |
uint32 LE |
1024 | 64 |
The decoded result is always a Uint32Array(65536) regardless of the on-wire width.
writePartitionState validates its input against PartitionStateSchemaV1, and
readPartitionState validates the reassembled counter, the reference chunk’s length and
each counter chunk’s length before returning.
Incremental publish
Section titled “Incremental publish”Given the previous publish’s references and the counter it published, only the chunks whose bytes changed are uploaded again; unchanged chunks keep their reference. A typical upload changes one or two chunks, not all 32. On acquire the lease seeds this baseline from the resumed state, so even the first publish of a session is incremental.
Read semantics
Section titled “Read semantics”Reads short-circuit on a cached “synced reference”: if the pointer still names the reference a device last synced with, it skips the downloads and reuses its local counter.
When the pointer exists but the reference chunk or any counter chunk cannot be read, or fails the checks above, the read reports failure without caching the bad reference, and the device falls back to read-only without claiming the partition, retried on the next acquire. It must not seed a zero counter: stamping from zero would re-issue every used slot and evict the partition’s data chunk by chunk.
Both sides also protect the published chunks from later overstamps: the publisher avoids the buckets of the partition’s lock SOC and of its live utilization chunks, and every device records the published state chunks’ buckets so its own utilization saves avoid placing chunks there. All of these chunks share the partition’s reserved slot, and a collision would evict the resume point a later takeover depends on.
Lease lifecycle
Section titled “Lease lifecycle”The reference orchestrator is PartitionLease. Constants:
| Constant | Value | Meaning |
|---|---|---|
LEASE_TTL_MS |
30 000 ms | A lease is valid this long; a crashed holder is reclaimable after. |
LEASE_REFRESH_MS |
10 000 ms | Holder re-writes the lock SOC this often (3 refreshes per TTL). |
IDLE_YIELD_MS |
30 000 ms | Holder voluntarily releases after this long with no upload. |
PEER_YIELD_MIN_IDLE_MS |
3 000 ms | Holder answers a peer’s bus request only after this long with no upload. |
PARTITION_LOCK_GUARD_MS |
2 000 ms | Guard window between claim-write and verify-read. |
SLOT_WAIT_TIMEOUT_MS |
30 000 ms | A device with no free partition polls this long for one to free up. |
INTENT_EPOCH_MS |
30 000 ms | Epoch of the intent and occupancy beacons. |
INTENT_GUARD_WINDOW_MS |
12 000 ms | How long a contender watches for rival intents before binding. |
STATE_POINTER_EPOCH_MS |
30 000 ms | Rotation period of the state-pointer SOC. |
SYNC_READ_TIMEOUT_MS |
2 500 ms | Bound on every lock, beacon, and roster read on the acquire path. |
PRESENCE_INTERVAL_MS |
20 000 ms | Account-bus liveness message; feeds the rival set, not the lock. |
PRESENCE_MAX_AGE_MS |
180 000 ms | A device unheard this long leaves the rival set (three throttled beats). |
KNOWN_DEVICE_MAX_AGE_MS |
30 min | A device that signed in this recently counts as a rival even with no bus. |
acquire(snapshot), where snapshot is { partitionCount }, returning
{ partition, partitionCount, localCounter, isReadOnly, lockPayload? }:
- If
partitionCount ≤ 1→ legacy single-device, no lease. - Read, concurrently: every partition’s lock SOC, the per-device intent and presence beacons, the device-independent occupancy beacon, and a speculative state pointer. The extra channels catch a live peer that has not yet written a lock.
- Choose the partition we already hold, else the lowest free/expired one
(
pickFreeOrExpired). If every partition has a live foreign holder → read-only. claimPartition— seed the counter from the partition state, then run the lock acquire/verify protocol. A contended fresh claim first runs an intent round; a device that is demonstrably alone takes a solo-clean fast path that skips the guard window. Onacquired, bind the counter to the stamper; onblocked/lost-race, fall back to read-only.
The whole call is bounded by PARTITION_LEASE_ACQUIRE_TIMEOUT_MS (45 s), within which a slot wait
is bounded by SLOT_WAIT_TIMEOUT_MS (30 s).
refresh() re-runs the lock protocol on the held partition to bump leasedUntil and heartbeats
the state pointer. It returns a LeaseRefreshOutcome, not a boolean:
held— still ours.lost—blocked,lost-raceoraborted. (A refresh that overlaps an in-flightrelease()aborts before writing, so it cannot mint a ghost claim the fenced release would refuse to clear.)displaced— a foreign beacon carries an earlier generation, so another device has a better claim. The coordinator handles this differently from a plain loss.release(localCounter)publishes the final counter to the partition state and its pointer, then writes the generation-fenced release sentinel (see “Release protocol” above) — skipped entirely when the lock no longer carries the claim being released.adoptIfLive()/hydrate()/serialize()let a reload re-adopt a still-valid lease from a local cache without a Swarm round-trip (re-validated on the next refresh).
The four cases
Section titled “The four cases”- Case A — first device, fresh batch. No lock SOC, no partition-state. Take partition 0, seed a zero counter.
- Case B — second device. Partition 0 is live-held; take partition 1, seed its counter from its published partition state (zero if never published).
- Case C — third+ device, turn-taking. All
PARTITION_COUNTpartitions are held. The new device asks the live holders for a slot over the account bus each round; exactly one idle holder answers, releases, and the waiter claims the freed partition in about one round trip, resuming from the published counter. Without a live answer it waits (SLOT_WAIT_TIMEOUT_MS, polling everyLEASE_REFRESH_MS) for a holder to go idle (IDLE_YIELD_MS→ it releases) or for a lease to lapse. - Case D — crash recovery. A holder dies without releasing. Its
leasedUntilexpires afterLEASE_TTL_MS; a peer then reclaims the partition. Resume is from the last published counter — an unclean crash that stamped past its last publish loses only uploads that were never reported complete. If the published counter exists but cannot be read, the taker stays read-only and retries rather than resuming from zero.
Account-state sync
Section titled “Account-state sync”Account state is one feed per device, folded on read. There is no shared document, so two devices never write the same address and there is nothing to arbitrate. The design record is docs/Account-State.md.
┌──────────────── per account, one owner: the backup signer ────────────────┐ discovery ─────▶│ roster feed swarm-id-roster-v1:<accountId> sequential feed │ rare writes (rare) │ each device appends its own Device record at the next free index │ (membership only) ├────────────────────────────────────────────────────────────────────────────┤ hot path ──────▶│ device-state feed swarm-id-devstate-v1:<accountId>:<deviceId> epoch feed│ frequent writes, (per device) │ latest entry = that device's full current view of the account │ no contention └────────────────────────────────────────────────────────────────────────────┘ read ──────────▶ roster → every live device's latest view, in parallel → fold → one accountEvery feed is owned by the backup signer and its payload is encrypted with
swarmEncryptionKey. Feeds are told apart by topic, not by signer, which is safe because
every device is the same user and already holds derivationKey.
Roster feed
Section titled “Roster feed”swarm-id-roster-v1:<accountId>, a sequential feed. Each device appends only its own
Device record, at the next free index, so a concurrent announce can never overwrite a
peer’s entry. The worst case is two devices racing for the same fresh index; the loser’s
record is appended again on its next sync. Readers scan the feed in parallel windows and
fold the entries by deviceId. A device appends only when it is missing from the roster or
its removedAt state changed, so the roster is written rarely.
Device-state feed
Section titled “Device-state feed”swarm-id-devstate-v1:<accountId>:<deviceId>, an epoch feed whose latest entry is the
device’s full current view (DeviceStateSnapshotSchemaV1):
{ version: 1, accountId, deviceId, timestamp, connectedApps[], postageStamps[], // with their tombstones accountName: { value, at }, // scalars carry their own clock defaultPostageStampBatchID: { value, at }, settings: { value, at }, accountPublicKey, accountCreatedAt, partitionCount } // immutables, so a fold needs no shared docA device writes its feed on a local change, never after a read, so write amplification is bounded and folding is repeatable. Both writers go through the batch write coordinator (the cross-tab lock plus the partition lease): the identity site publishes once per change, and the proxy iframe publishes with a short debounce when it acquires a lease or sees a local change.
Read: the fold
Section titled “Read: the fold”foldAccountFromSwarm derives the owner and the encryption key, reads the roster (an empty
roster means there is no backup), reads every non-removed device’s latest view in parallel,
and folds them:
| Collection | Recency clock | Tombstone |
|---|---|---|
| postage stamps | deletedAt ?? createdAt |
deletedAt |
| devices | max(removedAt ?? 0, lastSignedInAt) |
removedAt |
| connected apps | max(revokedAt ?? 0, disconnectedAt ?? 0, lastConnectedAt) |
revokedAt |
Last writer wins by that clock; tombstones are kept, and a later activity with a larger clock
brings an entry back (a removed device signing in again, for example). Fields that are plain
edits rather than statements of membership — a device’s name, a stamp’s name, an app’s drive
pointer — each ride their own clock and are laid over the winner, so a rename made on a
stale copy cannot outrank a revoke made elsewhere. The three scalars converge per field on
their at clocks, so concurrent changes to different scalars on different devices both
survive.
Restore, refresh, and the bus
Section titled “Restore, refresh, and the bus”- Restore (a device signing in to an account it has never seen): fold from Swarm and create the account from the result.
- Refresh (a device that already has the account): fold from Swarm and merge the result into the local account with the same rules, without publishing anything back.
- A peer’s bus message: an
account-deltaon the account bus is folded with the same rules, so live peers converge in about a round trip. A change folded from a peer is never published back at it.
The partition gate
Section titled “The partition gate”A device may publish account state only if it holds a partition (or can claim a free or expired one), because publishing writes chunks to the shared batch. The lock SOC decides who holds what. Which devices are worth checking for a competing claim, the rival set, comes from the account bus’s presence messages together with devices that signed in within the last 30 minutes; a live peer that set misses is still caught by the occupancy beacon. If every partition is held by a live foreign device, the publish is skipped and a peer publishes instead.
Porting checklist
Section titled “Porting checklist”To interoperate with the reference implementation, an external client must:
- Reconstruct the same
derivationKeyafter auth, then deriveswarmEncryptionKeyandbackupKeyviaderiveSecretexactly as above (HMAC-SHA256, key-as-hex-bytes, context-as-UTF-8). ThebackupSigneraddress is the owner of everything. - Generate a stable per-install
deviceId(UUID) and thetiebreaker(keccak256(deviceId)[0:8]). - Implement the lock-SOC protocol: the identifier hash over batch id and partition
(not the partition alone), SOC address, encrypted JSON
payload, the read → guard (2 s) → verify acquire flow, the intent beacons, generation
fencing, and the
generation-fenced
""release sentinel — the sentinel carries the released claim’s generation and is skipped when the lock no longer carries that claim. A port that mints fresh sentinel generations or releases unconditionally re-introduces the stale-release claim-clobbering race (#349). - Implement the slot formula
slot = K + p + K·jand route lock-SOC/counter chunks to reserved slotp. HonourPARTITION_COUNT = 2,DATA_COUNTER_START = 2. - Implement the partition state: the topic hash, the rotating state-pointer SOC, the reference-chunk → counter-chunks structure, publish-before-acknowledge, and the fail-safe (read-only, never zero-seed) handling of an unreadable published counter. Skipping the last one re-introduces silent slot-reuse data loss.
- Track per-bucket counters with the uint16/uint32 codec and the
swarm-id-util-chunk-v1key derivation (only needed to publish counters that the reference client can read). - Implement account-state sync: the append-only roster feed, one device-state epoch feed per device with the snapshot schema above, and the fold with its recency clocks and tombstones.
- Respect the partition gate: never publish account state without holding a partition.
All hashes are keccak256; multi-byte integers in identifiers and topics are big-endian
(uint32 for partitions, uint64 for epochs); serialized counters are little-endian;
every feed and SOC is owned by the backup signer.
Future work
Section titled “Future work”Tracked under the multi-device umbrella (#217):
- SWIP-60 transport for the account bus, so synced accounts get broker push through the Bee node instead of a separate signaling server. Waits on bee and bee-js releasing it; tracked in #571.
- Live-suite coverage of the bus handover — sub-second with both peers live, timeout
fallback with one peer killed — in
lib/test/live/. - A stale lock read after a bus wake still costs a full poll interval and can make a second holder yield (#724).