OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 15f5c20

0.2.23: commit-line — which signed frames are the chain, which are partition forks
A partition elects a signer on each side and both continue nextRev(head): the same rev over different records. commitLine decides, identically on every device, the head (highest rev, lowest CID on a tie), the chain, the forks that branched off it, and which forks no commit has healed yet. Only frames marked chain:1 (written by the fork-aware signer) can be forks. Real served history has restarted chains, skipped prev links and falling revs (wj62: 26 rev drops / 37 prev breaks in 387 frames; loser-club: an unmarked rule called 4511 of 4512 frames forks), so unmarked frames always stay on the chain, in the old seq order. buildCommitWrite accepts a CID-only `before` entry: a fork-heal diffs from a state assembled out of other commits' ops (byte-identical commit, tested). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15f5c2079c38e60b4e8be41b3a0fee19d91ce700
by plankatron <fleeky@gmail.com> · 2026-10-13T14:57:08.000Z

Changed files (6) +236 -3

lib/commit-line.js +116 -0
……@@ -0,0 +1,116 @@
1// commit-line — which signed #commit frames form THE chain, and which are forks of it.
2//
3// The shared __commit__ log is written by whichever co-owner signs. A network partition elects a signer
4// on each side, and both continue from the same head: nextRev(head) is a pure function of the head, so
5// they sign the SAME rev over DIFFERENT record sets. Those frames used to share a key (__commit__/<rev>)
6// and one silently overwrote the other, so the losing fork left no trace — and a consumer that had taken
7// it at the relay never learned the other side's records (hyper-proto-peer test/partition-fork-heal).
8//
9// Frames are now keyed by rev AND commit CID, so every fork survives, and this module decides, the same
10// way on every device:
11// head the highest rev; on a tie, the lowest commit CID (any deterministic rule works — it only has
12// to agree everywhere)
13// line every frame that is not a fork of the head's ancestry, in the order below. This is what the
14// membrane projects, what restore() replays, what sign() continues.
15// losers frames that branched off the line: a frame whose `prev` is the parent of a line frame it is
16// not, and everything descending from such a frame.
17// unhealed losers no line frame has named in its `heals` list yet — the heal commit's work.
18//
19// ⛔ ONLY FRAMES MARKED `chain: 1` CAN BE FORKS. Every log written before this module has history this
20// rule would misread: re-authored chains that restart with prev=null, prev links that skip, revs that
21// fall in seq order. Measured 2026-10-13 on our own served subscribeRepos from cursor 0: wj62 387
22// frames with 26 rev drops and 37 prev breaks; loser-club 4512 frames, of which an unmarked rule called
23// 4511 "forks". So a frame without the marker is ALWAYS on the line, and the line keeps the order
24// firehoseLog always had — seq, ties by rev then CID — so a legacy log projects exactly as before.
25
26const FIREHOSE_PREFIX = '__commit__/'
27export const CHAIN_MARK = 1
28
29const marked = (f) => f && f.chain === CHAIN_MARK && !!f.commitCid
30
31// The key a signed frame is stored under. An identical commit signed by two connected co-owners (same
32// head, same record set, RFC6979) yields the same CID and therefore the same key — the dedup the
33// deterministic rev was built for still holds.
34export function frameKey (rev, commitCid) {
35 return FIREHOSE_PREFIX + rev + '/' + String(commitCid)
36}
37
38function byRevThenCid (a, b) {
39 if (a.rev !== b.rev) return a.rev < b.rev ? -1 : 1
40 const ac = String(a.commitCid || '')
41 const bc = String(b.commitCid || '')
42 return ac < bc ? -1 : ac > bc ? 1 : 0
43}
44
45// The order firehoseLog has always returned: seq, ties by rev then CID. Two partitioned signers both
46// write seq count+1, so a fork and its sibling tie on seq; the loser is excluded, and the heal carries a
47// higher seq than either.
48function lineOrder (a, b) {
49 if ((a.seq || 0) !== (b.seq || 0)) return (a.seq || 0) - (b.seq || 0)
50 return byRevThenCid(a, b)
51}
52
53export function commitLine (frames) {
54 const all = (frames || []).filter((f) => f && f.rev)
55 if (all.length === 0) return { head: null, line: [], losers: [], unhealed: [] }
56
57 // The head: highest rev, lowest CID on a tie.
58 let head = null
59 for (const f of all) {
60 if (!head || f.rev > head.rev || (f.rev === head.rev && byRevThenCid(f, head) < 0)) head = f
61 }
62
63 const byCid = new Map()
64 for (const f of all) if (f.commitCid) byCid.set(String(f.commitCid), f)
65
66 // The head's ancestry, as far as `prev` links reach inside the log.
67 const ancestry = new Set()
68 for (let f = head; f && f.commitCid && !ancestry.has(String(f.commitCid));) {
69 ancestry.add(String(f.commitCid))
70 f = f.prev ? byCid.get(String(f.prev)) : null
71 }
72
73 // A fork point is the parent of a MARKED line frame. Genesis frames share the parent "none".
74 const parentOf = (f) => (f.prev ? String(f.prev) : '')
75 const forkParents = new Set()
76 for (const cid of ancestry) { const f = byCid.get(cid); if (marked(f)) forkParents.add(parentOf(f)) }
77
78 // Losers: a MARKED sibling of a marked line frame (same parent, not on the line), then its marked
79 // descendants. An unmarked frame is never a loser (see the header).
80 const losers = new Set()
81 for (const f of all) {
82 if (!marked(f) || ancestry.has(String(f.commitCid))) continue
83 if (forkParents.has(parentOf(f))) losers.add(String(f.commitCid))
84 }
85 for (let grew = true; grew;) {
86 grew = false
87 for (const f of all) {
88 if (!marked(f) || losers.has(String(f.commitCid)) || ancestry.has(String(f.commitCid))) continue
89 if (f.prev && losers.has(String(f.prev))) { losers.add(String(f.commitCid)); grew = true }
90 }
91 }
92
93 const line = all.filter((f) => !(f.commitCid && losers.has(String(f.commitCid)))).sort(lineOrder)
94 const loserFrames = all.filter((f) => f.commitCid && losers.has(String(f.commitCid))).sort(lineOrder)
95
96 const healed = new Set()
97 for (const f of line) for (const cid of (f.heals || [])) healed.add(String(cid))
98 const unhealed = loserFrames.filter((f) => !healed.has(String(f.commitCid)))
99
100 return { head, line, losers: loserFrames, unhealed }
101}
102
103// path -> record CID string after replaying `frames` in order onto `start` (a Map, copied). Uses only the
104// CIDs the ops carry, so it needs no record bytes.
105export function replayCids (frames, start = new Map()) {
106 const m = new Map(start)
107 for (const f of frames) {
108 for (const op of (f.ops || [])) {
109 if (op.action === 'delete') m.delete(op.path)
110 else if (op.cid) m.set(op.path, String(op.cid))
111 }
112 }
113 return m
114}
115
116export { FIREHOSE_PREFIX }
lib/signer.js +9 -1
……@@ -11,6 +11,7 @@ import * as dagCbor from '@ipld/dag-cbor'
1111import { buildMst, firehoseProofBlocks } from 'atproto-repo/lib/mst.js'
1212import { unsignedCommit, signCommit, commitCid } from 'atproto-repo/lib/commit.js'
1313import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
14import { CID } from 'multiformats/cid'
1415
1516// records: [{ collection, rkey, record }] — the linearized view (e.g. RepoBase.listRecords()).
1617// meta: { did, rev, prev=null, privateKey } — the shared #atproto signing key + chain position.
……@@ -42,9 +43,16 @@ export function materialize (records, { did, rev, prev = null, privateKey }) {
4243// position (prev = last commit CID, since = last rev); prevData is derived from `before`.
4344export function buildCommitWrite (before, after, { did, rev, prev = null, since = null, privateKey }) {
4445 if (!did || !rev || !privateKey) throw new Error('buildCommitWrite: did, rev, privateKey required')
46 // A `before` entry may carry only `cid` instead of `record`: the proof and the ops need nothing but
47 // the old CIDs, and a fork-heal commit diffs from a state assembled out of other commits' ops, whose
48 // CIDs are known and whose record bytes may not be (hyper-proto-peer swarm-device sign()).
4549 const enc = (recs) => {
4650 const m = new Map()
47 for (const { collection, rkey, record } of recs) {
51 for (const { collection, rkey, record, cid } of recs) {
52 if (record === undefined && cid) {
53 m.set(collection + '/' + rkey, { cid: typeof cid === 'string' ? CID.parse(cid) : cid, bytes: null })
54 continue
55 }
4856 const bytes = dagCbor.encode(record)
4957 m.set(collection + '/' + rkey, { cid: cidForBytesSync(bytes), bytes })
5058 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.22",
3 "version": "0.2.23",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",
test/all.js +1 -0
……@@ -10,6 +10,7 @@ import './membrane-projection-drift.test.mjs'
1010import './lease.test.js'
1111import './lease-handoff.test.js'
1212import './signer.test.js'
13import './commit-line.test.mjs' // pure; which frames are the chain, which are forks
1314import './signer-conformance.test.js'
1415import './membership.test.js'
1516import './doorbell-durable-write.test.js'
test/commit-line.test.mjs +94 -0
……@@ -0,0 +1,94 @@
1// commitLine — which frames form the chain, which are forks. Pure; no I/O.
2import '../lib/bare-globals.js'
3import test from 'brittle'
4import { commitLine, frameKey, replayCids } from '../lib/commit-line.js'
5
6// Marked frames (chain: 1) — what the fork-aware signer writes. seq follows rev unless a test sets it.
7const f = (rev, cid, prev, extra = {}) => ({ rev, commitCid: cid, prev, seq: parseInt(rev.slice(1), 36), chain: 1, ops: [], ...extra })
8const cids = (frames) => frames.map((x) => x.commitCid)
9
10test('a single chain has no losers and replays in rev order', async (t) => {
11 const log = [f('3b', 'c2', 'c1'), f('3a', 'c1', null), f('3c', 'c3', 'c2')]
12 const r = commitLine(log)
13 t.is(r.head.commitCid, 'c3')
14 t.alike(cids(r.line), ['c1', 'c2', 'c3'])
15 t.is(r.losers.length, 0)
16 t.is(r.unhealed.length, 0)
17})
18
19test('legacy frames without commitCid/prev stay on the line', async (t) => {
20 const r = commitLine([{ rev: 'b', seq: 2 }, { rev: 'a', seq: 1 }])
21 t.is(r.line.length, 2, 'nothing dropped')
22 t.is(r.losers.length, 0)
23})
24
25test('an equal-rev fork: the lower CID is the head, the other is a loser', async (t) => {
26 const log = [f('3a', 'c1', null), f('3b', 'cB', 'c1'), f('3b', 'cA', 'c1')]
27 const r = commitLine(log)
28 t.is(r.head.commitCid, 'cA', 'tie broken by the lower CID — the same on every device')
29 t.alike(cids(r.line), ['c1', 'cA'])
30 t.alike(cids(r.losers), ['cB'])
31 t.alike(cids(r.unhealed), ['cB'])
32})
33
34test('a losing branch longer than one frame: every frame on it is a loser', async (t) => {
35 // c1 -> x1 -> x2 (rev 3b, 3c) vs c1 -> y1 (rev 3d): y1 is the head, x1 and x2 lost.
36 const log = [f('3a', 'c1', null), f('3b', 'x1', 'c1'), f('3c', 'x2', 'x1'), f('3d', 'y1', 'c1')]
37 const r = commitLine(log)
38 t.is(r.head.commitCid, 'y1')
39 t.alike(cids(r.line), ['c1', 'y1'])
40 t.alike(cids(r.losers), ['x1', 'x2'])
41})
42
43test('a line frame naming a loser in `heals` marks it healed', async (t) => {
44 const log = [f('3a', 'c1', null), f('3b', 'cA', 'c1'), f('3b', 'cB', 'c1'), f('3c', 'h', 'cA', { heals: ['cB'] })]
45 const r = commitLine(log)
46 t.alike(cids(r.losers), ['cB'])
47 t.is(r.unhealed.length, 0)
48})
49
50test('a genesis fork (two frames with no prev)', async (t) => {
51 const r = commitLine([f('3a', 'g2', null), f('3a', 'g1', null)])
52 t.is(r.head.commitCid, 'g1')
53 t.alike(cids(r.losers), ['g2'])
54})
55
56test('frameKey keeps rev order and is unique per commit', async (t) => {
57 t.is(frameKey('3abc', 'bafy1'), '__commit__/3abc/bafy1')
58 t.not(frameKey('3abc', 'bafy1'), frameKey('3abc', 'bafy2'))
59})
60
61test('replayCids applies create/update/delete by CID', async (t) => {
62 const m = replayCids([
63 { ops: [{ action: 'create', path: 'p/a', cid: 'x' }, { action: 'create', path: 'p/b', cid: 'y' }] },
64 { ops: [{ action: 'update', path: 'p/a', cid: 'z' }, { action: 'delete', path: 'p/b', cid: null }] }
65 ])
66 t.alike([...m], [['p/a', 'z']])
67})
68
69test('UNMARKED legacy history is never a fork and keeps seq order (restarted chains, falling revs)', async (t) => {
70 // The shapes measured on our real served history: a second genesis (prev=null) after a re-author, a
71 // rev that falls in seq order, a prev link that skips.
72 const legacy = [
73 { rev: '3a', commitCid: 'g1', prev: null, seq: 1 },
74 { rev: '3b', commitCid: 'c2', prev: 'g1', seq: 2 },
75 { rev: '3d', commitCid: 'g2', prev: null, seq: 3 },
76 { rev: '3c', commitCid: 'c4', prev: 'missing', seq: 4 },
77 { rev: '3e', commitCid: 'c5', prev: 'g2', seq: 5 }
78 ]
79 const r = commitLine(legacy)
80 t.is(r.losers.length, 0, 'nothing classified as a fork')
81 t.alike(cids(r.line), ['g1', 'c2', 'g2', 'c4', 'c5'], 'exactly the old seq order')
82})
83
84test('a marked fork on top of legacy history: only the marked sibling loses', async (t) => {
85 const log = [
86 { rev: '3a', commitCid: 'g1', prev: null, seq: 1 },
87 { rev: '3c', commitCid: 'g2', prev: null, seq: 2 }, // legacy re-author, never a loser
88 f('3d', 'mB', 'g2', { seq: 3 }),
89 f('3d', 'mA', 'g2', { seq: 3 })
90 ]
91 const r = commitLine(log)
92 t.alike(cids(r.losers), ['mB'])
93 t.alike(cids(r.line), ['g1', 'g2', 'mA'])
94})
test/signer.test.js +15 -1
……@@ -6,7 +6,9 @@ import '../lib/bare-globals.js'
66import test from 'brittle'
77import { secp256k1 } from '@noble/curves/secp256k1'
88import { verifyCommit } from 'atproto-repo/lib/commit.js'
9import { materialize } from '../lib/signer.js'
9import { materialize, buildCommitWrite } from '../lib/signer.js'
10import * as dagCbor from '@ipld/dag-cbor'
11import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
1012
1113const PRIV = new Uint8Array(Buffer.from('9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c', 'hex'))
1214const PUB = secp256k1.getPublicKey(PRIV, true)
……@@ -56,3 +58,15 @@ test('guards', (t) => {
5658 t.exception(() => materialize(recs, { rev: 'x', privateKey: PRIV }), /required/, 'missing did rejected')
5759 t.exception(() => materialize(recs, { did: DID, privateKey: PRIV }), /required/, 'missing rev rejected')
5860})
61
62test('buildCommitWrite: a CID-only `before` gives the byte-identical commit (fork-heal input)', (t) => {
63 const before = recs.slice(0, 2)
64 const after = [{ ...recs[0], record: { ...recs[0].record, text: 'b2' } }, recs[2]]
65 const meta = { did: DID, rev: '3mxtest000022', prev: null, privateKey: PRIV }
66 const full = buildCommitWrite(before, after, meta)
67 const cidOnly = before.map(({ collection, rkey, record }) => ({ collection, rkey, cid: cidForBytesSync(dagCbor.encode(record)).toString() }))
68 const lean = buildCommitWrite(cidOnly, after, meta)
69 t.ok(lean.commitCid.equals(full.commitCid), 'same signed commit')
70 t.ok(lean.prevData.equals(full.prevData), 'same prevData')
71 t.alike(lean.ops.map((o) => o.action + ' ' + o.path), full.ops.map((o) => o.action + ' ' + o.path), 'same ops')
72})