OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / 2e8ef98

project the edges: likes, reposts and replies
interaction.md §5.4 freezes the signed set at like/repost/follow/block/reply. This lands the three whose subject is a RECORD. `follow` and `block` name an ACTOR — on atproto a DID, which a favela feed key is not — so they wait on the cross-network identity edge (§3.3). ⛔ OUR OWN RECORDS ONLY, and that is what makes this buildable now. A subject we authored resolves by pure function — §3.1's rkey pairing makes all three names functions of each other — and its CID is computable from the record's own bytes. A FOREIGN subject needs what §3 calls "the one irreversible decision", and nothing here forces it. ⛔ EVERY REFUSAL IS NAMED, because §5.5 says every verb states its loss and calls out `projectLike` returning null as a silence the design forbids. A non-`like` reaction is withheld rather than sent as a like — that would publish a different act under somebody's name. A subject on another feed says so. A subject not yet in the repo reports an ordinary WAIT, which is the common case when an edge is projected before its post. ⛔ EDGES RUN AFTER THE POSTS, always: a subject resolves only once the post it names is in the repo, so edges-first would withhold every one on the first pass and quietly fix itself on the second. ⛔ SUBJECTS ARE RESOLVED BEFORE PLANNING. planEdge is pure and calls `subject()` synchronously, so handing it an async resolver would put a Promise where the lexicon wants { uri, cid } — the record would build, pass every check, and publish an object with no subject. The resolver is INJECTED because computing a CID needs the atproto stack and this package does not depend on it: the desktop CLI supplies none and projects no edges, exactly as before. Replies go through the same planner a post uses with verb 'reply', so the 300-grapheme rule, the facets and the strip cannot drift between a post and a reply. Read-before-write per collection, since `have()` lists posts only and each write is a repo version. Rkeys are TIDs derived from the edge name, so a re-run reproduces them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2e8ef983783dc773598064c02c3adc8ccb5eda14
by plankatron <fleeky@gmail.com> · 2026-09-12T01:50:59.000Z

Changed files (6) +417 -87

