OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 767f69c

favela-peer 0.0.48: a record that CHANGED became permanently unreadable on every holder
ensureLocal verified a local copy against the pointer's oid and, on a positive mismatch, returned null — right there. The fetch below was reachable only when there was no local file at all. So "do not return the stale copy" was implemented as "do not go and get the right one", and those are different rules. Posts never hit it: a post is written under a fresh name and has nothing to be stale. The PROFILE is the one mutable record in the model, overwritten in place at the same (collection, name). So the moment its author edited it, every holder's copy went stale, the pointer named bytes they would never go and get, and the record read as unresolved — not until the next pass, but for ever. Nothing in the system would ever fetch it. That is why a profile change had never once reached a follower, and why the symptom was a profile that went BLANK rather than one that failed to update. Measured live: ashatron set a bio and a new avatar; the pointer replicated to ap-bridge in seconds and the actor's `summary` and `icon` both went null — it lost the profile it already had. The new avatar's bytes were fetchable through the same overlay at that moment (200, 1377 bytes), which ruled out transport and pointed here. Also verifies what LANDED. "A fetch returned" is not "the agreed bytes are on disk": a peer can answer with the wrong content, and — as shipped in files.js and avatars.js (0.0.46) — a fetch can land at its own destPath while the caller re-reads its own path and quietly returns the old bytes. Without the re-hash a stale record would refuse to resolve and then resolve to exactly the stale content it refused. Two tests, written red. 342/342 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
767f69c82738604a5a6ed85c7d1594b0e613ec56
by plankatron <fleeky@gmail.com> · 2026-09-09T00:50:38.000Z

Changed files (4) +145 -48

