OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / f61abf1

0.3.84: converge the subscription list instead of racing to write it
0.3.83 shipped a migration that REPLACED the list, which made "which device booted first" decide the identity's roster for every other device. It fired within minutes: pushing favela-web published it, autoupdate installed it fleet-wide, and two nodes migrated — ashatron wrote its own list from its own pin, and the HANDSET wrote wj62's, so a phone's derived roster became authoritative for the identity. Nothing was lost (the union happened to be correct) and no node wrote to a repo it does not sign for. But the mechanism was wrong, and the commit message for 0.3.83 stated something false: "a handset derives its roster and therefore never migrates". The worklet sets FAVELA_ROSTER in env before importing favela-web, so a phone looks exactly like a pinned node from here. It migrated. Devices hosting the same DID ARE the same identity, so convergence should need no flag and no config. What it needs is to be order-independent: - UNION, NEVER REPLACE. Each node contributes only what the list lacks, so any interleaving of devices reaches the same set and there is no winner. - DETERMINISTIC rkeys, derived from the subject rather than a fresh TID. Otherwise two devices adding the same DID write TWO records for one membership; reads dedup by subject so it looks correct while the repo accumulates duplicates for ever. Same subject, same rkey, second write is a no-op. - CREATE-IF-ABSENT that tolerates a concurrent create — losing that race is not an error, the loser merges into the winner's list. - CONTRIBUTE ONCE PER NODE, then the list is authoritative. Grow-only plus "re-merge my local set every boot" would make unsubscribing impossible: remove a member on one device and the next device to boot re-adds it from its own stale set. A marker in the store makes contribution one-time, so removals stick. Steady state is zero writes. Both decisions moved to lib/lists.js as pure functions, because the failure they prevent was only observable on a handset. 11 new assertions. ⛔ store.list() is an ASYNC GENERATOR yielding { rkey, record } — not an array, and not the { records } wrapper a P2P peer returns. The first draft awaited it and tested Array.isArray, which finds nothing silently and would have every node re-contribute on every boot. Also fixed in passing: the test's own hash stub was 'deadbeef' + hex(subject), which collided for two DIDs because every did:plc: shares a prefix — the rkey is a truncation, so a stub that does not diffuse tests nothing. It uses sha256 now. Verified: suite green; boots clean with a pinned roster and no write transport, the merge skipping as designed. NOT verified: the merge write itself, which needs a live writer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f61abf135c0794a05a9693a29bcbe21c8da9a6a0
by plankatron <fleeky@gmail.com> · 2026-08-25T18:01:31.000Z

Changed files (4) +153 -37

