OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / 9c0e443

README: the blobs gap closed, the edges shipped, and neither was recorded
"blobs. hpp exposes upload-blob; nothing calls it. A post with a picture sends its text and reports the picture lost." False since images landed: lib/bsky.js:172 calls upload-blob and lib/project.js:35 buildEmbed builds app.bsky.embed.images. The fediverse attachment gap is real and is a different gap; they are now separated. lib/plan-edge.js shipped on 2026-09-11 — likes, reposts and replies reach bsky — and the README, last touched 09-10, did not mention edges at all. The fediverse-delivery bullet was not false but was misleading. It read as work this component would grow, when ap-bridge already depends on favela-peer, reads the same records through the same FavelaPeer client, and delivers. Restated: the network is reached, by another component; what is odd is that one component plans what another performs. index.js's own header still says "v1 SENDS TO BSKY ONLY", which is correct. Also missing from Install and flags: --limit, --reproject, FAVELA_ROOM_TYPE, and that --watch always sends regardless of --send. That last one is a live trap — the deployed pgh args are ["--watch"] with no --send, so a reader who assumed the flags were orthogonal would expect a dry run and publish. The install line said `pgh app install favela-bridge`, which is not the command; package.json carries the registry key. Added the API section the style calls for, per favela-markdown 462e960 — plan, plan-edge, profile, project, bsky, peer, tid, with the caps quoted from the lexicon rather than recalled, and the two constraints a caller can silently break: ctx.subject must be synchronous, and BskySink refuses rather than throws when unconfigured. Measured: 52/52, 154 asserts. Glyph count 0 -> 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9c0e44395e5891ad64aaabc93fefb1db8eac689b
by plankatron <fleeky@gmail.com> · 2026-09-20T20:39:29.000Z

Changed files (1) +170 -30