.pgh-lock.json +43 -43
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.46",
3 "version": "0.0.47",
44 "runtime": "bare",
5 "rootTreeHash": "83d403e7ecbb4ad299b588e76a51781a94f18d1a807750e934cc058612288b4a",
6 "publishedAt": "2026-09-08T22:09:42.828Z",
5 "rootTreeHash": "0606f31132dea63b0e3301866b53c579c7432abad2f064a9853bab27093b6f27",
6 "publishedAt": "2026-09-09T00:45:20.793Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -3016,8 +3016,8 @@
30163016 "tree": "c5f18b3620514f54714aeb351f074adface216a335242e323e33d28d261376fe"
30173017 }
30183018 ],
3019 "reason": "version-splits only: 0 code-differs",
3020 "acceptedAt": "2026-09-08T22:09:42.842Z"
3019 "reason": "version-splits only",
3020 "acceptedAt": "2026-09-09T00:45:20.805Z"
30213021 },
30223022 {
30233023 "type": "VERSION_CONFLICT",
……@@ -3061,8 +3061,8 @@
30613061 "tree": "6bb5233025d1c30e4f181e29b6ab5af549d8909c7070c7b4438b30f1a4e76ce2"
30623062 }
30633063 ],
3064 "reason": "version-splits only: 0 code-differs",
3065 "acceptedAt": "2026-09-08T22:09:42.842Z"
3064 "reason": "version-splits only",
3065 "acceptedAt": "2026-09-09T00:45:20.805Z"
30663066 },
30673067 {
30683068 "type": "VERSION_CONFLICT",
……@@ -3076,8 +3076,8 @@
30763076 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
30773077 }
30783078 ],
3079 "reason": "version-splits only: 0 code-differs",
3080 "acceptedAt": "2026-09-08T22:09:42.842Z"
3079 "reason": "version-splits only",
3080 "acceptedAt": "2026-09-09T00:45:20.805Z"
30813081 },
30823082 {
30833083 "type": "VERSION_CONFLICT",
……@@ -3101,8 +3101,8 @@
31013101 "tree": "bdebba5a532ce4846b49f1e5ffad774becfc44182d0c3737f7e441ab42f4c43d"
31023102 }
31033103 ],
3104 "reason": "version-splits only: 0 code-differs",
3105 "acceptedAt": "2026-09-08T22:09:42.842Z"
3104 "reason": "version-splits only",
3105 "acceptedAt": "2026-09-09T00:45:20.805Z"
31063106 },
31073107 {
31083108 "type": "VERSION_CONFLICT",
……@@ -3131,8 +3131,8 @@
31313131 "tree": "b799e6ba8726aa951b45f12cc7ecffe5f4e9ee1e8adf09a34d026d9d5658828a"
31323132 }
31333133 ],
3134 "reason": "version-splits only: 0 code-differs",
3135 "acceptedAt": "2026-09-08T22:09:42.842Z"
3134 "reason": "version-splits only",
3135 "acceptedAt": "2026-09-09T00:45:20.805Z"
31363136 },
31373137 {
31383138 "type": "VERSION_CONFLICT",
……@@ -3146,8 +3146,8 @@
31463146 "tree": "e92100a4f821a638ac99c1407e4c7e784f8eb9661e6d9119ecbec4faae77cc7e"
31473147 }
31483148 ],
3149 "reason": "version-splits only: 0 code-differs",
3150 "acceptedAt": "2026-09-08T22:09:42.842Z"
3149 "reason": "version-splits only",
3150 "acceptedAt": "2026-09-09T00:45:20.805Z"
31513151 },
31523152 {
31533153 "type": "VERSION_CONFLICT",
……@@ -3161,8 +3161,8 @@
31613161 "tree": "1f5de2daa08643a5cb8bce2153ca4d55d4504fb366f1f1ef80bca1700ec085f7"
31623162 }
31633163 ],
3164 "reason": "version-splits only: 0 code-differs",
3165 "acceptedAt": "2026-09-08T22:09:42.842Z"
3164 "reason": "version-splits only",
3165 "acceptedAt": "2026-09-09T00:45:20.805Z"
31663166 },
31673167 {
31683168 "type": "VERSION_CONFLICT",
……@@ -3176,8 +3176,8 @@
31763176 "tree": "579bdc1eaabbca726d3916f1fc74cfd4986653f6748632db586817a2a8caabea"
31773177 }
31783178 ],
3179 "reason": "version-splits only: 0 code-differs",
3180 "acceptedAt": "2026-09-08T22:09:42.842Z"
3179 "reason": "version-splits only",
3180 "acceptedAt": "2026-09-09T00:45:20.805Z"
31813181 },
31823182 {
31833183 "type": "VERSION_CONFLICT",
……@@ -3191,8 +3191,8 @@
31913191 "tree": "967cf214778dbacd7cc7cc3850ffdff6a6f145b4bd73de741d0938d7d84d33d5"
31923192 }
31933193 ],
3194 "reason": "version-splits only: 0 code-differs",
3195 "acceptedAt": "2026-09-08T22:09:42.842Z"
3194 "reason": "version-splits only",
3195 "acceptedAt": "2026-09-09T00:45:20.805Z"
31963196 },
31973197 {
31983198 "type": "VERSION_CONFLICT",
……@@ -3216,8 +3216,8 @@
32163216 "tree": "35b214bfbf85f8bbcb9f1926470e77f4e240d9b5045b07f81ce83fec24c1380c"
32173217 }
32183218 ],
3219 "reason": "version-splits only: 0 code-differs",
3220 "acceptedAt": "2026-09-08T22:09:42.842Z"
3219 "reason": "version-splits only",
3220 "acceptedAt": "2026-09-09T00:45:20.805Z"
32213221 },
32223222 {
32233223 "type": "VERSION_CONFLICT",
……@@ -3231,8 +3231,8 @@
32313231 "tree": "67c4c05f69061f65a2548595c43282be2da8e0934e6ce347afa034b29cc40c81"
32323232 }
32333233 ],
3234 "reason": "version-splits only: 0 code-differs",
3235 "acceptedAt": "2026-09-08T22:09:42.842Z"
3234 "reason": "version-splits only",
3235 "acceptedAt": "2026-09-09T00:45:20.805Z"
32363236 },
32373237 {
32383238 "type": "VERSION_CONFLICT",
……@@ -3256,8 +3256,8 @@
32563256 "tree": "4ef8a03ba96b1488cb7ee35dc582b14d096ae4b3fab5481186c94516d434fe18"
32573257 }
32583258 ],
3259 "reason": "version-splits only: 0 code-differs",
3260 "acceptedAt": "2026-09-08T22:09:42.842Z"
3259 "reason": "version-splits only",
3260 "acceptedAt": "2026-09-09T00:45:20.805Z"
32613261 },
32623262 {
32633263 "type": "VERSION_CONFLICT",
……@@ -3281,8 +3281,8 @@
32813281 "tree": "5fc49d698f6ddfd653b3cdaa72ec572d6804293e2e9c3e98303f69417115e903"
32823282 }
32833283 ],
3284 "reason": "version-splits only: 0 code-differs",
3285 "acceptedAt": "2026-09-08T22:09:42.842Z"
3284 "reason": "version-splits only",
3285 "acceptedAt": "2026-09-09T00:45:20.805Z"
32863286 },
32873287 {
32883288 "type": "VERSION_CONFLICT",
……@@ -3296,8 +3296,8 @@
32963296 "tree": "d0099e1743480f28a08c7374d20ad5048088475180ab606626b63acd59e365a5"
32973297 }
32983298 ],
3299 "reason": "version-splits only: 0 code-differs",
3300 "acceptedAt": "2026-09-08T22:09:42.842Z"
3299 "reason": "version-splits only",
3300 "acceptedAt": "2026-09-09T00:45:20.805Z"
33013301 },
33023302 {
33033303 "type": "VERSION_CONFLICT",
……@@ -3311,8 +3311,8 @@
33113311 "tree": "60429c7756f039ec2b3e5240f702dee450c1da19ad6de06900a3877c6c72bb52"
33123312 }
33133313 ],
3314 "reason": "version-splits only: 0 code-differs",
3315 "acceptedAt": "2026-09-08T22:09:42.842Z"
3314 "reason": "version-splits only",
3315 "acceptedAt": "2026-09-09T00:45:20.805Z"
33163316 },
33173317 {
33183318 "type": "VERSION_CONFLICT",
……@@ -3336,8 +3336,8 @@
33363336 "tree": "6fc80ad1ccbfb046613f36299032f21a4752e2614ff8d88a7a5bd7ce66fc1293"
33373337 }
33383338 ],
3339 "reason": "version-splits only: 0 code-differs",
3340 "acceptedAt": "2026-09-08T22:09:42.842Z"
3339 "reason": "version-splits only",
3340 "acceptedAt": "2026-09-09T00:45:20.805Z"
33413341 },
33423342 {
33433343 "type": "VERSION_CONFLICT",
……@@ -3356,8 +3356,8 @@
33563356 "tree": "92424acff1a50468df203528e41739f44f602904abc4beb34500ce172c6d0ad2"
33573357 }
33583358 ],
3359 "reason": "version-splits only: 0 code-differs",
3360 "acceptedAt": "2026-09-08T22:09:42.842Z"
3359 "reason": "version-splits only",
3360 "acceptedAt": "2026-09-09T00:45:20.805Z"
33613361 },
33623362 {
33633363 "type": "VERSION_CONFLICT",
……@@ -3376,8 +3376,8 @@
33763376 "tree": "36d01631ddee74ac2ce62971abd7c0ac30562da8dfd6495480da56c534eb80b7"
33773377 }
33783378 ],
3379 "reason": "version-splits only: 0 code-differs",
3380 "acceptedAt": "2026-09-08T22:09:42.842Z"
3379 "reason": "version-splits only",
3380 "acceptedAt": "2026-09-09T00:45:20.805Z"
33813381 },
33823382 {
33833383 "type": "VERSION_CONFLICT",
……@@ -3391,8 +3391,8 @@
33913391 "tree": "bba869f5c85b11bce5671d57a87cd2793b490588867cd07fbe94cad796114970"
33923392 }
33933393 ],
3394 "reason": "version-splits only: 0 code-differs",
3395 "acceptedAt": "2026-09-08T22:09:42.842Z"
3394 "reason": "version-splits only",
3395 "acceptedAt": "2026-09-09T00:45:20.805Z"
33963396 },
33973397 {
33983398 "type": "VERSION_CONFLICT",
……@@ -3406,8 +3406,8 @@
34063406 "tree": "2ecc91a8c3595dda5dfc86ace62a70875520adc4e588a2ac14945dbc4313c450"
34073407 }
34083408 ],
3409 "reason": "version-splits only: 0 code-differs",
3410 "acceptedAt": "2026-09-08T22:09:42.842Z"
3409 "reason": "version-splits only",
3410 "acceptedAt": "2026-09-09T00:45:20.805Z"
34113411 }
34123412 ]
34133413}
lib/record-store.js +28 -4
……@@ -112,9 +112,21 @@ export class RecordStore {
112112 //
113113 // ⛔ A local hit is VERIFIED against `oid` before it is returned. Skipping that check is what let
114114 // two co-owners hold identical pointer sets, resolve them both happily, and publish different
115 // content. On a positive mismatch this returns null rather than the superseded copy: the record
116 // reads as unresolved and the caller stalls, which is the correct failure. Advancing with content
117 // that does not match what the group agreed on is not.
115 // content. A superseded copy is never returned: stalling loudly beats advancing with content
116 // nobody agreed on.
117 //
118 // ⛔⛔ BUT A MISMATCH MUST FETCH, and for a long time it did not — it returned null right there,
119 // and the fetch below was reachable only when there was no local file at all. Posts never hit it,
120 // because a post is written under a fresh name and has nothing to be stale. The PROFILE is the
121 // one record in the model that is MUTABLE and overwritten in place at the same (collection,
122 // name) — so the moment its author edited it, every holder's copy went stale, the pointer named
123 // bytes they would never go and get, and the record read as unresolved FOR EVER. Not "until the
124 // next pass": nothing in the system would ever fetch it. That is why a profile change had never
125 // once reached a follower, and why the symptom was a profile that went BLANK rather than one that
126 // failed to update.
127 //
128 // "Do not return the stale copy" and "do not go and get the right one" are different rules, and
129 // only the first was ever intended.
118130 async ensureLocal (collection, name, oid) {
119131 const local = await this.get(collection, name)
120132 if (local != null) {
……@@ -123,7 +135,8 @@ export class RecordStore {
123135 // Only a POSITIVE mismatch is staleness. If the bytes cannot be hashed we cannot judge them,
124136 // and inventing a verdict would re-resolve every record on every pass.
125137 if (!h || h.oid === oid) return local
126 return null
138 // Stale — fall through to the fetch. The superseded bytes stay addressable by their own oid
139 // in the overlay; what is replaced here is only this name's view of which version is current.
127140 }
128141 if (!this._overlay || !oid) return null
129142 const disk = this._pathFor(collection, name)
……@@ -137,6 +150,17 @@ export class RecordStore {
137150 ])
138151 if (!r) return null
139152 } catch { return null }
153
154 // ⛔ VERIFY WHAT LANDED, because "a fetch returned" is not "the agreed bytes are on disk". Two
155 // ways it can be false: a peer answers with content that does not hash to what was asked for,
156 // and — the one that actually shipped, in files.js and avatars.js (0.0.46) — the fetch lands at
157 // its own `destPath` while the caller re-reads its own path and quietly returns the OLD bytes.
158 // Without this check, a stale record would refuse to resolve and then, one line later, resolve
159 // to exactly the stale content it just refused.
160 if (typeof this._overlay.hashFile === 'function') {
161 const after = await this.localOid(collection, name)
162 if (after && after.oid !== oid) return null
163 }
140164 return this.get(collection, name)
141165 }
142166
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.47",
3 "version": "0.0.48",
44 "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/record-store.test.js +73 -0
……@@ -106,3 +106,76 @@ test('⛔ ensureLocal VERIFIES bytes against the expected oid (invariants 2 and
106106 t.is(await store.ensureLocal('post', 'a1', oid), null,
107107 'a mismatch returns NULL and stalls loudly — never the stale copy')
108108})
109
110// ⛔⛔ THE ONE THAT SHIPPED, and it is the reason a profile change never reached anybody.
111//
112// Invariant 3 says a mismatch must not return the stale copy. It does not say STOP — but that is
113// what the code did: on a positive mismatch it returned null and never reached the fetch below,
114// which was only reachable when there was no local file at all.
115//
116// Posts never hit it, because a post is written under a fresh name and there is no local file to be
117// stale. The PROFILE is the one record in the model that is MUTABLE and overwritten in place at the
118// same (collection, name) — so the moment its author edited it, every holder's copy went stale, the
119// pointer named bytes they would never go and get, and the record read as unresolved FOR EVER.
120//
121// Measured live 2026-09-08: ashatron set a bio and a new avatar; the pointer replicated to the
122// bridge in seconds and the actor's `summary` and `icon` both went NULL — it lost the profile it
123// already had. The new avatar's BYTES were fetchable through the same overlay at the same moment
124// (200, 1377 bytes), which is what ruled out a transport problem and pointed here.
125//
126// ⚠ Verified AFTER the fetch too. `files.js` and `avatars.js` both shipped a bug where the fetch
127// landed at `r.destPath` and the caller re-read its own path, quietly returning the old bytes
128// (0.0.46). Re-hashing is what makes "fetched" mean "fetched the agreed content" rather than
129// "something is on disk now".
130test('⛔⛔ a STALE local copy is REFETCHED, not just refused', async (t) => {
131 const root = tmp()
132 // The peer that has the bytes: content hash = the bytes, so any content is addressable by them.
133 const remote = new Map()
134 const overlay = {
135 async registerFile (_p, disk) { const b = fs.readFileSync(disk); const h = b4a.toString(b, 'hex'); remote.set(h, b); return { contentHash: h, size: b.length } },
136 async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } },
137 async fetchFile (oid, { destPath } = {}) {
138 const b = remote.get(oid)
139 if (!b) return null
140 fs.writeFileSync(destPath, b)
141 return { contentHash: oid, size: b.length, destPath }
142 },
143 async unregisterFile () {}
144 }
145 const store = new RecordStore(root, { overlay })
146
147 // What the group agreed on, registered with the overlay so a peer can serve it.
148 const agreed = serialize(rec('the profile everyone agreed on'))
149 const tmpPath = join(root, 'agreed.md')
150 fs.writeFileSync(tmpPath, agreed)
151 const { contentHash: agreedOid } = await overlay.registerFile('x', tmpPath)
152
153 // This node holds an OLDER copy under the same name — exactly a profile before its author edited it.
154 await store.put('profile', 'self', rec('the profile as it used to be'))
155 t.not((await store.localOid('profile', 'self')).oid, agreedOid, 'genuinely stale')
156
157 const got = await store.ensureLocal('profile', 'self', agreedOid)
158 t.ok(got, 'resolves — a mutable record must not be permanently unreadable because it changed')
159 t.is(got.body, 'the profile everyone agreed on\n', 'and it is the content the POINTER names')
160 t.is((await store.localOid('profile', 'self')).oid, agreedOid, 'the disk copy converged too')
161})
162
163test('⛔ but content that does not hash to the pointer is still refused after a fetch', async (t) => {
164 const root = tmp()
165 const overlay = {
166 async registerFile (_p, disk) { const b = fs.readFileSync(disk); return { contentHash: b4a.toString(b, 'hex'), size: b.length } },
167 async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } },
168 // A peer that answers with the WRONG bytes. Claiming success is the dangerous case: the caller
169 // would otherwise re-read its own path and publish whatever landed there.
170 async fetchFile (oid, { destPath } = {}) {
171 fs.writeFileSync(destPath, serialize(rec('not what was asked for')))
172 return { contentHash: oid, size: 1, destPath }
173 },
174 async unregisterFile () {}
175 }
176 const store = new RecordStore(root, { overlay })
177 await store.put('profile', 'self', rec('the profile as it used to be'))
178
179 t.is(await store.ensureLocal('profile', 'self', 'ffff'.repeat(16)), null,
180 'null — stalling beats advancing with content nobody agreed on')
181})