OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / dee994b

0.2.14: the membrane's repo + firehose projections must describe ONE state
serveMembrane wrote two cores a keyless consumer reads as a PAIR — it takes the head off the firehose and asserts the MST rebuilt from the repo core equals that head's data root (read-view getRepoFromCommit). projectFirehose sourced its half from the SHARED __commit__ log (so the served head is the NETWORK's), while projectRepo sourced the other half from device.repo.listRecords() — this NODE's live view. Two sources, no invariant tying them, so the pair held only by luck. Four ways it broke, all now reproduced offline in test/membrane-projection-drift.test.mjs, every one raising the same production error ("signed commit data CID != rebuilt MST root") that a consumer surfaces as getRepo 500: - LAGGING view: a co-owner whose pointers/bytes have not caught up projects a SUBSET of what the head commits to. A non-signer never adopts the network head and only resolves record bytes as a side effect of sign(), which returns at the election gate first — so it sits there indefinitely. This is hyper-social #14 incident 3: tabertron's device head stuck at s222 against a log head of s322, every getRepo 500, "a restart realigns it" the only known remedy. - DELETES never propagated: projectRepo only ever put(), never del(), so ONE signed delete broke the core permanently, independent of lag. - Running AHEAD: an unsigned write in the view projects a SUPERSET. - POLLUTION: a core already holding leftovers never self-corrected. projectRepoFrames replaces projectRepo on the projection path: the repo half is now derived from the SAME frames the firehose half consumes (each frame carries the record blocks for its own create/update ops, so replay needs no byte resolution and no live view at all), the result is VERIFIED against the head frame's data root before anything is written, and the two cores advance together or neither does. Correct by construction, for signer and non-signer alike — the file's own premise that "__commit__ is rev-ordered + deterministic, so every device projects the SAME firehose" finally applied to the repo half too. Incremental (apply only the not-yet-projected frames onto what the core holds), falling back to a full replay from genesis on a mismatch — which heals a core polluted by the old projection with no operator step. That also required loosening the anti-balloon guard: `log.length === seq.length` skipped the only code that can notice an already-inconsistent pair, so a polluted core kept 500ing until a new frame happened to arrive, and an idle DID never healed at all. Verification is now per-HEAD rather than per-new-frame, so the first sync after a deploy repairs it while steady state stays write-free (drift 4b covers the boot heal, drift 6 pins that idle syncs still grow neither core). When the head genuinely cannot be reconstructed (a referenced record block has not replicated, a discontinuous log, an undecodable frame) it advances NEITHER core and says so, naming WHICH fault it is rather than asserting one — the previously-served head stays consistent and degraded instead of 500ing, and the router's freshest-front logic can pick a co-owner that can reconstruct it. projectRepoFrames never throws; a malformed frame is one of the conditions it exists to survive. projectRepo is kept and exported: it remains the "project the live view" baseline that hyper-proto-peer's derive-vs-project equivalence proof compares exportCar against. Its comment now says why it must not be used for membrane projection. atproto-room 50/50, hyper-proto-peer 67/67, atproto-did-peer + atproto-store green (no existing assertion changed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dee994ba811abe4396a0176e25e3de49a41d6aef
by plankatron <fleeky@gmail.com> · 2026-07-27T13:49:31.000Z

Changed files (4) +392 -5

lib/serve-membrane.js +136 -4
……@@ -12,6 +12,7 @@ import ProtomuxRPC from 'protomux-rpc'
1212import { CID } from 'multiformats/cid'
1313import * as dagCbor from '@ipld/dag-cbor'
1414import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
15import { buildMst } from 'atproto-repo/lib/mst.js'
1516import { RepoStore } from 'atproto-store/lib/repo-store.js'
1617import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
1718import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
……@@ -38,13 +39,117 @@ function deserializeEntry (e) {
3839 }
3940}
4041
41// Project the device's current record set into the RepoStore (last-writer-wins by key).
42// Project the device's current LIVE record set into a RepoStore (last-writer-wins by key).
43//
44// ⚠ NOT the membrane projection path — see projectRepoFrames below for why. This reads THIS NODE's
45// view, which is a different source from the __commit__ frames the firehose projection consumes, so
46// using it to fill the membrane repo core produces a repo/head pair that does not describe one state.
47// Kept because it is the "project the live view" baseline the derive-vs-project equivalence proof
48// compares exportCar against (hyper-proto-peer/test/derive-vs-project.test.mjs) — a legitimate,
49// different job. It also never deletes, so it can only ever be used against a fresh store.
4250async function projectRepo (device, repo) {
4351 for (const { collection, rkey, record } of await device.repo.listRecords()) {
4452 await repo.put(collection, rkey, record)
4553 }
4654}
4755
56// The MST data root for a record set — byte-for-byte the computation `materialize` (lib/signer.js)
57// and read-view's getRepoFromCommit perform, so agreeing with it here means the consumer agrees too.
58// byPath: Map<"collection/rkey", record>. Sorted, because the MST is defined over ordered keys.
59function dataRoot (byPath) {
60 const mstRecords = [...byPath.entries()].map(([key, record]) => ({ key, value: cidForBytesSync(dagCbor.encode(record)) }))
61 mstRecords.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
62 return buildMst(mstRecords).root
63}
64
65// The data root a serialized __commit__ entry's signed commit commits to.
66function entryDataCid (e) {
67 return dagCbor.decode(b4a.from(e.commit, 'hex')).data
68}
69
70// Apply a run of __commit__ frames' ops onto a record set, resolving each create/update to the record
71// block the frame ITSELF carries (buildCommitWrite puts them in newBlocks). Race-free: it depends on
72// nothing but the frames. Returns the mutated map, or null if a referenced block is absent — a
73// partially-replicated log we must NOT pretend to have reconstructed.
74function applyFrames (byPath, frames) {
75 for (const e of frames) {
76 const blocks = new Map((e.blocks || []).map((b) => [b.cid, b.bytes]))
77 for (const op of (e.ops || [])) {
78 if (op.action === 'delete') { byPath.delete(op.path); continue }
79 const hex = op.cid ? blocks.get(op.cid) : null
80 if (!hex) return null
81 byPath.set(op.path, dagCbor.decode(b4a.from(hex, 'hex')))
82 }
83 }
84 return byPath
85}
86
87// Bring the membrane repo core to EXACTLY the record set the log's head frame commits to.
88//
89// This is the fix for the projectRepo/projectFirehose DRIFT. The two projections are read by a
90// keyless consumer as a PAIR: it takes the head off the firehose and asserts the MST rebuilt from
91// the repo core equals that head's data root (read-view getRepoFromCommit). projectRepo sourced the
92// repo half from `device.repo.listRecords()` — this NODE's live view — while projectFirehose sourced
93// the other half from the SHARED __commit__ log, so the invariant held only by luck. Four ways it
94// broke, all seen or provable (test/membrane-projection-drift.test.mjs):
95// - LAGGING view: a co-owner whose bytes/pointers have not caught up projects a SUBSET. A
96// non-signer never adopts the network head and only resolves bytes as a side effect of sign()
97// (which returns at the election gate), so it sits there indefinitely — tabertron's device head
98// stuck at s222 against a log head of s322, every getRepo 500 (hyper-social #14, incident 3).
99// - DELETES: projectRepo only ever put(), never del(), so one signed delete broke the core
100// PERMANENTLY regardless of lag.
101// - Running AHEAD: an unsigned write in the view projects a SUPERSET.
102// - POLLUTION: a core already holding leftovers from the above never self-corrects.
103// Deriving BOTH halves from the frames makes the pair correct by construction, for signer and
104// non-signer alike, and needs no byte resolution and no live view at all. It is the file's own
105// premise ("__commit__ is rev-ordered + deterministic, so every device projects the SAME firehose")
106// applied to the repo half.
107//
108// Incremental first (apply only the not-yet-projected frames onto what the core already holds), then
109// VERIFY against the head frame before writing anything. On a mismatch, replay the whole log from
110// genesis and re-verify — that heals a core polluted by the old live-view projection, which is the
111// state every deployed node is in right now, with no operator step.
112//
113// Returns { ok: true } when the core now matches the head (the caller may advance the firehose), or
114// { ok: false, reason } when it could not be made to match — the caller must then advance NEITHER core
115// (an older CONSISTENT pair still serves; an inconsistent one 500s on every read). It never throws:
116// a malformed frame is one of the conditions it exists to survive. `reason` distinguishes the faults
117// rather than asserting one of them, because a message that names a cause it cannot tell apart from
118// another is worse than one that says nothing (hyper-social #14, lesson 2 — "no connected peer served
119// this oid" cost two days by calling a transport fault a content fault).
120async function projectRepoFrames (repo, log, projected) {
121 let rows, curBytes, headData, target
122 try {
123 rows = await repo.records() // [{ key, bytes, record }], key order
124 curBytes = new Map(rows.map((r) => [r.key, r.bytes]))
125 headData = entryDataCid(log[log.length - 1])
126 target = applyFrames(new Map(rows.map((r) => [r.key, r.record])), log.slice(projected))
127 if (!target || !dataRoot(target).equals(headData)) {
128 target = applyFrames(new Map(), log) // full replay from genesis — heals a polluted/mis-seeded core
129 if (!target) return { ok: false, reason: 'a record block referenced by a #commit op is absent from the log (partial replication)' }
130 if (!dataRoot(target).equals(headData)) return { ok: false, reason: 'the replayed ops do not reproduce the head commit\'s data root (discontinuous log)' }
131 }
132 } catch (e) {
133 return { ok: false, reason: 'the log head could not be read as a signed #commit: ' + e.message }
134 }
135
136 // Write the DIFF only. Redundant puts are already no-ops (RepoStore opens the bee with
137 // alwaysDuplicate:false), but not walking them keeps an idle projection genuinely write-free.
138 for (const [key, record] of target) {
139 const bytes = dagCbor.encode(record)
140 const have = curBytes.get(key)
141 if (have && b4a.equals(b4a.from(have), b4a.from(bytes))) continue
142 const i = key.indexOf('/')
143 await repo.put(key.slice(0, i), key.slice(i + 1), record)
144 }
145 for (const key of curBytes.keys()) {
146 if (target.has(key)) continue
147 const i = key.indexOf('/')
148 await repo.del(key.slice(0, i), key.slice(i + 1)) // the delete projectRepo never performed
149 }
150 return { ok: true }
151}
152
48153// Append any NOT-yet-projected __commit__ frames into the FirehoseSequencer, in seq order. Idempotent
49154// (only entries with seq > sequencer.length). Deterministic across devices (same __commit__ order).
50155async function projectFirehose (device, seq, log = null) {
……@@ -112,6 +217,15 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
112217 // What this DOES do is make it loud, once, naming the DID and both revs — the condition was previously
113218 // invisible from every surface, which is why it survived four days.
114219 let staleWarned = false
220 let driftWarnedAt = null // the head rev we last refused to project — re-report when the head moves
221 // The head rev at which this process last VERIFIED the repo core against the firehose head. The
222 // anti-balloon guard below used to skip on `log.length === seq.length` alone, which also skipped the
223 // only thing that can notice an ALREADY-INCONSISTENT pair — so a core polluted by the old live-view
224 // projection (every deployed node, today) would keep 500ing until a new frame happened to arrive, and
225 // an idle DID would never heal at all. Verifying once per head instead of once per new frame means the
226 // first sync after a deploy repairs it, and steady state stays free (the check is skipped the moment
227 // this head is known good). Nothing else writes the core, so a verified head cannot go bad underneath us.
228 let verifiedAt = null
115229 const sync = async () => {
116230 const log = await device.firehoseLog()
117231 if (!log.length) return
……@@ -126,8 +240,26 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
126240 }
127241 return
128242 }
129 if (log.length === seq.length) return // fully projected — nothing new (the anti-balloon guard)
130 if (repo) await projectRepo(device, repo) // skipped entirely when the repo core is off
243 // Fully projected AND already verified at this head — nothing to do (the anti-balloon guard).
244 if (log.length === seq.length && verifiedAt === logHeadRev) return
245 // The repo core is brought to the head frame's record set FIRST, and the firehose is advanced ONLY
246 // if that succeeded — the two cores are a pair a consumer reads together, so they advance together
247 // or not at all. Skipped entirely when the repo core is off (the stateless virtual device derives
248 // its CAR on demand instead, so there is no pair to keep).
249 const projection = repo ? await projectRepoFrames(repo, log, seq.length) : { ok: true }
250 if (!projection.ok) {
251 if (driftWarnedAt !== logHeadRev) {
252 driftWarnedAt = logHeadRev
253 console.error(JSON.stringify({
254 msg: 'membrane projection REFUSED — the repo core cannot be made to match the record set the log head commits to, so NEITHER core advanced: the previously-served head stays CONSISTENT (degraded) instead of 500ing every read. A co-owner that can reconstruct this head should be fronted instead.',
255 did, reason: projection.reason, logLength: log.length, membraneLength: seq.length, logHeadRev
256 }))
257 }
258 return
259 }
260 verifiedAt = logHeadRev
261 // No-op when the firehose is already at the log head (the repo-only reconcile above) — its loop
262 // runs from seq.length to log.length.
131263 await projectFirehose(device, seq, log)
132264 }
133265 await sync()
……@@ -252,4 +384,4 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
252384 }
253385}
254386
255export { deserializeEntry, projectRepo, projectFirehose, revTime }
387export { deserializeEntry, projectRepo, projectRepoFrames, projectFirehose, revTime }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.13",
3 "version": "0.2.14",
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
……@@ -5,6 +5,7 @@
55import '../lib/bare-globals.js'
66import './repo-base.test.js'
77import './membrane-stale-gate.test.mjs'
8import './membrane-projection-drift.test.mjs'
89import './lease.test.js'
910import './lease-handoff.test.js'
1011import './signer.test.js'
test/membrane-projection-drift.test.mjs +254 -0
……@@ -0,0 +1,254 @@
1// projectRepo / projectFirehose DRIFT — the membrane's two projections must describe ONE state.
2//
3// serveMembrane.sync() writes two cores that a keyless consumer reads as a PAIR:
4// - the membrane FIREHOSE gets every __commit__ frame, so the consumer's verified head is the
5// NETWORK's latest signed commit (the log is shared + replicated).
6// - the membrane REPO gets the record set, and the consumer rebuilds the MST from it and asserts
7// it equals the verified head's data root (read-view getRepoFromCommit).
8//
9// So the load-bearing invariant is: the repo core must hold EXACTLY the record set the last
10// projected frame commits to. projectRepo did not maintain it — it wrote `device.repo.listRecords()`,
11// this NODE's live view, which is a different source from the frames. Three independent ways to break:
12//
13// 1. LAGGING view — a co-owner whose pointers/bytes have not caught up projects a SUBSET of what
14// the head commits to. A non-signer never adopts the network head and only resolves bytes as a
15// side effect of sign() (which returns at the election gate), so it can sit like this for days.
16// Observed live: tabertron's device head stuck at s222 while the log head was s322 → every
17// getRepo 500'd (hyper-social #14, incident 3).
18// 2. DELETES never propagate — projectRepo only ever put(); it never del(). Once any delete is
19// signed, the repo core keeps the dead record FOREVER, so the rebuilt MST can never again match
20// the head. Unconditional and permanent, independent of any lag.
21// 3. Running AHEAD — a view carrying pointers not yet signed into any frame projects a SUPERSET.
22//
23// The fix makes the repo projection a pure function of the SAME frames the firehose projection
24// consumes (each frame carries the record blocks for its own create/update ops), verifies the
25// result against the head frame's data root BEFORE writing anything, and advances both cores
26// together or neither. That is the file's own stated premise — "__commit__ is rev-ordered +
27// deterministic, so every device projects the SAME firehose" — extended to the repo half.
28//
29// Run: bare test/membrane-projection-drift.test.mjs
30import '../lib/bare-globals.js'
31import test from 'brittle'
32import b4a from 'b4a'
33import fs from 'bare-fs'
34import * as dagCbor from '@ipld/dag-cbor'
35import { secp256k1 } from '@noble/curves/secp256k1'
36import { serveMembrane } from '../lib/serve-membrane.js'
37import { buildCommitWrite } from '../lib/signer.js'
38import { openCorestore } from 'atproto-store/lib/corestore.js'
39import { getRepoFromCommit } from 'atproto-store/lib/read-view.js'
40import { decodeCommitFrame } from 'atproto-store/lib/firehose.js'
41import { readCar, loadCommit, listRecords as carListRecords } from 'atproto-repo/lib/repo.js'
42import { verifyCommit } from 'atproto-repo/lib/commit.js'
43
44const PRIV = new Uint8Array(b4a.from('9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c', 'hex'))
45const PUB = secp256k1.getPublicKey(PRIV, true)
46const DID = 'did:plc:membranedrift00000000000'
47const POST = 'app.bsky.feed.post'
48const tmp = () => { const d = '/tmp/ar-drift-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
49
50const rec = (text) => ({ $type: POST, text, createdAt: '2026-07-26T00:00:00.000Z' })
51const A = { collection: POST, rkey: '3aaa', record: rec('a') }
52const B = { collection: POST, rkey: '3bbb', record: rec('b') }
53const path = (r) => r.collection + '/' + r.rkey
54
55// serveMembrane only touches these few swarm members.
56function fakeSwarm () {
57 return { on () {}, off () {}, keyPair: { publicKey: b4a.alloc(32, 7) }, join () { return { flushed: async () => {} } } }
58}
59
60// A device whose __commit__ log AND live record view we control independently — the whole point:
61// the two sources can disagree, and the projection must not fabricate a pair out of them.
62function device () {
63 const d = { _log: [], _view: [] }
64 d.repo = { listRecords: async () => d._view }
65 d.firehoseLog = async () => d._log
66 return d
67}
68
69// The __commit__ entry shape SwarmDevice.serializeEntry writes and serve-membrane's
70// deserializeEntry consumes (binary as hex). Pinned here so this suite exercises the real contract.
71const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
72function serialize (write, seq) {
73 return {
74 seq,
75 time: new Date(0).toISOString(),
76 rev: write.rev,
77 since: write.since,
78 prev: write.signedCommit.prev ? write.signedCommit.prev.toString() : null,
79 prevData: write.prevData ? write.prevData.toString() : null,
80 commitCid: write.commitCid.toString(),
81 commit: hex(write.commitBlock.bytes),
82 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 })),
83 blocks: write.newBlocks.map((b) => ({ cid: b.cid.toString(), bytes: hex(b.bytes) }))
84 }
85}
86
87// Sign one frame continuing from `prevWrite` (null = genesis): before -> after record sets.
88function frame (before, after, rev, prevWrite, seq) {
89 const w = buildCommitWrite(before, after, {
90 did: DID,
91 rev,
92 prev: prevWrite ? prevWrite.commitCid : null,
93 since: prevWrite ? prevWrite.rev : null,
94 privateKey: PRIV
95 })
96 return { write: w, entry: serialize(w, seq) }
97}
98
99// EXACTLY the keyless consumer read path (DidPeer.getRepo over a projecting front): take the head
100// frame off the membrane firehose, verify its commit against the DID key, then rebuild the CAR from
101// the membrane repo core. Throws iff the two projections disagree — the production 500.
102async function consumerGetRepo (serving) {
103 const { body } = decodeCommitFrame(await serving.seq.core.get(serving.seq.core.length - 1))
104 const signed = loadCommit(await readCar(body.blocks)).value
105 if (!verifyCommit(signed, PUB)) throw new Error('head does not verify')
106 const { carBytes } = await getRepoFromCommit(serving.repo, signed)
107 return carListRecords(await readCar(carBytes)).map((r) => r.key).sort()
108}
109
110async function capture (t, fn) {
111 const lines = []
112 const orig = console.error
113 console.error = (s) => lines.push(String(s))
114 t.teardown(() => { console.error = orig })
115 try { await fn() } finally { console.error = orig }
116 return lines
117}
118
119const serve = async (dev) => serveMembrane(dev, { corestore: openCorestore(tmp()), swarm: fakeSwarm(), did: DID })
120
121test('drift 1 — a LAGGING view must not be paired with the NETWORK head (tabertron s222 vs s322)', async (t) => {
122 const dev = device()
123 const f1 = frame([], [A], '3lll000000000', null, 1)
124 const f2 = frame([A], [A, B], '3lll000000001', f1.write, 2)
125 dev._log = [f1.entry, f2.entry]
126 // The node agrees on the POINTER set via the autobase but has not resolved B into its view — the
127 // exact state a non-signing co-owner sits in (bytes only resolve inside sign(), which it never reaches).
128 dev._view = [A]
129
130 const serving = await serve(dev)
131 t.teardown(() => serving.close())
132 await serving.sync()
133
134 const keys = await consumerGetRepo(serving)
135 t.alike(keys, [path(A), path(B)].sort(), 'the repo core holds exactly what the projected head commits to — derived from the frames, not from the lagging view')
136})
137
138test('drift 2 — a signed DELETE must reach the repo core (projectRepo only ever put)', async (t) => {
139 const dev = device()
140 const f1 = frame([], [A, B], '3lll000000000', null, 1)
141 dev._log = [f1.entry]
142 dev._view = [A, B]
143
144 const serving = await serve(dev)
145 t.teardown(() => serving.close())
146 await serving.sync()
147 t.alike(await consumerGetRepo(serving), [path(A), path(B)].sort(), 'both records projected at the first head')
148
149 // B is deleted and the delete is SIGNED: the new head commits to {A} only.
150 const f2 = frame([A, B], [A], '3lll000000001', f1.write, 2)
151 dev._log = [f1.entry, f2.entry]
152 dev._view = [A]
153 await serving.sync()
154
155 t.alike(await consumerGetRepo(serving), [path(A)], 'the delete propagated — the repo core no longer serves the removed record')
156})
157
158test('drift 3 — a view running AHEAD of the log must not leak unsigned records into the repo core', async (t) => {
159 const dev = device()
160 const f1 = frame([], [A], '3lll000000000', null, 1)
161 dev._log = [f1.entry]
162 // B is written but NOT yet signed into any frame (the window between a write and the next sign).
163 dev._view = [A, B]
164
165 const serving = await serve(dev)
166 t.teardown(() => serving.close())
167 await serving.sync()
168
169 t.alike(await consumerGetRepo(serving), [path(A)], 'only the SIGNED record set is served — the unsigned write waits for its frame')
170})
171
172test('drift 4 — a core POLLUTED by the old live-view projection heals when the next frame arrives', async (t) => {
173 const dev = device()
174 const f1 = frame([], [A], '3lll000000000', null, 1)
175 dev._log = [f1.entry]
176 dev._view = [A]
177
178 const serving = await serve(dev)
179 t.teardown(() => serving.close())
180 // What the old projection left behind: a record no frame ever committed to (e.g. one deleted long
181 // ago, whose del() never happened). Every deployed node's core is in some version of this state.
182 await serving.repo.put(POST, '3zzz', rec('stale'))
183
184 const f2 = frame([A], [A, B], '3lll000000001', f1.write, 2)
185 dev._log = [f1.entry, f2.entry]
186 dev._view = [A, B]
187 await serving.sync()
188
189 t.alike(await consumerGetRepo(serving), [path(A), path(B)].sort(), 'the stale record was reconciled AWAY — the core is made to match the head, not merely added to')
190})
191
192test('drift 4b — a polluted core heals at BOOT, with no new frame (the deployed-node migration)', async (t) => {
193 const dev = device()
194 const f1 = frame([], [A], '3lll000000000', null, 1)
195 dev._log = [f1.entry]
196 dev._view = [A]
197
198 // One corestore across two serveMembrane lifetimes = a restart onto the same cores on disk.
199 const corestore = openCorestore(tmp())
200 const before = await serveMembrane(dev, { corestore, swarm: fakeSwarm(), did: DID })
201 await before.sync()
202 await before.repo.put(POST, '3zzz', rec('stale'))
203 before.close()
204
205 // The log has NOT advanced, so the old `log.length === seq.length` guard skipped everything and the
206 // node kept 500ing — indefinitely for an idle DID. Verification is per-HEAD, not per-new-frame.
207 const after = await serveMembrane(dev, { corestore, swarm: fakeSwarm(), did: DID })
208 t.teardown(() => after.close())
209 t.alike(await consumerGetRepo(after), [path(A)], 'the initial sync repaired the pair without an operator step and without a new commit')
210})
211
212test('drift 5 — frames that cannot be replayed must advance NEITHER core, and say so', async (t) => {
213 const dev = device()
214 const f1 = frame([], [A], '3lll000000000', null, 1)
215 dev._log = [f1.entry]
216 dev._view = [A]
217
218 const serving = await serve(dev)
219 t.teardown(() => serving.close())
220 t.alike(await consumerGetRepo(serving), [path(A)], 'a consistent pair is being served at the first head')
221
222 // B's record block is missing from its frame (partial replication of the shared log): the frame's
223 // create op references a block we do not have, so its committed record set cannot be reconstructed.
224 const f2 = frame([A], [A, B], '3lll000000001', f1.write, 2)
225 const bCid = f2.write.ops.find((o) => o.path === path(B)).cid.toString()
226 f2.entry.blocks = f2.entry.blocks.filter((b) => b.cid !== bCid)
227 dev._log = [f1.entry, f2.entry]
228 dev._view = [A]
229
230 const logs = await capture(t, () => serving.sync())
231
232 t.is(serving.seq.core.length, 1, 'the firehose was NOT advanced onto a head the repo cannot match')
233 t.alike(await consumerGetRepo(serving), [path(A)], 'the previous CONSISTENT pair still serves — degraded, not a 500')
234 t.ok(logs.length >= 1, 'the refusal is reported, not silent')
235 t.ok(/projection/i.test(logs[0]), 'the message names the projection')
236 t.is(JSON.parse(logs[0]).did, DID, 'names the DID')
237})
238
239test('drift 6 — the anti-balloon guard still holds: no new frames, no writes to either core', async (t) => {
240 const dev = device()
241 const f1 = frame([], [A, B], '3lll000000000', null, 1)
242 dev._log = [f1.entry]
243 dev._view = [A, B]
244
245 const serving = await serve(dev)
246 t.teardown(() => serving.close())
247 await serving.sync()
248 const repoLen = serving.repo.core.length
249 const seqLen = serving.seq.core.length
250
251 for (let i = 0; i < 5; i++) await serving.sync()
252 t.is(serving.seq.core.length, seqLen, 'firehose core did not grow on idle syncs')
253 t.is(serving.repo.core.length, repoLen, 'repo core did not grow on idle syncs — the ~2s tick cannot balloon it')
254})