OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 9969e37

0.3.126: the fork heal converges consumers after REPEATED partitions
0.3.125 healed a single partition but not a sequence of them: a consumer behind a rev-guarded relay can hold any mix of branches, and 0.3.125 diffed the heal from one fork's state and treated a fork as healed once a commit named it. Found by the autobee signer spike's fuzz; reproduced deterministically over MemKV (test/multi-partition-relay: 17/30 seeds red on 0.3.125, consumer != merged while every node agreed). The heal now RESTATES every path touched since the earliest uncovered fork point (atproto-room 0.2.24 healPlan): `update` for a record that exists — forced through a placeholder old CID, never `create`, which the reference AppView ignores over a stale copy — and `delete` for one that does not. It records `asserts` and `heals` so later signers can tell whether it can have been skipped. Verified: multi-partition-relay 30/30 seeds; 300 seeds x 24 steps green; largest heal 53 ops over 100 seeds (repos <= 51 records). Over autobee 2.12.1 with every device at weight 2 and everyone signing: no stall with a device offline or removed, all scenarios and fuzz seeds 1-10 converge, 0 spurious deletes, 0 lost posts. Full suite 166/166. Open: a heal window over ~200 touched paths would exceed the relay's 200-ops/commit limit; splitting is not implemented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9969e372fa21b9e9f9e03e144efc09609b75aea1
by plankatron <fleeky@gmail.com> · 2026-10-13T15:34:59.000Z

Changed files (4) +139 -69