bin/favela-web.js +69 -35
……@@ -44,7 +44,7 @@ import { Writer, writeTransportConfigured } from '../lib/writer.js'
4444import { SCOPE_COLLECTION, DEFAULT_SCOPE, POST_COLLECTIONS } from '../lib/scopes.js'
4545import { createMuteStore } from '../lib/mutes.js'
4646import { createBookmarkStore } from '../lib/bookmarks.js'
47import { listRecord, listitemRecord, ownLists, membersOf, memberRkey, listUri as mkListUri, LIST_COLLECTION, LISTITEM_COLLECTION, PURPOSES, CURATE } from '../lib/lists.js'
47import { listRecord, listitemRecord, ownLists, membersOf, memberRkey, listUri as mkListUri, subscriptionRkey, missingMembers, LIST_COLLECTION, LISTITEM_COLLECTION, PURPOSES, CURATE } from '../lib/lists.js'
4848import { gateRecord, choiceOf, describe as describeGate, canReply, mentionsOf, listUriOf, GATE_CHOICES } from '../lib/threadgate.js'
4949import { postgateRecord, quoteChoiceOf, canQuote, describeQuoteGate, QUOTE_CHOICES } from '../lib/postgate.js'
5050import { parseListUri, resolveRoster } from '../lib/roster.js'
……@@ -904,54 +904,88 @@ const writer = WRITE_TRANSPORT
904904 }
905905 })
906906
907// ── ONE-TIME: TURN A HAND-PINNED ROSTER INTO A REPLICATED ONE ────────────────────────────────────────
908// A node whose subscriptions came from FAVELA_ROSTER in apps.json writes that set into an
909// app.bsky.graph.list in its own repo, ONCE. After that the list replicates with the identity, every
910// device hosting the DID resolves the same roster (see the discovery above), and the env pin is dead
911// weight that can be deleted.
907// ── CONVERGE THE SUBSCRIPTION LIST ACROSS EVERY DEVICE OF THIS IDENTITY ──────────────────────────────
908// Devices that host the same DID ARE the same identity, so they should agree on what it subscribes to
909// without anyone configuring anything. This merges each node's local roster into the identity's own
910// app.bsky.graph.list, which then replicates like any other record.
912911//
913// ⛔ ONLY FROM AN EXPLICIT PIN, AND ONLY WHERE WE SIGN. `ROSTER.length` is the pinned case by
914// definition — a handset DERIVES its roster from hosted + tracked and so never migrates, which is also
915// what stops two devices racing to author the same list. `writer.state().did` confirms this node signs
916// for the identity whose repo we are about to write to.
912// ⛔ GROW-ONLY UNION, NEVER A REPLACE. The first cut wrote the list wholesale, which made "who booted
913// first" decide everyone's roster — a handset won that race against tabertron on 2026-08-25 and its
914// derived set became authoritative for the identity (#53). A union is order-independent instead: each
915// node contributes only what the list lacks, so any interleaving converges on the same set and there is
916// no winner.
917917//
918// Runs AFTER `writer` exists: this used to sit beside the roster resolve 150 lines up, where `writer`
919// is still in its temporal dead zone — a ReferenceError at boot on every node, caught before shipping
920// rather than in a log.
918// ⛔ DETERMINISTIC rkeys. A fresh TID per add means two devices contributing the same DID concurrently
919// write TWO records for one membership. resolveRoster dedups by subject so it READS correctly, but the
920// repo accumulates duplicates forever. Deriving the rkey from the subject makes the add itself
921// idempotent: same membership, same record, second write is a no-op.
921922//
922// The migrated members are subscribed IMMEDIATELY rather than at the next boot, using the same runtime
923// addPeer that following and viewing now use. A migration that needs a restart to take effect is the
924// same defect this release is fixing.
925if (!LIST && ROSTER.length && (HOSTED_DIDS || []).length) {
923// ⛔ CONTRIBUTE ONCE PER NODE, then treat the list as authoritative. Grow-only and "re-merge my local
924// set every boot" together mean an unsubscribe can never stick: delete a member on one device and the
925// next device to boot re-adds it from its own stale local set. The marker below makes contribution a
926// one-time act, so removals survive.
927//
928// Steady state is ZERO writes: nothing new to contribute, nothing written.
929const SUBSCRIPTIONS_MARKER = STORE('subscriptions-merged')
930
931// The hash behind the deterministic rkey (lib/lists.js explains why it must be deterministic).
932const rkeyHash = (subject) => b4a.toString(crypto.data(b4a.from(subject)), 'hex')
933
934if (!LIST && (HOSTED_DIDS || []).length) {
926935 const selfDid = HOSTED_DIDS[0]
927936 const uri = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}`
928937 ;(async () => {
929938 try {
930 const store = await getRecordStore(selfDid)
931 if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) return // already migrated; discovery picks it up
932939 const st = await writer.state().catch(() => null)
933940 if (!st || st.did !== selfDid) return // this node does not sign for the identity — not ours to write
941
942 // What this node knows, minus itself: hosted DIDs are subscribed by virtue of being hosted, and a
943 // list entry for yourself says nothing.
944 const local = ROSTER
945 const contributed = fs.existsSync(SUBSCRIPTIONS_MARKER)
946 const store = await getRecordStore(selfDid)
947 const listRec = await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)
948
949 if (!listRec) {
950 // Create-if-absent. A concurrent create from a sibling device is not an error: whoever loses the
951 // race still proceeds to merge, and both arrive at the same list.
952 try {
953 await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, {
954 $type: LIST_COLLECTION,
955 purpose: CURATE,
956 name: 'subscriptions',
957 description: 'Identities this account subscribes to. Shared by every device of this identity.',
958 createdAt: new Date().toISOString()
959 }, { action: 'create' })
960 } catch (e) {
961 console.log(JSON.stringify({ msg: 'subscriptions: list already created by another device, merging into it', error: e.message }))
962 }
963 }
964
965 if (contributed) return // already gave this node's set to the list; the list is authoritative now
966
967 // Union: add only what the list does not already have.
968 // ⛔ store.list() is an ASYNC GENERATOR yielding { rkey, record } (overlay-repo-store.js:288) —
969 // not an array and not the { records } wrapper the P2P peer returns. Awaiting it and testing
970 // Array.isArray finds nothing, silently, and every node then re-contributes every boot.
971 const already = new Set()
972 for await (const { record } of store.list(LISTITEM_COLLECTION)) {
973 if (record && record.list === uri && record.subject) already.add(record.subject)
974 }
975 const missing = missingMembers({ local, listed: [...already], selfDid })
934976 const createdAt = new Date().toISOString()
935 await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, {
936 $type: LIST_COLLECTION,
937 purpose: CURATE,
938 name: 'subscriptions',
939 description: 'Identities this account subscribes to. Migrated from FAVELA_ROSTER.',
940 createdAt
941 }, { action: 'create' })
942 const members = ROSTER.filter((d) => d !== selfDid) // hosted DIDs are subscribed regardless
943 for (const did of members) {
944 await writer.putRecord(LISTITEM_COLLECTION, tidNow(), {
977 for (const did of missing) {
978 await writer.putRecord(LISTITEM_COLLECTION, subscriptionRkey(did, rkeyHash), {
945979 $type: LISTITEM_COLLECTION, subject: did, list: uri, createdAt
946 }, { action: 'create' })
980 }, { action: 'create' }).catch(() => {}) // deterministic rkey: a concurrent identical add is a no-op
947981 addPeer(did).catch(() => {})
948982 }
949 console.log(JSON.stringify({
950 msg: 'roster: MIGRATED FAVELA_ROSTER into a replicated list — every device hosting this DID now inherits it, and the env pin can be removed',
951 list: uri, members: members.length
952 }))
983 try { fs.writeFileSync(SUBSCRIPTIONS_MARKER, createdAt) } catch {}
984 if (missing.length) {
985 console.log(JSON.stringify({ msg: 'subscriptions: merged this node\'s roster into the shared list', list: uri, added: missing.length }))
986 }
953987 } catch (e) {
954 console.error(JSON.stringify({ msg: 'roster: list migration failed (the env roster still applies)', error: e.message }))
988 console.error(JSON.stringify({ msg: 'subscriptions: merge failed (this node keeps its local roster)', error: e.message }))
955989 }
956990 })()
957991}
lib/lists.js +44 -0
……@@ -84,3 +84,47 @@ export async function memberRkey (store, uri, subject) {
8484 for (const m of await membersOf(store, uri)) if (m.subject === subject) return m.rkey
8585 return null
8686}
87
88// ── CONVERGING ONE IDENTITY'S SUBSCRIPTIONS ACROSS ITS DEVICES ───────────────────────────────────────
89// Devices hosting the same DID are the same identity and should agree on what it subscribes to with
90// nothing configured on any of them. Both helpers below exist so that decision is testable without a
91// repo, a writer or a handset — the failure they prevent was only visible on a phone.
92
93// The rkey binding a subject to the subscriptions list.
94//
95// ⛔ DETERMINISTIC, NOT A FRESH TID. Two devices contributing the same DID concurrently would otherwise
96// write TWO records for one membership: reads dedup by subject so it looks fine, while the repo
97// accumulates duplicates for ever. Same subject => same rkey => the second write is a no-op, which is
98// what makes the merge idempotent rather than merely convergent-on-read.
99//
100// rkey charset is [A-Za-z0-9.:_~-], so hex — not base64url. Truncated: a collision across a handful of
101// subscriptions is not a practical concern, and would only drop one add, which another device's merge
102// re-contributes.
103export function subscriptionRkey (subject, hash) {
104 if (typeof subject !== 'string' || !subject) throw new Error('subject required')
105 if (typeof hash !== 'function') throw new Error('hash function required')
106 return 'sub' + String(hash(subject)).slice(0, 24)
107}
108
109// What THIS node still owes the shared list: its own roster, minus itself, minus what the list already
110// has, deduped and order-stable.
111//
112// ⛔ UNION, NEVER REPLACE. Writing the list wholesale makes "who booted first" decide the identity's
113// roster for every device — a handset won that race against a server on 2026-08-25 and its derived set
114// became authoritative. A union is order-independent: any interleaving of devices converges on the same
115// set, and there is no winner to be.
116//
117// Self is excluded because a hosted DID is subscribed by virtue of being hosted; listing yourself says
118// nothing and would round-trip into the roster as a duplicate.
119export function missingMembers ({ local = [], listed = [], selfDid = null } = {}) {
120 const have = new Set(listed)
121 const out = []
122 const seen = new Set()
123 for (const did of local) {
124 if (typeof did !== 'string' || !did.startsWith('did:')) continue
125 if (did === selfDid || have.has(did) || seen.has(did)) continue
126 seen.add(did)
127 out.push(did)
128 }
129 return out
130}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.83",
3 "version": "0.3.84",
44 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
test/lists.test.mjs +39 -1
……@@ -2,7 +2,7 @@
22//
33// The shape is the point: a list is a NAME, and membership lives in SEPARATE listitem records. So a list
44// of 500 people is 501 records, and removing someone deletes THEIR listitem rather than editing the list.
5import { listRecord, listitemRecord, isModlist, describePurpose, listUri, ownLists, membersOf, memberRkey, CURATE, MODLIST } from '../lib/lists.js'
5import { listRecord, listitemRecord, isModlist, describePurpose, listUri, ownLists, membersOf, memberRkey, CURATE, MODLIST, subscriptionRkey, missingMembers } from '../lib/lists.js'
66
77let pass = 0
88const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
……@@ -58,3 +58,41 @@ ok((await ownLists(bare, 'did:plc:me')).length === 0, 'no lists yet is not an er
5858ok((await membersOf(bare, LIST_URI)).length === 0, 'nor is an empty list')
5959
6060console.log(`\nLISTS OK: ${pass} assertions`)
61
62
63// ── converging one identity's subscriptions across its devices ───────────────────────────────────
64// These two decisions are why a handset and a server can merge into one list without a winner. The bug
65// they encode was only reproducible on a phone (hyper-social #53), so it is pinned here instead.
66{
67 // A REAL hash, not a stub: the rkey is a TRUNCATION, so a stub that does not diffuse its input makes
68 // this test pass or fail for reasons production never sees. (A first attempt used
69 // 'deadbeef' + hex(subject) and collided, because every did:plc: shares a prefix.)
70 const { createHash } = await import('node:crypto')
71 const hash = (s) => createHash('sha256').update(s).digest('hex')
72 const A = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'
73 const B = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'
74 const SELF = 'did:plc:sssssssssssssssssssssss'
75
76 // ⛔ Deterministic: two devices adding the same subject must produce the SAME record, or one
77 // membership becomes two listitems that reads dedup and the repo keeps for ever.
78 ok(subscriptionRkey(A, hash) === subscriptionRkey(A, hash), 'the same subject always yields the same rkey')
79 ok(subscriptionRkey(A, hash) !== subscriptionRkey(B, hash), 'different subjects do not collide')
80 ok(/^[A-Za-z0-9.:_~-]+$/.test(subscriptionRkey(A, hash)), 'the rkey stays inside the atproto rkey charset')
81 ok(subscriptionRkey(A, hash).length <= 27, 'and stays short')
82 let threw = false
83 try { subscriptionRkey('', hash) } catch { threw = true }
84 ok(threw, 'an empty subject is refused rather than hashed into a real rkey')
85
86 // ⛔ Union, never replace: this is what stops "who booted first" deciding the whole roster.
87 ok(JSON.stringify(missingMembers({ local: [A, B], listed: [A], selfDid: SELF })) === JSON.stringify([B]),
88 'only what the list lacks is contributed')
89 ok(missingMembers({ local: [A], listed: [A], selfDid: SELF }).length === 0,
90 'a node with nothing new writes nothing — steady state is zero writes')
91 ok(missingMembers({ local: [SELF, A], listed: [], selfDid: SELF }).length === 1,
92 'a hosted DID never lists itself')
93 ok(missingMembers({ local: [A, A], listed: [], selfDid: SELF }).length === 1,
94 'a repeated local entry is contributed once')
95 ok(missingMembers({ local: ['not-a-did', A], listed: [], selfDid: SELF }).length === 1,
96 'junk in the local roster is not written into the repo')
97 ok(missingMembers({}).length === 0, 'no local roster, nothing to contribute')
98}