README.md +170 -30
……@@ -1,56 +1,196 @@
11# favela-bridge
22
3Projects a node's own favela records onto the networks its identity has turned on.
3Projects a node's own favela records onto the networks its identity has turned on. Reads
4`favela-peer`, decides, and drives the sinks.
45
5 favela-bridge plan and report; publish nothing
6 favela-bridge --send project once
7 favela-bridge --watch project continuously (implies --send)
8
9 FAVELA_PEER_SOCKET favela-peer's socket
10 FAVELA_BSKY_SOCKET hyper-proto-peer's control socket — required to publish
11 FAVELA_BRIDGE_INTERVAL seconds between cycles when watching (default 15)
6```
7favela-bridge plan every record and report; publish nothing
8favela-bridge --send project once
9favela-bridge --watch project continuously, every cycle
10favela-bridge --limit <n> a ceiling on one cycle
11favela-bridge --reproject re-derive records already in the repo and update what changed
12```
1213
1314## Install
1415
1516```sh
16pgh app install favela-bridge
17pgh publish .
18pgh app install pgh://<registry-key>/favela-bridge --name favela-bridge
1719```
1820
19Needs a running `favela-peer` (read) and, to publish, a running `hyper-proto-peer`
20(`FAVELA_BSKY_SOCKET`). With no `--send` it plans and reports and writes nothing.
21A `pgh.type: daemon` app, deployed with `args: ["--watch"]` and `autostart: true`.
22
23| var | default | |
24|---|---|---|
25| `FAVELA_PEER_SOCKET` | `${HOME}/.config/favela-peer/favela.sock` | favela-peer's socket, read |
26| `FAVELA_BSKY_SOCKET` | `${HOME}/.config/hyper-proto-peer/data/control.sock` | hpp's control socket; required to publish |
27| `FAVELA_BRIDGE_INTERVAL` | `15` | seconds between cycles when watching, floor 5 |
28| `FAVELA_ROOM_TYPE` | `public` | what the node's own feed is treated as |
29
30Dry by default. Projection is publication: it puts words under an identity on a network that does not
31forget, so a bare run says what would happen and does nothing.
32
33`--watch` always sends, and `--send` has no bearing on it. The two flags read as orthogonal and are
34not — the deployed pgh args are `["--watch"]` with no `--send`. Use `--limit 0` for a watch that sends
35nothing. This is deliberately not "fixed" by making watch honour `--send`, because both deployed
36daemons rely on it and would silently stop publishing.
37
38`--limit` exists for the first send into a repo that has followers: a feed with a history projects all
39of it the first time, and "16 posts at once" is a different act from "post".
40
41`--reproject` is reconciliation, the counterpart to the event path. A post is skipped once its rkey is
42in the repo, which is right for delivery and wrong the moment the projection improves — every post
43made before images worked would keep its text-only version for ever, and no event revisits it. It
44compares against the repo and writes only a real difference.
2145
2246## Why it is its own component
2347
24favela-peer must not know what atproto is — `favela-peer/test/boundary.test.js` enforces it, from
48`favela-peer` must not know what atproto is; `favela-peer/test/boundary.test.js` enforces it, from
2549hyper-social #61 §10: *"the moment the daemon imports the projection the dependency has been
26inverted"*. And hyper-proto-peer signs a production DID. So the projection lives in neither: this
27reads the peer, decides, and drives the sinks.
50inverted"*. And `hyper-proto-peer` signs a production DID. So the projection lives in neither.
2851
29It is also where the reach decision belongs. `favela-markdown/lib/reach.js` exists because that
30check "would otherwise be written three times… and the third one would eventually be written
31wrong". One component asks once and fans out.
52It is also where the reach decision belongs. `favela-markdown/lib/reach.js` exists because that check
53would otherwise be written three times and the third one would eventually be written wrong. One
54component asks once and fans out.
3255
3356## Consent, then capability
3457
3558Two independent gates, and both must pass:
3659
37- **`maxReach(roomType, verb)`** — what a room MAY reach. A followers room cannot reach bsky at all,
60- **`maxReach(roomType, verb)`** — what a room *may* reach. A followers room cannot reach bsky at all,
3861 because atproto has no addressing and a projection it cannot restrict is a leak.
39- **the identity's `reach` preference** — what its owner has ASKED for, set in favela-surface's
40 settings and stored in the peer's own feed. Read every cycle: unticking a network withdraws
41 consent, and a cached copy would keep publishing.
62- **the identity's `reach` preference** — what its owner has *asked* for, set in favela-surface's
63 settings and stored in the peer's own feed. Read every cycle: unticking a network withdraws consent,
64 and a cached copy would keep publishing.
65
66A failure to read either is off. Unknown consent is not consent.
67
68## API
69
70Every planner is pure — no network, no store — so what gets published is testable on its own. The
71caller does the I/O.
72
73### `lib/plan.js`
74
75`planRecord(row, ctx) -> { networks, bsky, fediverse }`. One reach decision, two projections; each
76network derives from the markdown, never from the other's output.
77
78| | |
79|---|---|
80| `MAX_IMAGES` | 4 |
81| `MAX_IMAGE_BYTES` | 2000000 |
82| `selectImages(described)` | which described images may be sent |
83| `samePost(desired, actual)` | compared against the repo, never a local note of what was last sent |
84
85Withheld, never truncated, and withheld, never resized. Cutting a post at 300 graphemes publishes
86something the author did not write, under their name, with nothing to say it was cut; shrinking a
87photo publishes a degraded copy the same way. The canonical record stays whole on favela and the loss
88is named. bsky's cap is 300 **graphemes** and 3000 bytes — an emoji is one grapheme and four bytes,
89and counting the wrong one rejects valid posts.
90
91Images are named off the same parse `render` uses, never off a regex over the stripped text, because
92`strip()` removes an image entirely and by then there is no evidence a picture was in the post. They
93are named, not uploaded: resolving an oid to a blob ref is I/O.
94
95bsky gets plain text plus facets; the fediverse gets HTML, because AS2 `content` is HTML and Mastodon
96renders a useful subset of it. Rendering from the markdown is the entire point of planning both —
97headings, lists, emphasis and links cannot survive a trip through bsky's plain `text`.
98
99`createdAt` is the author's time, unchanged. Stamping projection time would make every backfilled
100record claim to be new.
101
102### `lib/plan-edge.js`
103
104`planEdge(edge, ctx) -> { project, collection, rkey, record, losses }`.
105
106| constant | |
107|---|---|
108| `LIKE_COLLECTION` | `app.bsky.feed.like` |
109| `REPOST_COLLECTION` | `app.bsky.feed.repost` |
110| `POST_COLLECTION` | `app.bsky.feed.post` — replies |
42111
43A failure to read either is OFF. Unknown consent is not consent.
112The signed set is frozen at like, repost, follow, block, reply. This handles the three whose subject is
113a **record**. `follow` and `block` name an actor, and on atproto an actor is a DID — a favela feed key
114is not one, so those wait on the cross-network identity edge.
44115
45## What it does not do yet
116Our own records only. A subject we authored resolves by pure function and its CID is computable from
117the record's own bytes; a foreign subject needs the naming decision hyper-social #71 §8 calls "the one
118irreversible decision", and nothing here forces it.
119
120`ctx.subject` is supplied by the caller and must be **synchronous** — `planEdge` is pure and calls it
121inline, so an async resolver would put a Promise where the lexicon wants `{uri, cid}` and publish a
122like pointing at nothing.
123
124Every refusal is named. A `planEdge` returning a bare `null` is a silence the design forbids.
125
126### `lib/profile.js`
127
128`planProfile({ name, bio, avatar, banner })`, `sameProfile(desired, actual)`.
129
130| | |
131|---|---|
132| `MAX_NAME_GRAPHEMES` | 64 |
133| `MAX_DESCRIPTION_GRAPHEMES` | 256 |
134| `MAX_PROFILE_IMAGE_BYTES` | 1000000 |
135| `BSKY_IMAGE_TYPES` | `image/png`, `image/jpeg` |
136
137It decides, it does not upload: images are named by oid and resolved to blob refs by the caller.
138
139### `lib/project.js`
140
141The pass itself, with no transport in it — it has to run in two places, here against unix sockets and
142inside the handset worklet, where bsky records are authored and signed next to the DID key because that
143key is on the phone. It used to live in `index.js` next to argv parsing and `process.exit`, so the only
144way to run it on a phone was to write it a second time.
145
146| | |
147|---|---|
148| `projectCycle({ peer, bsky, roomType, send, limit, reproject, log })` | one full pass |
149| `reconcileProfile(peer, bsky, reach, send, log)` | bring the bsky profile into line |
150| `buildEmbed(peer, bsky, imgs)` | turn the oids `planBsky` named into `app.bsky.embed.images` |
151
152`index.js` owns the process and this owns the projection. The split is exactly the exit code: a refusal
153to send must end a CLI run non-zero and must **not** end a worklet, where exiting kills the app someone
154is holding. One cycle never throws the watch loop — a peer restarting, a sink respawning on OTA, a
155socket that vanished are all ordinary.
156
157### `lib/bsky.js`
158
159`class BskySink(sock)` — hpp's control socket. `send`, `have`, `getRecord`, `putRecord`, `getProfile`,
160`putProfile`, `uploadBlob`, and a `configured` getter. Unconfigured, every method refuses rather than
161throwing.
162
163### `lib/peer.js`, `lib/tid.js`
164
165`FavelaPeer` is re-exported from `favela-identity/lib/peer-client.js` — one copy of the wire format,
166because ap-bridge reads the same records to project the fediverse and a protocol client written twice
167drifts. `tidFor(name)` pairs a favela record name with an atproto rkey, which is what makes the two
168names pure functions of each other.
169
170## What it does not do
171
172- **Fediverse delivery.** The plan is computed and reported, and this component sends to bsky only.
173 The network itself is not unreached: `ap-bridge` depends on `favela-peer` directly, reads the same
174 records through the same client, holds the RSA key and signs at delivery. The gap is that one
175 component plans a projection another performs, and the plan here is reported rather than omitted so
176 that stays visible.
177- **Facets.** Ours are `{byteStart, byteEnd, type, value}` where value is a `favela://` URI; atproto
178 wants `{index, features:[{$type, did}]}`, so projecting one means resolving a favela identity root to
179 a `did:plc:`. Mentions travel as plain text and the loss is named.
180- **Fediverse attachments.** ap-bridge has `projectAttachments`; wiring it needs the blob to be
181 reachable from there. bsky images do project — `buildEmbed` uploads them through
182 `BskySink.uploadBlob`.
183- **`follow` and `block`.** See `lib/plan-edge.js`.
184
185## Test
186
187```sh
188npm test # brittle-bare test/*.test.js
189```
46190
47- **fediverse delivery.** The plan is computed and reported; ap-bridge holds the RSA key and signs
48 at delivery, and that wiring is the next step. Reported rather than omitted so the gap is visible.
49- **facets.** Ours carry `favela://<root>`; atproto wants a `did:plc:`. Mentions travel as plain
50 text and say so.
51- **blobs.** hpp exposes `upload-blob`; nothing calls it. A post with a picture sends its text and
52 reports the picture lost.
19152 tests, 154 asserts, ~0.3 s. Every planner is pure, so the whole decision surface is covered without
192a socket.
53193
54194## License
55195
56Not yet declared.
196Not yet declared — `package.json` carries no `license` field and there is no LICENSE file.