// commit-line — which signed #commit frames form THE chain, and which are forks of it.
//
// The shared __commit__ log is written by whichever co-owner signs. A network partition elects a signer
// on each side, and both continue from the same head: nextRev(head) is a pure function of the head, so
// they sign the SAME rev over DIFFERENT record sets. Those frames used to share a key (__commit__/<rev>)
// and one silently overwrote the other, so the losing fork left no trace — and a consumer that had taken
// it at the relay never learned the other side's records (hyper-proto-peer test/partition-fork-heal).
//
// Frames are now keyed by rev AND commit CID, so every fork survives, and this module decides, the same
// way on every device:
// head the highest rev; on a tie, the lowest commit CID (any deterministic rule works — it only has
// to agree everywhere)
// line every frame that is not a fork of the head's ancestry, in the order below. This is what the
// membrane projects, what restore() replays, what sign() continues.
// losers frames that branched off the line: a frame whose `prev` is the parent of a line frame it is
// not, and everything descending from such a frame.
// unhealed losers no line frame has named in its `heals` list yet — the heal commit's work.
//
// ⛔ ONLY FRAMES MARKED `chain: 1` CAN BE FORKS. Every log written before this module has history this
// rule would misread: re-authored chains that restart with prev=null, prev links that skip, revs that
// fall in seq order. Measured 2026-10-13 on our own served subscribeRepos from cursor 0: wj62 387
// frames with 26 rev drops and 37 prev breaks; loser-club 4512 frames, of which an unmarked rule called
// 4511 "forks". So a frame without the marker is ALWAYS on the line, and the line keeps the order
// firehoseLog always had — seq, ties by rev then CID — so a legacy log projects exactly as before.
const FIREHOSE_PREFIX = '__commit__/'
export const CHAIN_MARK = 1
const marked = (f) => f && f.chain === CHAIN_MARK && !!f.commitCid
// The key a signed frame is stored under. An identical commit signed by two connected co-owners (same
// head, same record set, RFC6979) yields the same CID and therefore the same key — the dedup the
// deterministic rev was built for still holds.
export function frameKey (rev, commitCid) {
return FIREHOSE_PREFIX + rev + '/' + String(commitCid)
}
function byRevThenCid (a, b) {
if (a.rev !== b.rev) return a.rev < b.rev ? -1 : 1
const ac = String(a.commitCid || '')
const bc = String(b.commitCid || '')
return ac < bc ? -1 : ac > bc ? 1 : 0
}
// The order firehoseLog has always returned: seq, ties by rev then CID. Two partitioned signers both
// write seq count+1, so a fork and its sibling tie on seq; the loser is excluded, and the heal carries a
// higher seq than either.
function lineOrder (a, b) {
if ((a.seq || 0) !== (b.seq || 0)) return (a.seq || 0) - (b.seq || 0)
return byRevThenCid(a, b)
}
export function commitLine (frames) {
const all = (frames || []).filter((f) => f && f.rev)
if (all.length === 0) return { head: null, line: [], losers: [], unhealed: [] }
// The head: highest rev, lowest CID on a tie.
let head = null
for (const f of all) {
if (!head || f.rev > head.rev || (f.rev === head.rev && byRevThenCid(f, head) < 0)) head = f
}
const byCid = new Map()
for (const f of all) if (f.commitCid) byCid.set(String(f.commitCid), f)
// The head's ancestry, as far as `prev` links reach inside the log.
const ancestry = new Set()
for (let f = head; f && f.commitCid && !ancestry.has(String(f.commitCid));) {
ancestry.add(String(f.commitCid))
f = f.prev ? byCid.get(String(f.prev)) : null
}
// A fork point is the parent of a MARKED line frame. Genesis frames share the parent "none".
const parentOf = (f) => (f.prev ? String(f.prev) : '')
const forkParents = new Set()
for (const cid of ancestry) { const f = byCid.get(cid); if (marked(f)) forkParents.add(parentOf(f)) }
// Losers: a MARKED sibling of a marked line frame (same parent, not on the line), then its marked
// descendants. An unmarked frame is never a loser (see the header).
const losers = new Set()
for (const f of all) {
if (!marked(f) || ancestry.has(String(f.commitCid))) continue
if (forkParents.has(parentOf(f))) losers.add(String(f.commitCid))
}
for (let grew = true; grew;) {
grew = false
for (const f of all) {
if (!marked(f) || losers.has(String(f.commitCid)) || ancestry.has(String(f.commitCid))) continue
if (f.prev && losers.has(String(f.prev))) { losers.add(String(f.commitCid)); grew = true }
}
}
const line = all.filter((f) => !(f.commitCid && losers.has(String(f.commitCid)))).sort(lineOrder)
const loserFrames = all.filter((f) => f.commitCid && losers.has(String(f.commitCid))).sort(lineOrder)
const healed = new Set()
for (const f of line) for (const cid of (f.heals || [])) healed.add(String(cid))
const unhealed = loserFrames.filter((f) => !healed.has(String(f.commitCid)))
return { head, line, losers: loserFrames, unhealed }
}
// healPlan — what the next commit must restate so that EVERY consumer converges, whatever it received.
//
// A consumer behind a relay accepts whatever arrives with a higher rev, so after repeated partitions it
// hops between branches and holds a mix no branch ever had (found by seeded fuzz, 2026-10-13: the
// consumer took two commits of one losing branch, two of another, dropped both heals of the first fork
// because an equal rev had arrived first). The signer cannot know what any consumer received, so a heal
// must not depend on it: it restates the merged truth for every path any frame touched since the fork
// point — `update` for a record that exists, `delete` for one that does not — and records the window it
// asserted (`asserts`, the fork-point rev) and the forks it knew (`heals`).
//
// A heal is EFFECTIVE — no consumer can have skipped it, and it covered everything below it — when no fork
// frame has a rev at or above it, and every fork frame below it is either one it knew or one an earlier
// effective heal covered. Forks covered by an effective heal are done; any other fork reopens a window.
//
// SPLIT HEALS. indigo drops a #commit with more than 200 ops (verify.go MaxCommitOps), so a window wider
// than that is restated over several commits: partial ones carry `restates` (the window's fork-point
// rev), the last carries `asserts`, `heals` and `partsFrom` (the first partial's rev). A partial's paths
// are not restated again — unless a fork frame reaches its rev, because then a consumer may have taken
// the fork and skipped it. For the same reason a split heal is effective only if no fork frame reaches
// its FIRST part.
//
// Returns null (nothing to heal) or { from, paths: Set (still to restate), window: Set (every path of the
// window), heals: [commitCid], partsFrom }.
export function healPlan ({ line, losers }) {
if (!losers || losers.length === 0) return null
const all = line.concat(losers)
const byCid = new Map()
for (const f of all) if (f.commitCid) byCid.set(String(f.commitCid), f)
const loserCids = new Set(losers.map((f) => String(f.commitCid)))
const forkPointRev = (f) => {
let r = f
while (r.prev && loserCids.has(String(r.prev))) r = byCid.get(String(r.prev))
const p = r.prev ? byCid.get(String(r.prev)) : null
return p ? p.rev : ''
}
// Walk the line's asserting heals in order; each effective one covers the forks it knew.
const covered = new Set()
for (const h of line) {
if (h.asserts === undefined) continue
const knew = new Set((h.heals || []).map(String))
const reach = h.partsFrom || h.rev
let effective = true
for (const l of losers) {
const c = String(l.commitCid)
if (l.rev >= reach) { effective = false; break }
if (!covered.has(c) && !knew.has(c)) { effective = false; break }
}
if (effective) for (const c of knew) covered.add(c)
}
const open = losers.filter((l) => !covered.has(String(l.commitCid)))
if (open.length === 0) return null
let from = null
for (const l of open) { const r = forkPointRev(l); if (from === null || r < from) from = r }
const paths = new Set()
for (const f of all) if (f.rev > from) for (const op of (f.ops || [])) paths.add(op.path)
const window = new Set(paths)
// Partials of THIS window that no fork reaches: their paths are done.
let maxLoserRev = ''
for (const l of losers) if (l.rev > maxLoserRev) maxLoserRev = l.rev
let partsFrom = null
for (const f of line) {
if (f.restates === undefined || f.restates !== from || f.rev <= maxLoserRev) continue
if (partsFrom === null || f.rev < partsFrom) partsFrom = f.rev
// Only update/delete restate a path for every consumer; a `create` in a partial is an ordinary diff op
// the reference AppView ignores over a stale copy, so that path is still owed.
for (const op of (f.ops || [])) if (op.action !== 'create') paths.delete(op.path)
}
const heals = losers.filter((l) => l.rev > from).map((l) => String(l.commitCid))
return { from, paths, window, heals, partsFrom }
}
// path -> record CID string after replaying `frames` in order onto `start` (a Map, copied). Uses only the
// CIDs the ops carry, so it needs no record bytes.
export function replayCids (frames, start = new Map()) {
const m = new Map(start)
for (const f of frames) {
for (const op of (f.ops || [])) {
if (op.action === 'delete') m.delete(op.path)
else if (op.cid) m.set(op.path, String(op.cid))
}
}
return m
}
export { FIREHOSE_PREFIX }