| … | … | @@ -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 | ||
| 26 | const FIREHOSE_PREFIX = '__commit__/' | |
| 27 | export const CHAIN_MARK = 1 | |
| 28 | ||
| 29 | const 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. | |
| 34 | export function frameKey (rev, commitCid) { | |
| 35 | return FIREHOSE_PREFIX + rev + '/' + String(commitCid) | |
| 36 | } | |
| 37 | ||
| 38 | function 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. | |
| 48 | function 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 | ||
| 53 | export 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. | |
| 105 | export 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 | ||
| 116 | export { FIREHOSE_PREFIX } |
| … | … | @@ -11,6 +11,7 @@ import * as dagCbor from '@ipld/dag-cbor' |
| 11 | 11 | import { buildMst, firehoseProofBlocks } from 'atproto-repo/lib/mst.js' |
| 12 | 12 | import { unsignedCommit, signCommit, commitCid } from 'atproto-repo/lib/commit.js' |
| 13 | 13 | import { cidForBytesSync } from 'atproto-repo/lib/cid.js' |
| 14 | import { CID } from 'multiformats/cid' | |
| 14 | 15 | |
| 15 | 16 | // records: [{ collection, rkey, record }] — the linearized view (e.g. RepoBase.listRecords()). |
| 16 | 17 | // 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 }) { |
| 42 | 43 | // position (prev = last commit CID, since = last rev); prevData is derived from `before`. |
| 43 | 44 | export function buildCommitWrite (before, after, { did, rev, prev = null, since = null, privateKey }) { |
| 44 | 45 | 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()). | |
| 45 | 49 | const enc = (recs) => { |
| 46 | 50 | 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 | } | |
| 48 | 56 | const bytes = dagCbor.encode(record) |
| 49 | 57 | m.set(collection + '/' + rkey, { cid: cidForBytesSync(bytes), bytes }) |
| 50 | 58 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-room", |
| 3 | "version": "0.2.22", | |
| 3 | "version": "0.2.23", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -10,6 +10,7 @@ import './membrane-projection-drift.test.mjs' |
| 10 | 10 | import './lease.test.js' |
| 11 | 11 | import './lease-handoff.test.js' |
| 12 | 12 | import './signer.test.js' |
| 13 | import './commit-line.test.mjs' // pure; which frames are the chain, which are forks | |
| 13 | 14 | import './signer-conformance.test.js' |
| 14 | 15 | import './membership.test.js' |
| 15 | 16 | import './doorbell-durable-write.test.js' |
| … | … | @@ -0,0 +1,94 @@ |
| 1 | // commitLine — which frames form the chain, which are forks. Pure; no I/O. | |
| 2 | import '../lib/bare-globals.js' | |
| 3 | import test from 'brittle' | |
| 4 | import { 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. | |
| 7 | const f = (rev, cid, prev, extra = {}) => ({ rev, commitCid: cid, prev, seq: parseInt(rev.slice(1), 36), chain: 1, ops: [], ...extra }) | |
| 8 | const cids = (frames) => frames.map((x) => x.commitCid) | |
| 9 | ||
| 10 | test('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 | ||
| 19 | test('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 | ||
| 25 | test('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 | ||
| 34 | test('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 | ||
| 43 | test('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 | ||
| 50 | test('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 | ||
| 56 | test('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 | ||
| 61 | test('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 | ||
| 69 | test('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 | ||
| 84 | test('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 | }) |
| … | … | @@ -6,7 +6,9 @@ import '../lib/bare-globals.js' |
| 6 | 6 | import test from 'brittle' |
| 7 | 7 | import { secp256k1 } from '@noble/curves/secp256k1' |
| 8 | 8 | import { verifyCommit } from 'atproto-repo/lib/commit.js' |
| 9 | import { materialize } from '../lib/signer.js' | |
| 9 | import { materialize, buildCommitWrite } from '../lib/signer.js' | |
| 10 | import * as dagCbor from '@ipld/dag-cbor' | |
| 11 | import { cidForBytesSync } from 'atproto-repo/lib/cid.js' | |
| 10 | 12 | |
| 11 | 13 | const PRIV = new Uint8Array(Buffer.from('9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c', 'hex')) |
| 12 | 14 | const PUB = secp256k1.getPublicKey(PRIV, true) |
| … | … | @@ -56,3 +58,15 @@ test('guards', (t) => { |
| 56 | 58 | t.exception(() => materialize(recs, { rev: 'x', privateKey: PRIV }), /required/, 'missing did rejected') |
| 57 | 59 | t.exception(() => materialize(recs, { did: DID, privateKey: PRIV }), /required/, 'missing rev rejected') |
| 58 | 60 | }) |
| 61 | ||
| 62 | test('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 | }) |