lib/swarm-device.js +29 -68
……@@ -31,7 +31,9 @@ import { encodeReceipt, receiptEpoch } from 'atproto-room/lib/membership.js'
3131// key let one overwrite the other, so the losing fork vanished and its consumers never got the other
3232// side's records (test/partition-fork-heal.test.mjs). commitLine decides the chain the same way on
3333// every device; frames off it are forks that the next commit heals.
34import { commitLine, frameKey, replayCids, CHAIN_MARK } from 'atproto-room/lib/commit-line.js'
34import { commitLine, frameKey, healPlan, CHAIN_MARK } from 'atproto-room/lib/commit-line.js'
35// The stand-in old value that turns a restated path into an `update` op. Never a real record's CID.
36const HEAL_PLACEHOLDER = cidForBytesSync(dagCbor.encode({ $type: 'hyper-proto-peer.healPlaceholder' })).toString()
3537
3638// StateBaseV3's durable banned-identity set + minimum-epoch receipt (see state-base.js).
3739// atproto-base-REVOCATION.md §3/§6.
……@@ -70,8 +72,9 @@ function serializeEntry (write, seq) {
7072 // Written by the fork-aware signer: only marked frames can be classified as forks (commit-line.js —
7173 // legacy logs carry restarted chains and falling revs that must stay on the line untouched).
7274 chain: CHAIN_MARK,
73 // Fork commits this one heals (commitLine counts a loser named here as done). Absent on ordinary commits.
74 ...(write.heals && write.heals.length ? { heals: write.heals } : {}),
75 // A heal: the fork commits it knew (heals) and the fork-point rev from which it restated every touched
76 // path (asserts). atproto-room healPlan decides from these whether a heal can have been skipped.
77 ...(write.asserts !== undefined ? { heals: write.heals || [], asserts: write.asserts } : {}),
7578 commit: hex(write.commitBlock.bytes),
7679 ops: write.ops.map((o) => ({ action: o.action, path: o.path, cid: o.cid ? o.cid.toString() : null, prev: o.prev ? o.prev.toString() : null })),
7780 blocks: write.newBlocks.map((b) => ({ cid: b.cid.toString(), bytes: hex(b.bytes) }))
……@@ -493,26 +496,22 @@ export class SwarmDevice {
493496 const rev = this._nextRev()
494497 const prev = this.head ? this.head.commitCid : null
495498 const out = materialize(records, { did: this.did, rev, prev, privateKey: this.privateKey })
496 // Forks to heal: commits another partition signed that are off the chain. Their consumers hold a
497 // state this chain never diffed from, so the next commit must name what differs for them too.
498 const heal = (!force && chain && chain.unhealed.length) ? this._healBefore(chain, records) : null
499 const healing = !!(heal && heal.differs)
500 if (this.head && out.dataCid.equals(this.head.dataCid) && !healing) return null // no new intents
499 // Forks to heal (atproto-room healPlan): a consumer behind a relay may hold ANY mix of branches, so the
500 // heal restates every path any frame touched since the earliest uncovered fork point.
501 const plan = (!force && chain) ? healPlan(chain) : null
502 if (this.head && out.dataCid.equals(this.head.dataCid) && !plan) return null // no new intents
501503 // persist the #commit into the shared firehose log (option a) BEFORE advancing head, so every
502504 // device + the membrane sees each signed commit. The diff is _lastRecords -> records, or for a heal
503 // the fork-aware state assembled by _healBefore.
505 // the asserting state built by _healBefore.
504506 if (this.firehose) {
505507 const since = this.head ? this.head.rev : null
506 const write = buildCommitWrite(healing ? heal.before : this._lastRecords, records, { did: this.did, rev, prev, since, privateKey: this.privateKey })
507 // Named even when nothing differed: those forks' consumers already match, so this commit's ops
508 // apply to them as they are, and the forks are done.
509 if (heal) write.heals = heal.heals
508 const write = buildCommitWrite(plan ? this._healBefore(plan, records) : this._lastRecords, records, { did: this.did, rev, prev, since, privateKey: this.privateKey })
509 if (plan) { write.heals = plan.heals; write.asserts = plan.from }
510510 // Only append a #commit frame when the diff is non-empty. A 0-op frame can only arise from the
511511 // rare restart-with-unsigned-pending-write edge (baseline == current yet head.dataCid differs);
512512 // emitting it would be a malformed commit. head still advances below so we don't re-trigger.
513513 if (write.ops.length > 0) {
514 // seq is informational now: the chain is ordered by rev (commitLine), because two partitioned
515 // signers both write count+1 here.
514 // seq is informational now: two partitioned signers both write count+1 here.
516515 const existing = await this.sb.listNow(FIREHOSE_PREFIX)
517516 await this.sb.set(frameKey(rev, write.commitCid), serializeEntry(write, Object.keys(existing).length + 1))
518517 }
……@@ -538,64 +537,26 @@ export class SwarmDevice {
538537 return Object.values(map).filter((f) => f && f.rev)
539538 }
540539
541 // The "before" state a fork-heal commit diffs from, so its ops bring EVERY fork's consumers to the
542 // merged record set.
543 //
544 // Why one commit can serve them all: the reference bsky AppView applies ops and nothing else — a
545 // `create` for a record it holds is ignored, `update` upserts, `delete` removes — and a merged value
546 // for a path is always one side's value. So for each path either side touched since the fork, take
547 // the old value from the first fork whose value differs from the merged one (else the chain's own):
548 // a path a fork lacks becomes a create (a no-op where it exists with the merged content), a differing
549 // value becomes an update, a path gone from the merged set becomes a delete. buildCommitWrite still
550 // proves the ops invert to this state's root; only `prevData` names a state no single consumer held,
540 // The "before" state a heal commit diffs from, so that its ops RESTATE every path in the plan:
541 // a record in the merged set -> `update` (never `create`: the reference bsky AppView ignores a create
542 // for a record it already holds, so a consumer holding a stale copy would keep it). Where the chain's
543 // own previous value differs it is used; otherwise a fixed placeholder CID stands in, so the diff
544 // still yields an update.
545 // a path absent from the merged set -> `delete` (a no-op for a consumer that never had it).
546 // Every other path keeps the chain's previous value, so ordinary changes diff as usual. buildCommitWrite
547 // still proves the ops invert to this state's root; only `prevData` names a state no consumer held,
551548 // which the relay logs and accepts (indigo verify.go:140-143, warn-only even in strict mode).
552 //
553 // Returns { before: [{collection, rkey, cid}], heals: [commitCid], differs } — differs=false means
554 // every fork's consumers already match the merged set on these paths, so an ordinary commit serves.
555 _healBefore (chain, records) {
549 _healBefore (plan, records) {
556550 const merged = new Map()
557551 for (const r of records) merged.set(r.collection + '/' + r.rkey, cidForBytesSync(dagCbor.encode(r.record)).toString())
558 const lineState = replayCids(chain.line)
559
560 // Group the unhealed forks into branches by their root (the fork frame whose parent is on the chain).
561 const loserCids = new Set(chain.losers.map((f) => String(f.commitCid)))
562 const byCid = new Map(chain.losers.map((f) => [String(f.commitCid), f]))
563 const rootOf = (f) => { let r = f; while (r.prev && loserCids.has(String(r.prev))) r = byCid.get(String(r.prev)); return r }
564 const branches = new Map()
565 for (const f of chain.unhealed) {
566 const root = rootOf(f)
567 const k = String(root.commitCid)
568 if (!branches.has(k)) branches.set(k, { root, frames: [] })
569 branches.get(k).frames.push(f)
570 }
571
572 const touched = new Set()
573 const forkStates = []
574 for (const { root, frames } of branches.values()) {
575 const at = root.prev ? chain.line.findIndex((f) => String(f.commitCid) === String(root.prev)) : -1
576 const ancestor = replayCids(chain.line.slice(0, at + 1))
577 // The fork's own frames, plus the whole losing branch up to the newest unhealed one.
578 const branch = chain.losers.filter((f) => String(rootOf(f).commitCid) === String(root.commitCid))
579 forkStates.push(replayCids(branch, ancestor))
580 for (const f of branch) for (const op of (f.ops || [])) touched.add(op.path)
581 for (const f of chain.line.slice(at + 1)) for (const op of (f.ops || [])) touched.add(op.path)
582 }
583 for (const p of new Set([...merged.keys(), ...lineState.keys()])) if (merged.get(p) !== lineState.get(p)) touched.add(p)
584
585 const before = new Map(lineState)
586 let differs = false
587 for (const p of touched) {
552 const before = new Map()
553 for (const r of this._lastRecords) before.set(r.collection + '/' + r.rkey, cidForBytesSync(dagCbor.encode(r.record)).toString())
554 for (const p of plan.paths) {
588555 const m = merged.get(p)
589 let v = lineState.get(p)
590 for (const s of forkStates) if (s.get(p) !== m) { v = s.get(p); differs = true; break }
591 if (v === undefined) before.delete(p); else before.set(p, v)
592 }
593 const heals = chain.unhealed.map((f) => String(f.commitCid))
594 return {
595 before: [...before].map(([path, cid]) => { const i = path.indexOf('/'); return { collection: path.slice(0, i), rkey: path.slice(i + 1), cid } }),
596 heals,
597 differs
556 const was = before.get(p)
557 before.set(p, (was !== undefined && was !== m) ? was : HEAL_PLACEHOLDER)
598558 }
559 return [...before].map(([path, cid]) => { const i = path.indexOf('/'); return { collection: path.slice(0, i), rkey: path.slice(i + 1), cid } })
599560 }
600561
601562 // Re-seed head + the diff baseline from the shared __commit__ log after a (re)start. Without this,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.125",
3 "version": "0.3.126",
44 "description": "Self-custody, P2P-native atproto peer \u2014 holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
test/all.js +1 -0
……@@ -17,6 +17,7 @@ import './swarm-device.test.js'
1717import './partition-fork-heal.test.mjs' // fast + offline; partitioned signers fork the chain — the heal reaches every consumer
1818import './reanchor-unsigned-writes.test.mjs' // fast + offline; a re-anchor must not absorb local writes
1919import './partition-fork-membrane.test.mjs' // fast + offline; both sides' membranes converge on the heal
20import './multi-partition-relay.test.mjs' // fast + offline; 30 seeded partition histories — a relay-fed consumer always converges
2021import './event-driven-sign.test.mjs'
2122import './failover-election.test.mjs'
2223import './swarm-device-overlay.test.js'
test/multi-partition-relay.test.mjs +108 -0
……@@ -0,0 +1,108 @@
1// MULTI-PARTITION × RELAY — a consumer fed by a rev-guarded relay must converge after ANY sequence of
2// partitions, not just one.
3//
4// Found by the autobee signer spike (2026-10-13, seeded fuzz): after repeated partitions every node
5// agreed, yet the consumer did not. The relay accepts whatever arrives with a higher rev, so a consumer
6// HOPS between branches — it took two commits of one losing branch, then two of another, dropped both
7// heals of the first fork (an equal rev had arrived first), then took a line commit. Its state matched
8// no branch. 0.3.125's heal modelled each consumer as sitting on ONE fork and diffed from that fork's
9// state, and it counted a fork as healed once a commit NAMED it — though no consumer may ever have
10// received that commit. The signer cannot know what any consumer received.
11//
12// Deterministic version of the fuzz: seeded random partitions over MemKV replicas (one per device), a
13// deterministic merge (the globally latest write of a key wins — a valid linearization), every device
14// signs every step, every device's membrane feeds one consumer: the relay drops rev <= stored; the
15// reference AppView applies ops blindly, ignores a create for a record it holds, upserts on update.
16
17import '../lib/bare-globals.js'
18import test from 'brittle'
19import b4a from 'b4a'
20import * as dagCbor from '@ipld/dag-cbor'
21import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
22import { commitLine } from 'atproto-room/lib/commit-line.js'
23import { SwarmDevice } from '../lib/swarm-device.js'
24
25const DID = 'did:plc:multipartition'
26const KEY = (() => { const k = b4a.alloc(32); k[31] = 7; return k })()
27const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-10-13T00:00:00.000Z' })
28
29let clock = 0 // global write order: the merge keeps the latest write of a key
30class MemKV {
31 constructor () { this.m = new Map() } // key -> { v, at }
32 async set (k, v) { this.m.set(k, { v, at: ++clock }) }
33 async get (k) { const e = this.m.get(k); return e ? e.v : null }
34 async getNow (k) { return this.get(k) }
35 async del (k) { this.m.delete(k) }
36 async list (prefix = '') { const o = {}; for (const [k, e] of this.m) if (k.startsWith(prefix)) o[k] = e.v; return o }
37 async listNow (prefix = '') { return this.list(prefix) }
38}
39
40// Make every member of a group hold the union of the group's writes (latest write of each key wins).
41function sync (group) {
42 const best = new Map()
43 for (const n of group) for (const [k, e] of n.sb.m) { const b = best.get(k); if (!b || e.at > b.at) best.set(k, e) }
44 for (const n of group) for (const [k, e] of best) n.sb.m.set(k, e)
45}
46
47class Consumer {
48 constructor () { this.rev = ''; this.records = new Map(); this.seen = new Set() }
49 offer (f) {
50 if (this.seen.has(f.commitCid)) return
51 this.seen.add(f.commitCid)
52 if (f.rev <= this.rev) return
53 this.rev = f.rev
54 for (const op of f.ops || []) {
55 if (op.action === 'create') { if (!this.records.has(op.path)) this.records.set(op.path, op.cid) } else if (op.action === 'update') this.records.set(op.path, op.cid)
56 else if (op.action === 'delete') this.records.delete(op.path)
57 }
58 }
59}
60
61const cidsOf = async (dev) => new Map((await dev.repo.listRecords(null, { now: true })).map((r) => [r.collection + '/' + r.rkey, cidForBytesSync(dagCbor.encode(r.record)).toString()]))
62const same = (a, b) => a.size === b.size && [...a].every(([k, v]) => b.get(k) === v)
63
64async function run (seed, steps = 12) {
65 let s = seed >>> 0
66 const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 }
67 const pick = (arr) => arr[Math.floor(rnd() * arr.length)]
68
69 const all = ['A', 'B', 'C'].map((name) => { const sb = new MemKV(); return { name, sb, dev: new SwarmDevice(sb, { did: DID, privateKey: KEY, self: name, firehose: true }) } })
70 const consumer = new Consumer()
71 const signAll = async (group) => { for (const n of group) { await n.dev.sign(); for (const f of await n.dev.firehoseLog()) consumer.offer(f) } }
72
73 let groups = [all]
74 let next = 0
75 const live = new Set()
76 for (let step = 0; step < steps; step++) {
77 if (rnd() < 0.4) {
78 if (rnd() < 0.35) groups = [all]
79 else { const lone = pick(all); groups = [[lone], all.filter((x) => x !== lone)] }
80 }
81 for (const g of groups) {
82 for (let k = 0; k < 1 + Math.floor(rnd() * 2); k++) {
83 const dev = pick(g).dev
84 const r = rnd()
85 if (r < 0.6 || live.size === 0) { const rkey = 'p' + (next++); live.add(rkey); await dev.putRecord('app.bsky.feed.post', rkey, post(seed + ' ' + step + ' ' + rkey)) } else if (r < 0.85) { await dev.putRecord('app.bsky.feed.post', pick([...live]), post('edit ' + seed + ' ' + step)) } else { const rkey = pick([...live]); live.delete(rkey); await dev.deleteRecord('app.bsky.feed.post', rkey) }
86 }
87 sync(g)
88 }
89 for (const g of groups) { await signAll(g); sync(g) }
90 }
91 sync(all)
92 for (let i = 0; i < 6; i++) { await signAll(all); sync(all) }
93
94 const merged = await cidsOf(all[0].dev)
95 const heads = new Set()
96 let nodesAgree = true
97 for (const n of all) { heads.add(commitLine(await n.dev.firehoseFrames()).head.commitCid); if (!same(await cidsOf(n.dev), merged)) nodesAgree = false }
98 return { nodesAgree, oneHead: heads.size === 1, consumerOk: same(consumer.records, merged) }
99}
100
101test('a relay-fed consumer converges after ANY sequence of partitions (seeded, 30 seeds)', async (t) => {
102 const bad = []
103 for (let seed = 1; seed <= 30; seed++) {
104 const r = await run(seed)
105 if (!r.nodesAgree || !r.oneHead || !r.consumerOk) bad.push(seed + ':' + JSON.stringify(r))
106 }
107 t.alike(bad, [], 'every seed: nodes agree, one head, consumer == merged records')
108})