.pgh-lock.json +95 -85
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.7",
3 "version": "0.0.12",
44 "runtime": "bare",
5 "rootTreeHash": "d0caf910b7b70149d2c2a861573d2f2e7e5b7acb223bf717758ff84716482d5e",
6 "publishedAt": "2026-09-09T22:23:14.763Z",
5 "rootTreeHash": "25ba97434575533c058d481beb79f2327abea0203a678d61c11ea6b931773393",
6 "publishedAt": "2026-09-10T00:03:40.772Z",
77 "deps": [
88 {
99 "name": "atproto-store",
……@@ -1509,9 +1509,9 @@
15091509 },
15101510 {
15111511 "name": "favela-markdown",
1512 "version": "0.1.6",
1513 "tree": "08913adf8bf7452a12cc06528c49b4629c880de61fa45f01990e33b000a820d9",
1514 "normHash": "853f6fe2c24cea0c956653eaa7d5a13b7feca966cad5c9a6164c2dc1ff324881",
1512 "version": "0.2.0",
1513 "tree": "e980c91c65708beb2d24964f4f8bce19d01f9783d1d93e4eff8674851df64611",
1514 "normHash": "2b6a91ad3e40196bc8a2bed0be0d54af70e365e9159504e3cb86e73f034b8c5f",
15151515 "workspace": true
15161516 },
15171517 {
……@@ -2985,8 +2985,8 @@
29852985 "tree": "b6396de092d11e559d109b0dea7995f340b7959c4efeb0baa06b091f99a43245"
29862986 }
29872987 ],
2988 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
2989 "acceptedAt": "2026-09-09T22:23:14.783Z"
2988 "reason": "dep version-splits only; 0 code-differs",
2989 "acceptedAt": "2026-09-10T00:03:40.787Z"
29902990 },
29912991 {
29922992 "type": "VERSION_CONFLICT",
……@@ -3003,15 +3003,10 @@
30033003 "appName": "favela-web",
30043004 "version": "0.0.3",
30053005 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
3006 },
3007 {
3008 "appName": "hyper-proto-peer",
3009 "version": "0.0.3",
3010 "tree": "1aa7863120ed4d861c4ba8230a8ba551d94d0a922e691697ff7ac77ec9ec7f9a"
30113006 }
30123007 ],
3013 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3014 "acceptedAt": "2026-09-09T22:23:14.783Z"
3008 "reason": "dep version-splits only; 0 code-differs",
3009 "acceptedAt": "2026-09-10T00:03:40.787Z"
30153010 },
30163011 {
30173012 "type": "VERSION_CONFLICT",
……@@ -3025,8 +3020,8 @@
30253020 "tree": "6b8b5a11a8be2dd0ea1fb34391d0ca0b8f711c52776588382767f305433c68c4"
30263021 }
30273022 ],
3028 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3029 "acceptedAt": "2026-09-09T22:23:14.783Z"
3023 "reason": "dep version-splits only; 0 code-differs",
3024 "acceptedAt": "2026-09-10T00:03:40.787Z"
30303025 },
30313026 {
30323027 "type": "VERSION_CONFLICT",
……@@ -3045,8 +3040,8 @@
30453040 "tree": "5b662871145437a17e36c1dbebd64fc6e29be649b168ffb7638d3c5eaa781443"
30463041 }
30473042 ],
3048 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3049 "acceptedAt": "2026-09-09T22:23:14.783Z"
3043 "reason": "dep version-splits only; 0 code-differs",
3044 "acceptedAt": "2026-09-10T00:03:40.787Z"
30503045 },
30513046 {
30523047 "type": "VERSION_CONFLICT",
……@@ -3060,8 +3055,8 @@
30603055 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
30613056 }
30623057 ],
3063 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3064 "acceptedAt": "2026-09-09T22:23:14.783Z"
3058 "reason": "dep version-splits only; 0 code-differs",
3059 "acceptedAt": "2026-09-10T00:03:40.787Z"
30653060 },
30663061 {
30673062 "type": "VERSION_CONFLICT",
……@@ -3075,8 +3070,8 @@
30753070 "tree": "a49b9c4ace4ade9435e530c82694bf39e10e0176475ae6b4270dec913bf6c5ef"
30763071 }
30773072 ],
3078 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3079 "acceptedAt": "2026-09-09T22:23:14.783Z"
3073 "reason": "dep version-splits only; 0 code-differs",
3074 "acceptedAt": "2026-09-10T00:03:40.787Z"
30803075 },
30813076 {
30823077 "type": "VERSION_CONFLICT",
……@@ -3090,8 +3085,8 @@
30903085 "tree": "6260923011112545fcd768b1c2013d6d3467277913acb90b11404fefd4ddab44"
30913086 }
30923087 ],
3093 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3094 "acceptedAt": "2026-09-09T22:23:14.783Z"
3088 "reason": "dep version-splits only; 0 code-differs",
3089 "acceptedAt": "2026-09-10T00:03:40.787Z"
30953090 },
30963091 {
30973092 "type": "VERSION_CONFLICT",
……@@ -3105,8 +3100,8 @@
31053100 "tree": "623bbbece48d0415c906980604a2d46515e0a7b1aeb63516eae5ceb981a27bdd"
31063101 }
31073102 ],
3108 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3109 "acceptedAt": "2026-09-09T22:23:14.783Z"
3103 "reason": "dep version-splits only; 0 code-differs",
3104 "acceptedAt": "2026-09-10T00:03:40.787Z"
31103105 },
31113106 {
31123107 "type": "VERSION_CONFLICT",
……@@ -3120,8 +3115,8 @@
31203115 "tree": "e6991084a57472974a7f19d371cdfa226c28bd92be2d3b5a7ba38eabba8b3bb5"
31213116 }
31223117 ],
3123 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3124 "acceptedAt": "2026-09-09T22:23:14.783Z"
3118 "reason": "dep version-splits only; 0 code-differs",
3119 "acceptedAt": "2026-09-10T00:03:40.787Z"
31253120 },
31263121 {
31273122 "type": "VERSION_CONFLICT",
……@@ -3135,8 +3130,8 @@
31353130 "tree": "ad81cb00dc100cbacbe692462fefc6cd1d676e4b6022e3b9fa2d9294a4461aac"
31363131 }
31373132 ],
3138 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3139 "acceptedAt": "2026-09-09T22:23:14.783Z"
3133 "reason": "dep version-splits only; 0 code-differs",
3134 "acceptedAt": "2026-09-10T00:03:40.787Z"
31403135 },
31413136 {
31423137 "type": "VERSION_CONFLICT",
……@@ -3150,8 +3145,8 @@
31503145 "tree": "5e9cf8230c28d6b667d121cb23a5202b5d5d44e12dbeef0a7d6fbcc55621bb3d"
31513146 }
31523147 ],
3153 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3154 "acceptedAt": "2026-09-09T22:23:14.783Z"
3148 "reason": "dep version-splits only; 0 code-differs",
3149 "acceptedAt": "2026-09-10T00:03:40.787Z"
31553150 },
31563151 {
31573152 "type": "VERSION_CONFLICT",
……@@ -3165,8 +3160,8 @@
31653160 "tree": "f43421419fb851f1bf8fd6f284e23d9f8b5ff2bcb320ac71dcf420b261dc7264"
31663161 }
31673162 ],
3168 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3169 "acceptedAt": "2026-09-09T22:23:14.783Z"
3163 "reason": "dep version-splits only; 0 code-differs",
3164 "acceptedAt": "2026-09-10T00:03:40.787Z"
31703165 },
31713166 {
31723167 "type": "VERSION_CONFLICT",
……@@ -3180,8 +3175,8 @@
31803175 "tree": "dacb5f2689ca922d0431d9958d29e8c070a472355bd9b3fe3ca112be5c159f23"
31813176 }
31823177 ],
3183 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3184 "acceptedAt": "2026-09-09T22:23:14.783Z"
3178 "reason": "dep version-splits only; 0 code-differs",
3179 "acceptedAt": "2026-09-10T00:03:40.787Z"
31853180 },
31863181 {
31873182 "type": "VERSION_CONFLICT",
……@@ -3195,8 +3190,8 @@
31953190 "tree": "d678235d9563addbff0dea060f7739123bb6bc239e344b2fec5db536e2340797"
31963191 }
31973192 ],
3198 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3199 "acceptedAt": "2026-09-09T22:23:14.783Z"
3193 "reason": "dep version-splits only; 0 code-differs",
3194 "acceptedAt": "2026-09-10T00:03:40.787Z"
32003195 },
32013196 {
32023197 "type": "VERSION_CONFLICT",
……@@ -3215,8 +3210,8 @@
32153210 "tree": "0423999b6587f905fd2e8cb7768e5d0587da0489595e8dd543497e3604f35ff1"
32163211 }
32173212 ],
3218 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3219 "acceptedAt": "2026-09-09T22:23:14.783Z"
3213 "reason": "dep version-splits only; 0 code-differs",
3214 "acceptedAt": "2026-09-10T00:03:40.787Z"
32203215 },
32213216 {
32223217 "type": "VERSION_CONFLICT",
……@@ -3230,8 +3225,8 @@
32303225 "tree": "9386fa8b9509fda8b231ae9fd0d7672da1eab57959333e06ee29ba583e60bb5b"
32313226 }
32323227 ],
3233 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3234 "acceptedAt": "2026-09-09T22:23:14.783Z"
3228 "reason": "dep version-splits only; 0 code-differs",
3229 "acceptedAt": "2026-09-10T00:03:40.787Z"
32353230 },
32363231 {
32373232 "type": "VERSION_CONFLICT",
……@@ -3245,8 +3240,8 @@
32453240 "tree": "08013f3f76e93f236b04c5a804dfd12debdc44bc2b5a5a5010f190f96ad682ec"
32463241 }
32473242 ],
3248 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3249 "acceptedAt": "2026-09-09T22:23:14.783Z"
3243 "reason": "dep version-splits only; 0 code-differs",
3244 "acceptedAt": "2026-09-10T00:03:40.787Z"
32503245 },
32513246 {
32523247 "type": "VERSION_CONFLICT",
……@@ -3260,8 +3255,8 @@
32603255 "tree": "336bf1b134f4534c20b04e224211a4eda1dfb700abd3f8626fd08036d9df7b84"
32613256 }
32623257 ],
3263 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3264 "acceptedAt": "2026-09-09T22:23:14.783Z"
3258 "reason": "dep version-splits only; 0 code-differs",
3259 "acceptedAt": "2026-09-10T00:03:40.787Z"
32653260 },
32663261 {
32673262 "type": "VERSION_CONFLICT",
……@@ -3275,8 +3270,8 @@
32753270 "tree": "3dde067fd3ec455f2aafa0afda7cdfab8f96d439bb5cc4e221997909a755bb7d"
32763271 }
32773272 ],
3278 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3279 "acceptedAt": "2026-09-09T22:23:14.783Z"
3273 "reason": "dep version-splits only; 0 code-differs",
3274 "acceptedAt": "2026-09-10T00:03:40.787Z"
32803275 },
32813276 {
32823277 "type": "VERSION_CONFLICT",
……@@ -3300,8 +3295,8 @@
33003295 "tree": "bdebba5a532ce4846b49f1e5ffad774becfc44182d0c3737f7e441ab42f4c43d"
33013296 }
33023297 ],
3303 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3304 "acceptedAt": "2026-09-09T22:23:14.783Z"
3298 "reason": "dep version-splits only; 0 code-differs",
3299 "acceptedAt": "2026-09-10T00:03:40.787Z"
33053300 },
33063301 {
33073302 "type": "VERSION_CONFLICT",
……@@ -3320,8 +3315,8 @@
33203315 "tree": "d31788ffa334976153023c6de84e5020b2009f51f9d472d0c2f09a13a53fc2b6"
33213316 }
33223317 ],
3323 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3324 "acceptedAt": "2026-09-09T22:23:14.783Z"
3318 "reason": "dep version-splits only; 0 code-differs",
3319 "acceptedAt": "2026-09-10T00:03:40.787Z"
33253320 },
33263321 {
33273322 "type": "VERSION_CONFLICT",
……@@ -3335,8 +3330,8 @@
33353330 "tree": "6d6f0aff7bfff8b3693ddd00349d7aa296cfa35adc9abb0865fba66828814db5"
33363331 }
33373332 ],
3338 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3339 "acceptedAt": "2026-09-09T22:23:14.783Z"
3333 "reason": "dep version-splits only; 0 code-differs",
3334 "acceptedAt": "2026-09-10T00:03:40.787Z"
33403335 },
33413336 {
33423337 "type": "VERSION_CONFLICT",
……@@ -3350,8 +3345,8 @@
33503345 "tree": "e693499377f146eec5788fa2887c4e42614f7f2efd3d5848d9019deec86c6632"
33513346 }
33523347 ],
3353 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3354 "acceptedAt": "2026-09-09T22:23:14.783Z"
3348 "reason": "dep version-splits only; 0 code-differs",
3349 "acceptedAt": "2026-09-10T00:03:40.787Z"
33553350 },
33563351 {
33573352 "type": "VERSION_CONFLICT",
……@@ -3365,8 +3360,8 @@
33653360 "tree": "d99fbe108f5700f5e73d908e2d3d39ed44021b11bc74e1f152ebb493719b9eaa"
33663361 }
33673362 ],
3368 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3369 "acceptedAt": "2026-09-09T22:23:14.783Z"
3363 "reason": "dep version-splits only; 0 code-differs",
3364 "acceptedAt": "2026-09-10T00:03:40.787Z"
33703365 },
33713366 {
33723367 "type": "VERSION_CONFLICT",
……@@ -3380,8 +3375,8 @@
33803375 "tree": "4664a97e951dbbfc25e868cbdbec870de2682812c1c19fe7f6d3daf2fe33446a"
33813376 }
33823377 ],
3383 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3384 "acceptedAt": "2026-09-09T22:23:14.783Z"
3378 "reason": "dep version-splits only; 0 code-differs",
3379 "acceptedAt": "2026-09-10T00:03:40.787Z"
33853380 },
33863381 {
33873382 "type": "VERSION_CONFLICT",
……@@ -3400,8 +3395,8 @@
34003395 "tree": "4db5ae5fc0ad8552270405cd87877ac18d796bf082a05cb55e03a158af622290"
34013396 }
34023397 ],
3403 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3404 "acceptedAt": "2026-09-09T22:23:14.783Z"
3398 "reason": "dep version-splits only; 0 code-differs",
3399 "acceptedAt": "2026-09-10T00:03:40.787Z"
34053400 },
34063401 {
34073402 "type": "VERSION_CONFLICT",
……@@ -3415,8 +3410,8 @@
34153410 "tree": "91b555116e75b7bc6309063652b2de61264901fb1e9a0326886bf355acd0acd8"
34163411 }
34173412 ],
3418 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3419 "acceptedAt": "2026-09-09T22:23:14.783Z"
3413 "reason": "dep version-splits only; 0 code-differs",
3414 "acceptedAt": "2026-09-10T00:03:40.787Z"
34203415 },
34213416 {
34223417 "type": "VERSION_CONFLICT",
……@@ -3430,8 +3425,8 @@
34303425 "tree": "9c22db1f4e3137df4e44ba33887b65ce280bfc93454f8b91d2e41b458452e86b"
34313426 }
34323427 ],
3433 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3434 "acceptedAt": "2026-09-09T22:23:14.783Z"
3428 "reason": "dep version-splits only; 0 code-differs",
3429 "acceptedAt": "2026-09-10T00:03:40.787Z"
34353430 },
34363431 {
34373432 "type": "VERSION_CONFLICT",
……@@ -3445,8 +3440,8 @@
34453440 "tree": "d7176ab113d79ab81c4a811285dcaca3948b5c822aac38968594d55904c0f243"
34463441 }
34473442 ],
3448 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3449 "acceptedAt": "2026-09-09T22:23:14.783Z"
3443 "reason": "dep version-splits only; 0 code-differs",
3444 "acceptedAt": "2026-09-10T00:03:40.787Z"
34503445 },
34513446 {
34523447 "type": "VERSION_CONFLICT",
……@@ -3460,8 +3455,8 @@
34603455 "tree": "4d8d5a7eb968560e887d7c9d097a5326b281b67b04e69a5e6b34514985e861ae"
34613456 }
34623457 ],
3463 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3464 "acceptedAt": "2026-09-09T22:23:14.783Z"
3458 "reason": "dep version-splits only; 0 code-differs",
3459 "acceptedAt": "2026-09-10T00:03:40.787Z"
34653460 },
34663461 {
34673462 "type": "VERSION_CONFLICT",
……@@ -3475,8 +3470,8 @@
34753470 "tree": "7236258ca920c2f9853c24ead4286ec92a5f709fdf0117f38257e9702752d3ce"
34763471 }
34773472 ],
3478 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3479 "acceptedAt": "2026-09-09T22:23:14.783Z"
3473 "reason": "dep version-splits only; 0 code-differs",
3474 "acceptedAt": "2026-09-10T00:03:40.787Z"
34803475 },
34813476 {
34823477 "type": "VERSION_CONFLICT",
……@@ -3495,8 +3490,8 @@
34953490 "tree": "955c2e26f31af120554761af5ea65e6773648f137083594bb888429432200344"
34963491 }
34973492 ],
3498 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3499 "acceptedAt": "2026-09-09T22:23:14.783Z"
3493 "reason": "dep version-splits only; 0 code-differs",
3494 "acceptedAt": "2026-09-10T00:03:40.787Z"
35003495 },
35013496 {
35023497 "type": "VERSION_CONFLICT",
……@@ -3515,8 +3510,8 @@
35153510 "tree": "a8699972843ff0e9e0b134447b71b055bbd8acd4a6ea3187624a7a52931ce10e"
35163511 }
35173512 ],
3518 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3519 "acceptedAt": "2026-09-09T22:23:14.783Z"
3513 "reason": "dep version-splits only; 0 code-differs",
3514 "acceptedAt": "2026-09-10T00:03:40.787Z"
35203515 },
35213516 {
35223517 "type": "VERSION_CONFLICT",
……@@ -3535,8 +3530,8 @@
35353530 "tree": "46b1c3d46b4bf7703f0baf537ffbb80489394e28737a5c2e1641ca94ac115c4c"
35363531 }
35373532 ],
3538 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3539 "acceptedAt": "2026-09-09T22:23:14.783Z"
3533 "reason": "dep version-splits only; 0 code-differs",
3534 "acceptedAt": "2026-09-10T00:03:40.787Z"
35403535 },
35413536 {
35423537 "type": "VERSION_CONFLICT",
……@@ -3555,8 +3550,23 @@
35553550 "tree": "db9a29b3a3c27cca5ad561f21995e04945465c98d438579beff71e029e662776"
35563551 }
35573552 ],
3558 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3559 "acceptedAt": "2026-09-09T22:23:14.783Z"
3553 "reason": "dep version-splits only; 0 code-differs",
3554 "acceptedAt": "2026-09-10T00:03:40.787Z"
3555 },
3556 {
3557 "type": "VERSION_CONFLICT",
3558 "dep": "favela-markdown",
3559 "newVersion": "0.2.0",
3560 "newTree": "e980c91c65708beb2d24964f4f8bce19d01f9783d1d93e4eff8674851df64611",
3561 "existing": [
3562 {
3563 "appName": "ap-bridge",
3564 "version": "0.1.6",
3565 "tree": "08913adf8bf7452a12cc06528c49b4629c880de61fa45f01990e33b000a820d9"
3566 }
3567 ],
3568 "reason": "dep version-splits only; 0 code-differs",
3569 "acceptedAt": "2026-09-10T00:03:40.787Z"
35603570 },
35613571 {
35623572 "type": "VERSION_CONFLICT",
……@@ -3570,8 +3580,8 @@
35703580 "tree": "e92100a4f821a638ac99c1407e4c7e784f8eb9661e6d9119ecbec4faae77cc7e"
35713581 }
35723582 ],
3573 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3574 "acceptedAt": "2026-09-09T22:23:14.783Z"
3583 "reason": "dep version-splits only; 0 code-differs",
3584 "acceptedAt": "2026-09-10T00:03:40.787Z"
35753585 },
35763586 {
35773587 "type": "VERSION_CONFLICT",
……@@ -3595,8 +3605,8 @@
35953605 "tree": "f4e87130e4397b037b121356f2db0161fadd8f2c320f7042fa31f4b5308193ec"
35963606 }
35973607 ],
3598 "reason": "dep version-splits only; gate reports 0 code-differs, 0 unclassified",
3599 "acceptedAt": "2026-09-09T22:23:14.783Z"
3608 "reason": "dep version-splits only; 0 code-differs",
3609 "acceptedAt": "2026-09-10T00:03:40.787Z"
36003610 }
36013611 ]
36023612}
lib/plan-edge.js +112 -0
……@@ -0,0 +1,112 @@
1// An edge, projected. Pure — no network, no store, so what gets published is testable on its own.
2//
3// ⛔ THE SIGNED SET IS FROZEN (interaction.md §5.4): like, repost, follow, block, reply. This handles
4// the three whose subject is a RECORD. `follow` and `block` name an ACTOR, and on atproto an actor is a
5// DID — a favela feed key is not one, so those need the cross-network identity edge (§3.3) and are out
6// of scope until a foreign subject can be named.
7//
8// ⛔ OUR OWN RECORDS ONLY, for now. A subject we authored resolves to `at://<did>/app.bsky.feed.post/
9// <tid>` by pure function — §3.1's "rkey pairing already makes all three names pure functions of each
10// other" — and its CID is computable from the record's own bytes. A FOREIGN subject needs the naming
11// decision interaction.md §3 calls "the one irreversible decision", and nothing here forces it.
12//
13// ⚠ EVERY REFUSAL IS NAMED. §5.5: "Every verb states its loss", and `projectLike` returning null is
14// called out there as a silence the design forbids.
15
16import { planRecord } from './plan.js'
17import { tidFor } from './tid.js'
18
19export const LIKE_COLLECTION = 'app.bsky.feed.like'
20export const REPOST_COLLECTION = 'app.bsky.feed.repost'
21export const POST_COLLECTION = 'app.bsky.feed.post'
22
23const WITHHELD = (collection, ...losses) => ({ project: false, collection, rkey: null, record: null, losses })
24
25/**
26 * @param {object} edge from peer `edges-by`: { name, collection, target, value, createdAt, body }
27 * @param {object} ctx
28 * @param {string} ctx.ownFeed this identity's feed key — a target on any other feed is foreign
29 * @param {function} ctx.subject (target) => { uri, cid } | null — resolved by the caller, which is the
30 * only part that needs the repo
31 * @param {string} [ctx.roomType]
32 */
33export function planEdge (edge, ctx = {}) {
34 const kind = edge && edge.collection
35 const target = (edge && edge.target) || null
36 const out = kind === 'repost' ? REPOST_COLLECTION : kind === 'reply' ? POST_COLLECTION : LIKE_COLLECTION
37
38 if (!target) return WITHHELD(out, 'the edge names no target')
39
40 // ⛔ THE SUBJECT MUST BE OURS. Naming somebody else's record means choosing how a foreign subject is
41 // stored, permanently — see the header. Said as a loss rather than skipped, so a person can see that
42 // their like of a stranger's post did not travel.
43 if (ctx.ownFeed && target.feed !== ctx.ownFeed) {
44 return WITHHELD(out, 'subject is on another feed — cross-network subjects are not named yet')
45 }
46 if (target.collection !== 'post') {
47 return WITHHELD(out, 'subject is a ' + target.collection + ', and only a post projects to bsky')
48 }
49
50 const subject = ctx.subject ? ctx.subject(target) : null
51 // ⛔ NOT AN ERROR, AND NOT A SILENCE. A subject that is not in the repo yet is the ordinary case when
52 // an edge is projected before the post it points at — the next pass finds it.
53 if (!subject || !subject.uri || !subject.cid) {
54 return WITHHELD(out, 'the subject is not in the atproto repo yet — it projects once the post does')
55 }
56
57 const createdAt = edge.createdAt || new Date().toISOString()
58 // ⛔ A REAL TID, like a post's. The AppView is keyed "tid" and silently drops records whose rkey is
59 // not one — measured on the live network for posts, and there is no reason to believe likes differ.
60 // Derived from the edge's own name, so a re-run reproduces it and a reversal can find it.
61 const rkey = tidFor(edge.name)
62
63 if (kind === 'reaction' || kind === 'like') {
64 // ⛔ bsky HAS ONE REACTION. A favela reaction carries a value, and anything but `like` has nowhere
65 // to go — reported, never silently sent as a like, which would publish a different act.
66 const value = edge.value || 'like'
67 if (value !== 'like') {
68 return WITHHELD(LIKE_COLLECTION, 'bsky has no reaction other than like, and this one is "' + value + '"')
69 }
70 return {
71 project: true,
72 collection: LIKE_COLLECTION,
73 rkey,
74 record: { $type: LIKE_COLLECTION, subject, createdAt },
75 losses: []
76 }
77 }
78
79 if (kind === 'repost') {
80 return {
81 project: true,
82 collection: REPOST_COLLECTION,
83 rkey,
84 record: { $type: REPOST_COLLECTION, subject, createdAt },
85 losses: []
86 }
87 }
88
89 if (kind === 'reply') {
90 // ⛔ THROUGH THE SAME PLANNER A POST USES, with verb 'reply' so the reach lattice answers for the
91 // right act. Writing a second text pipeline here is how the 300-grapheme rule, the facets and the
92 // strip would drift between a post and a reply.
93 const planned = planRecord({ name: edge.name, body: edge.body, createdAt }, { roomType: ctx.roomType || 'public', verb: 'reply' })
94 if (!planned.bsky.project) return WITHHELD(POST_COLLECTION, ...planned.bsky.losses)
95
96 // ⚠ root AND parent. atproto threads need both; the caller resolves the root by walking up, and
97 // falls back to the parent when the subject is itself a top-level post — which is the common case
98 // and not an error.
99 const root = (ctx.root && ctx.root(target)) || subject
100 return {
101 project: true,
102 collection: POST_COLLECTION,
103 rkey,
104 record: { ...planned.bsky.record, reply: { root, parent: subject } },
105 losses: planned.bsky.losses
106 }
107 }
108
109 return WITHHELD(out, 'no bsky projection for a ' + kind)
110}
111
112export default { planEdge, LIKE_COLLECTION, REPOST_COLLECTION, POST_COLLECTION }
lib/project.js +59 -2
……@@ -21,6 +21,7 @@ import { planProfile, sameProfile } from './profile.js'
2121import { samePost } from './plan.js'
2222import { tidFor } from './tid.js'
2323import { POST_COLLECTION } from './bsky.js'
24import { planEdge } from './plan-edge.js'
2425
2526// oid -> blob ref. Content-addressed, so this can never go stale.
2627const blobCache = new Map()
……@@ -145,7 +146,11 @@ export async function projectCycle ({
145146 // Two header details the CLI knows and this function cannot: whether the room type was CONFIGURED
146147 // or assumed, and which socket the sink is. Reported because "public (assumed)" is the input the
147148 // reach lattice cannot infer, and if it is ever wrong it is wrong in the direction of a leak.
148 roomTypeNote = '', sinkLabel = null
149 roomTypeNote = '', sinkLabel = null,
150 // ⛔ INJECTED, because resolving a subject means computing an atproto record CID and this package
151 // does not depend on the atproto stack. favela-host supplies both; the desktop CLI supplies neither
152 // and simply projects no edges, exactly as before.
153 resolveSubject = null, resolveRoot = null
149154} = {}) {
150155 const reach = await peer.reach()
151156 const { feed, rows } = await peer.ownPosts()
……@@ -226,11 +231,63 @@ export async function projectCycle ({
226231 else log(' fediverse: withheld — ' + plan.fediverse.losses.join('; '))
227232 }
228233
234 // ── edges ────────────────────────────────────────────────────────────────────────────────────
235 //
236 // ⛔ AFTER THE POSTS, ALWAYS. An edge names a subject, and the subject resolves only once the post
237 // it points at is in the repo — projecting edges first would withhold every one of them on the first
238 // pass with "not in the atproto repo yet" and fix itself silently on the second.
239 //
240 // ⚠ Skipped entirely when the caller supplies no resolver: favela-bridge's CLI has no way to compute
241 // a subject CID (that needs the atproto stack, which this package deliberately does not depend on),
242 // so a desktop run keeps doing exactly what it did before.
243 const edgeStats = { sent: 0, skipped: 0, withheld: 0 }
244 if (resolveSubject && reach.networks.includes('bsky')) {
245 for (const collection of ['reaction', 'repost', 'reply']) {
246 let list = []
247 try { list = await peer.call('edges-by', { collection }) } catch { list = [] }
248 for (const e of Array.isArray(list) ? list : []) {
249 // ⛔ RESOLVED BEFORE PLANNING. planEdge is pure and calls `subject()` synchronously, so handing
250 // it an async resolver puts a Promise where the lexicon wants { uri, cid } — the record would
251 // be built, pass every check, and publish an object with no subject.
252 const subj = await resolveSubject(e.target)
253 const rootSubj = resolveRoot ? await resolveRoot(e.target) : null
254 const plan = planEdge(e, { ownFeed: feed, roomType, subject: () => subj, root: rootSubj ? () => rootSubj : null })
255 const line = ' ' + collection + '/' + e.name
256 if (!plan.project) {
257 edgeStats.withheld++
258 log(line + ' ' + plan.collection + ': withheld — ' + plan.losses.join('; '))
259 continue
260 }
261 // ⛔ READ BEFORE WRITE, per collection. `have()` lists posts only, and an edge written every
262 // pass would append a repo version for a like nobody changed.
263 const existing = await bsky.getRecord(plan.collection, plan.rkey)
264 if (existing && existing.unknown) {
265 log(line + ' ' + plan.collection + ': could not read what is there — not touching it')
266 continue
267 }
268 if (existing) {
269 edgeStats.skipped++
270 log(line + ' ' + plan.collection + ': already there')
271 continue
272 }
273 if (!send) {
274 log(line + ' ' + plan.collection + ': would send → ' + plan.record.subject.uri)
275 continue
276 }
277 const w = await bsky.putRecord(plan.collection, plan.rkey, plan.record)
278 if (w.sent) { edgeStats.sent++; log(line + ' ' + plan.collection + ': SENT ' + plan.rkey) }
279 else log(line + ' ' + plan.collection + ': FAILED — ' + w.error)
280 }
281 }
282 log(' edges: sent ' + edgeStats.sent + ' · already there ' + edgeStats.skipped +
283 ' · withheld ' + edgeStats.withheld)
284 }
285
229286 log(' ' + (send ? 'sent ' + sent : 'would send ' + (rows.length - withheld - skipped)) +
230287 ' · already there ' + skipped + ' · withheld ' + withheld +
231288 (held ? ' · held by limit ' + held : ''))
232289
233 return { refused: null, feed, rows: rows.length, sent, skipped, withheld, held, profile, reach }
290 return { refused: null, feed, rows: rows.length, sent, skipped, withheld, held, profile, reach, edges: edgeStats }
234291}
235292
236293export default { projectCycle, reconcileProfile, buildEmbed }
node_modules/favela-identity +1 -0
……@@ -0,0 +1 @@
1../../favela-identity
test/plan-edge.test.js +94 -0
……@@ -0,0 +1,94 @@
1// Projecting an edge — a like, a repost, a reply.
2//
3// ⛔ THE SIGNED SET IS FROZEN (interaction.md §5.4). These are the three whose subject is a RECORD;
4// follow and block name an ACTOR, and on atproto an actor is a DID, which a favela feed key is not.
5//
6// ⛔ EVERY REFUSAL IS NAMED. §5.5 says "every verb states its loss" and calls out `projectLike`
7// returning null as a silence the design forbids — so a withheld edge carries a reason a person can
8// read, not an absence they have to infer.
9
10import test from 'brittle'
11import { planEdge, LIKE_COLLECTION, REPOST_COLLECTION, POST_COLLECTION } from '../lib/plan-edge.js'
12import { tidFor } from '../lib/tid.js'
13
14const OURS = 'a'.repeat(64)
15const THEIRS = 'b'.repeat(64)
16const SUBJECT = { uri: 'at://did:plc:me/app.bsky.feed.post/3mv', cid: 'bafy123' }
17const ctx = (extra = {}) => ({ ownFeed: OURS, subject: () => SUBJECT, ...extra })
18const edge = (collection, extra = {}) => ({
19 name: '1788822724600',
20 collection,
21 target: { feed: OURS, collection: 'post', name: '1788800000000' },
22 createdAt: '2026-09-07T23:12:04.600Z',
23 ...extra
24})
25
26test('★★ a like becomes app.bsky.feed.like, under a TID rkey', (t) => {
27 const p = planEdge(edge('reaction', { value: 'like' }), ctx())
28 t.ok(p.project)
29 t.is(p.collection, LIKE_COLLECTION)
30 t.alike(p.record.subject, SUBJECT, 'subject carries uri AND cid — the lexicon requires both')
31 t.is(p.record.createdAt, '2026-09-07T23:12:04.600Z', 'the edge\'s own time, not now')
32 t.is(p.rkey, tidFor('1788822724600'), 'derived from the edge name, so a re-run reproduces it')
33})
34
35test('★★★ a non-like reaction is withheld, never sent as a like', (t) => {
36 // ⛔ bsky has exactly one reaction. Sending a 🎉 as a like would publish a different act under
37 // somebody's name.
38 const p = planEdge(edge('reaction', { value: '🎉' }), ctx())
39 t.absent(p.project)
40 t.ok(p.losses.some((l) => /no reaction other than like/.test(l)), p.losses.join('; '))
41})
42
43test('★★ a repost becomes app.bsky.feed.repost', (t) => {
44 const p = planEdge(edge('repost'), ctx())
45 t.ok(p.project)
46 t.is(p.collection, REPOST_COLLECTION)
47 t.alike(p.record.subject, SUBJECT)
48})
49
50test('★★★ a reply is a post carrying root and parent', (t) => {
51 const p = planEdge(edge('reply', { body: 'well said' }), ctx())
52 t.ok(p.project)
53 t.is(p.collection, POST_COLLECTION)
54 t.is(p.record.text, 'well said')
55 t.alike(p.record.reply.parent, SUBJECT)
56 // ⚠ With no deeper chain the root IS the parent — a top-level reply, which is the common case and
57 // not an error.
58 t.alike(p.record.reply.root, SUBJECT, 'root falls back to the parent')
59})
60
61test('★★★ a reply to a reply carries the thread ROOT, not the parent twice', (t) => {
62 const ROOT = { uri: 'at://did:plc:me/app.bsky.feed.post/3root', cid: 'bafyroot' }
63 const p = planEdge(edge('reply', { body: 'and another thing' }), ctx({ root: () => ROOT }))
64 t.alike(p.record.reply.root, ROOT, 'the walked-up root')
65 t.alike(p.record.reply.parent, SUBJECT, 'and the immediate parent')
66})
67
68test('★★★ a subject on someone else\'s feed is withheld, and says why', (t) => {
69 // ⛔ Naming a foreign subject means choosing how it is stored, permanently (interaction.md §3).
70 // Nothing here forces that decision, and a person should see that their like did not travel.
71 const p = planEdge(edge('reaction', { value: 'like', target: { feed: THEIRS, collection: 'post', name: 'x' } }), ctx())
72 t.absent(p.project)
73 t.ok(p.losses.some((l) => /another feed/.test(l)), p.losses.join('; '))
74})
75
76test('★★★ an unresolvable subject is an ordinary wait, not a failure', (t) => {
77 // An edge projected before the post it points at — the next pass finds it.
78 const p = planEdge(edge('reaction', { value: 'like' }), ctx({ subject: () => null }))
79 t.absent(p.project)
80 t.ok(p.losses.some((l) => /not in the atproto repo yet/.test(l)), p.losses.join('; '))
81})
82
83test('★★★ a reply in a room that reaches nothing is withheld by the lattice', (t) => {
84 // ⛔ The reach lattice answers for the VERB. A members room reaches nothing but favela, and an
85 // interaction written there must not escape because the identity has bsky switched on.
86 const p = planEdge(edge('reply', { body: 'secret' }), ctx({ roomType: 'members' }))
87 t.absent(p.project)
88 t.ok(p.losses.length > 0, 'and says so: ' + p.losses.join('; '))
89})
90
91test('★★ a follow is not projected here — an actor is not a record', (t) => {
92 const p = planEdge(edge('follow'), ctx())
93 t.absent(p.project)
94})
test/project.test.js +56 -0
……@@ -118,3 +118,59 @@ test('★★ the profile is projected, and only when it differs', async (t) => {
118118 const second = await projectCycle({ peer, bsky: sink, send: true })
119119 t.absent(second.profile.done, 'and NOT rewritten when it already matches — each write is a repo version')
120120})
121
122test('★★★ edges are projected after the posts, and only with a resolver', async (t) => {
123 // ⛔ AFTER, ALWAYS. A subject resolves only once the post it points at is in the repo, so edges
124 // first would withhold every one on the first pass and quietly fix itself on the second.
125 const name = '1788822724600'
126 const { sink, written } = fakeSink()
127 const peer = fakePeer({ rows: [row(name, 'hello')] })
128 peer.call = async (cmd, args = {}) => {
129 if (cmd === 'edges-by' && args.collection === 'reaction') {
130 return [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name } }]
131 }
132 return cmd === 'profile' ? null : []
133 }
134
135 // Without a resolver nothing edge-shaped happens — the desktop CLI cannot compute a subject CID.
136 const plain = await projectCycle({ peer, bsky: sink, send: true })
137 t.absent(plain.edges && plain.edges.sent, 'no resolver, no edges')
138
139 const SUBJ = { uri: 'at://did:plc:me/app.bsky.feed.post/3mv', cid: 'bafy1' }
140 const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => SUBJ })
141 t.is(r.edges.sent, 1, 'the like was projected')
142
143 const like = written.find((w) => w.collection === 'app.bsky.feed.like')
144 t.ok(like, 'as an app.bsky.feed.like')
145 t.alike(like.record.subject, SUBJ, 'with uri and cid')
146
147 const post = written.find((w) => w.collection === 'app.bsky.feed.post')
148 t.ok(post, 'and the post it points at went too')
149})
150
151test('★★★ an edge already in the repo is not written again', async (t) => {
152 // ⛔ Each write is a repo version. A like nobody changed must not append one every pass.
153 const name = '1788822724600'
154 const { sink, written, records } = fakeSink()
155 records.set('app.bsky.feed.like/' + tidFor('e1'), { $type: 'app.bsky.feed.like' })
156 const peer = fakePeer({ rows: [] })
157 peer.call = async (cmd, args = {}) => (cmd === 'edges-by' && args.collection === 'reaction')
158 ? [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name } }]
159 : (cmd === 'profile' ? null : [])
160
161 const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => ({ uri: 'at://x/y/z', cid: 'c' }) })
162 t.is(r.edges.skipped, 1, 'recognised as already there')
163 t.is(written.filter((w) => w.collection === 'app.bsky.feed.like').length, 0, 'and nothing was written')
164})
165
166test('★★★ with bsky switched off, no edge is projected either', async (t) => {
167 const { sink, written } = fakeSink()
168 const peer = fakePeer({ networks: ['fediverse'], rows: [] })
169 peer.call = async (cmd, args = {}) => (cmd === 'edges-by' && args.collection === 'reaction')
170 ? [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name: 'p' } }]
171 : (cmd === 'profile' ? null : [])
172
173 const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => ({ uri: 'at://x/y/z', cid: 'c' }) })
174 t.absent(r.edges.sent, 'consent gates edges exactly as it gates posts')
175 t.is(written.length, 0)
176})