| … | … | @@ -100,6 +100,58 @@ export function commitLine (frames) { |
| 100 | 100 | return { head, line, losers: loserFrames, unhealed } |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | // healPlan — what the next commit must restate so that EVERY consumer converges, whatever it received. | |
| 104 | // | |
| 105 | // A consumer behind a relay accepts whatever arrives with a higher rev, so after repeated partitions it | |
| 106 | // hops between branches and holds a mix no branch ever had (found by seeded fuzz, 2026-10-13: the | |
| 107 | // consumer took two commits of one losing branch, two of another, dropped both heals of the first fork | |
| 108 | // because an equal rev had arrived first). The signer cannot know what any consumer received, so a heal | |
| 109 | // must not depend on it: it restates the merged truth for every path any frame touched since the fork | |
| 110 | // point — `update` for a record that exists, `delete` for one that does not — and records the window it | |
| 111 | // asserted (`asserts`, the fork-point rev) and the forks it knew (`heals`). | |
| 112 | // | |
| 113 | // A heal is EFFECTIVE — no consumer can have skipped it, and it covered everything below it — when no fork | |
| 114 | // frame has a rev at or above it, and every fork frame below it is either one it knew or one an earlier | |
| 115 | // effective heal covered. Forks covered by an effective heal are done; any other fork reopens a window. | |
| 116 | // | |
| 117 | // Returns null (nothing to heal) or { from, paths: Set, heals: [commitCid] }. | |
| 118 | export function healPlan ({ line, losers }) { | |
| 119 | if (!losers || losers.length === 0) return null | |
| 120 | const all = line.concat(losers) | |
| 121 | const byCid = new Map() | |
| 122 | for (const f of all) if (f.commitCid) byCid.set(String(f.commitCid), f) | |
| 123 | const loserCids = new Set(losers.map((f) => String(f.commitCid))) | |
| 124 | const forkPointRev = (f) => { | |
| 125 | let r = f | |
| 126 | while (r.prev && loserCids.has(String(r.prev))) r = byCid.get(String(r.prev)) | |
| 127 | const p = r.prev ? byCid.get(String(r.prev)) : null | |
| 128 | return p ? p.rev : '' | |
| 129 | } | |
| 130 | ||
| 131 | // Walk the line's asserting heals in order; each effective one covers the forks it knew. | |
| 132 | const covered = new Set() | |
| 133 | for (const h of line) { | |
| 134 | if (h.asserts === undefined) continue | |
| 135 | const knew = new Set((h.heals || []).map(String)) | |
| 136 | let effective = true | |
| 137 | for (const l of losers) { | |
| 138 | const c = String(l.commitCid) | |
| 139 | if (l.rev >= h.rev) { effective = false; break } | |
| 140 | if (!covered.has(c) && !knew.has(c)) { effective = false; break } | |
| 141 | } | |
| 142 | if (effective) for (const c of knew) covered.add(c) | |
| 143 | } | |
| 144 | ||
| 145 | const open = losers.filter((l) => !covered.has(String(l.commitCid))) | |
| 146 | if (open.length === 0) return null | |
| 147 | let from = null | |
| 148 | for (const l of open) { const r = forkPointRev(l); if (from === null || r < from) from = r } | |
| 149 | const paths = new Set() | |
| 150 | for (const f of all) if (f.rev > from) for (const op of (f.ops || [])) paths.add(op.path) | |
| 151 | const heals = losers.filter((l) => l.rev > from).map((l) => String(l.commitCid)) | |
| 152 | return { from, paths, heals } | |
| 153 | } | |
| 154 | ||
| 103 | 155 | // path -> record CID string after replaying `frames` in order onto `start` (a Map, copied). Uses only the |
| 104 | 156 | // CIDs the ops carry, so it needs no record bytes. |
| 105 | 157 | export function replayCids (frames, start = new Map()) { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-room", |
| 3 | "version": "0.2.23", | |
| 3 | "version": "0.2.24", | |
| 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", |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | // commitLine — which frames form the chain, which are forks. Pure; no I/O. |
| 2 | 2 | import '../lib/bare-globals.js' |
| 3 | 3 | import test from 'brittle' |
| 4 | import { commitLine, frameKey, replayCids } from '../lib/commit-line.js' | |
| 4 | import { commitLine, frameKey, replayCids, healPlan } from '../lib/commit-line.js' | |
| 5 | 5 | |
| 6 | 6 | // Marked frames (chain: 1) — what the fork-aware signer writes. seq follows rev unless a test sets it. |
| 7 | 7 | const f = (rev, cid, prev, extra = {}) => ({ rev, commitCid: cid, prev, seq: parseInt(rev.slice(1), 36), chain: 1, ops: [], ...extra }) |
| … | … | @@ -92,3 +92,42 @@ test('a marked fork on top of legacy history: only the marked sibling loses', as |
| 92 | 92 | t.alike(cids(r.losers), ['mB']) |
| 93 | 93 | t.alike(cids(r.line), ['g1', 'g2', 'mA']) |
| 94 | 94 | }) |
| 95 | ||
| 96 | // healPlan — f(rev, cid, prev, {ops, heals, asserts}); revs are single letters after '3' for readability. | |
| 97 | const op = (path) => ({ action: 'create', path, cid: 'x' }) | |
| 98 | ||
| 99 | test('healPlan: no forks, nothing to heal', async (t) => { | |
| 100 | t.is(healPlan(commitLine([f('3a', 'c1', null), f('3b', 'c2', 'c1')])), null) | |
| 101 | }) | |
| 102 | ||
| 103 | test('healPlan: a fork opens a window at its fork point covering BOTH sides', async (t) => { | |
| 104 | const log = [f('3a', 'c1', null, { ops: [op('p/base')] }), f('3b', 'cA', 'c1', { ops: [op('p/a')] }), f('3b', 'cB', 'c1', { ops: [op('p/b')] }), f('3c', 'cA2', 'cA', { ops: [op('p/a2')] })] | |
| 105 | const plan = healPlan(commitLine(log)) | |
| 106 | t.is(plan.from, '3a', 'window starts after the fork point') | |
| 107 | t.alike([...plan.paths].sort(), ['p/a', 'p/a2', 'p/b'], 'both branches, not the common history') | |
| 108 | t.alike(plan.heals, ['cB']) | |
| 109 | }) | |
| 110 | ||
| 111 | test('healPlan: an effective heal closes the window', async (t) => { | |
| 112 | const log = [f('3a', 'c1', null), f('3b', 'cA', 'c1', { ops: [op('p/a')] }), f('3b', 'cB', 'c1', { ops: [op('p/b')] }), | |
| 113 | f('3c', 'h', 'cA', { ops: [op('p/b')], heals: ['cB'], asserts: '3a' })] | |
| 114 | t.is(healPlan(commitLine(log)), null) | |
| 115 | }) | |
| 116 | ||
| 117 | test('healPlan: a fork AT OR ABOVE the heal makes it skippable — the window reopens from the earliest fork', async (t) => { | |
| 118 | // cX forked from cA and reached rev 3c = the heal's rev: a consumer that took cX dropped the heal. | |
| 119 | const log = [f('3a', 'c1', null), f('3b', 'cA', 'c1', { ops: [op('p/a')] }), f('3b', 'cB', 'c1', { ops: [op('p/b')] }), | |
| 120 | f('3c', 'h', 'cA', { ops: [op('p/b')], heals: ['cB'], asserts: '3a' }), f('3c', 'hX', 'cA', { ops: [op('p/x')] }), f('3d', 'n', 'h', { ops: [op('p/n')] })] | |
| 121 | const plan = healPlan(commitLine(log)) | |
| 122 | t.ok(plan, 'not covered') | |
| 123 | t.is(plan.from, '3a', 'back to the first fork point, because the consumer may have skipped the heal') | |
| 124 | t.ok(plan.paths.has('p/b') && plan.paths.has('p/x') && plan.paths.has('p/n'), 'restates every path since then') | |
| 125 | }) | |
| 126 | ||
| 127 | test('healPlan: a fork the heal never KNEW (arrived late, below it) reopens the window', async (t) => { | |
| 128 | const log = [f('3a', 'c1', null), f('3b', 'cA', 'c1', { ops: [op('p/a')] }), f('3b', 'cB', 'c1', { ops: [op('p/b')] }), | |
| 129 | f('3c', 'h', 'cA', { ops: [op('p/b')], heals: ['cB'], asserts: '3a' }), f('3b', 'cLate', 'c1', { ops: [op('p/late')] })] | |
| 130 | const plan = healPlan(commitLine(log)) | |
| 131 | t.ok(plan && plan.paths.has('p/late'), 'the late fork\'s paths are restated') | |
| 132 | t.alike(plan.heals.sort(), ['cB', 'cLate']) | |
| 133 | }) |