| … | … | @@ -0,0 +1,558 @@ |
| 1 | # The record vocabulary — `net.favela.post` |
| 2 | |
| 3 | **Status:** design, not implemented. Nothing here has been built. |
| 4 | **Revised 2026-08-31** (§0, §4.1) — the canonical record becomes a *markdown file*, not a |
| 5 | DAG-CBOR record. atproto stops being the canonical P2P path and becomes a projection like any other. |
| 6 | **Date:** 2026-08-05 |
| 7 | **Scope:** hyper-proto-peer (authoring), atproto-store (storage), atproto-bridge + ap-bridge |
| 8 | (projection), favela-web (compose + render). |
| 9 | |
| 10 | --- |
| 11 | |
| 12 | ## 0. The governing decision (2026-08-31) |
| 13 | |
| 14 | **atproto was never meant to be the canonical P2P path. It became one by default.** Building the |
| 15 | peer layer on atproto's data model imported atproto's constraints — MST derivation, DAG-CBOR |
| 16 | byte-exactness, lexicon field ceilings, blob CIDs sealed inside signed records — into a network |
| 17 | that only ever wanted to *interface* with atproto. That machinery is genuinely useful, but it |
| 18 | belongs at the bridge/relay layer, not underneath every full-mode peer. |
| 19 | |
| 20 | **So: the canonical record is a markdown document. Every network — atproto and ActivityPub alike — |
| 21 | is a lossy projection derived from it on read.** |
| 22 | |
| 23 | ``` |
| 24 | records/net.favela.post/3msdnle65lk22.md canonical. what the author typed. |
| 25 | records/app.bsky.feed.post/3msdnle65lk22.dagcbor derived projection (bridge builds the MST from it) |
| 26 | https://ap.7rnx.net/ap/objects/3msdnle65lk22 derived projection |
| 27 | ``` |
| 28 | |
| 29 | This is a smaller change than it sounds, because `atproto-store/lib/overlay-repo-store.js` already |
| 30 | stores one file per record and *derives* MST/commit/CAR on read — "nothing atproto-specific is |
| 31 | stored beyond the record bytes + a tiny head.json". Only the encoding of the record file changes. |
| 32 | |
| 33 | **What it buys.** A full-mode peer needs: parse markdown, one file per record, hyper-overlay |
| 34 | replication. **No MST, no DAG-CBOR, no byte-exact CIDs.** Those become the bridge's problem, where |
| 35 | they belong. This is what makes a second implementation tractable. |
| 36 | |
| 37 | ### 0.1 The two governing properties |
| 38 | |
| 39 | Everything else is negotiable; these are not. |
| 40 | |
| 41 | - **Mutable.** A post is a file that can be rewritten. `OverlayRepoStore` is already |
| 42 | overwrite-in-place on edit and unlink on delete, so on-disk size is O(current records), not |
| 43 | O(operations-ever). |
| 44 | - **Extensible.** New extensions can be added at any time without migrating old records, *because |
| 45 | projections are derived on read*. Old posts stay valid; new projections simply carry more. This is |
| 46 | the opposite of a lexicon change, which is a migration. |
| 47 | |
| 48 | **Consequence:** mutable content means the content hash changes on every edit, so **the rkey is the |
| 49 | stable identity, never the hash.** §3.1 already depends on this, and it is why the 22 already- |
| 50 | federated AP object URIs keep resolving. |
| 51 | |
| 52 | --- |
| 53 | |
| 54 | ## 1. The decision |
| 55 | |
| 56 | **The canonical stored record moves from `app.bsky.feed.post` to `net.favela.post`. bsky and |
| 57 | ActivityPub become projections of it. The P2P side reads the canonical record directly and is the |
| 58 | only place the whole post exists.** |
| 59 | |
| 60 | ### Why — the vocabulary was never chosen |
| 61 | |
| 62 | Today the file on disk *is* a bsky post: |
| 63 | |
| 64 | ``` |
| 65 | records/app.bsky.feed.post/3msdnle65lk22.dagcbor |
| 66 | { "$type": "app.bsky.feed.post", "text": "…", "createdAt": "…", "langs": ["en"] } |
| 67 | ``` |
| 68 | |
| 69 | That is bsky's semantic model. It has the fields bsky decided a post has — and no addressing, |
| 70 | because on bsky everything is public. |
| 71 | |
| 72 | The existing favela lexicon confirms this was inherited rather than designed. `net.favela.local.post` |
| 73 | is *literally the bsky shape minus `langs`*: |
| 74 | |
| 75 | ```json |
| 76 | { "$type": "net.favela.local.post", "text": "bork", "createdAt": "2026-07-17T18:11:00.877Z" } |
| 77 | ``` |
| 78 | |
| 79 | So one application's model became the ceiling for every projection. ActivityPub can express |
| 80 | addressing natively — `to`/`cc` are already being emitted — but the stored record has nowhere to |
| 81 | say what the addressing should be, so it is hardcoded to Public. |
| 82 | |
| 83 | This document does not fix a bug. It makes a decision that was previously a default. |
| 84 | |
| 85 | --- |
| 86 | |
| 87 | ## 2. What is verified vs assumed |
| 88 | |
| 89 | Everything in §2 was checked during design. Nothing else in this document should be treated as |
| 90 | verified. |
| 91 | |
| 92 | | claim | status | |
| 93 | |---|---| |
| 94 | | `net.favela.local.post` is the bsky shape minus `langs` | ✅ decoded from disk | |
| 95 | | 148 existing `app.bsky.feed.post` records, **zero** with extra fields | ✅ scanned | |
| 96 | | Lexicon validation is **open** — unknown fields "should be ignored" | ✅ [atproto spec](https://atproto.com/specs/lexicon) | |
| 97 | | …but preservation is **not guaranteed** — other PDSs "might strip" | ✅ [discussion #2126](https://github.com/bluesky-social/atproto/discussions/2126) | |
| 98 | | Custom fields are safe only if they cannot collide with future lexicon fields | ✅ [Custom Schemas](https://docs.bsky.app/docs/advanced-guides/custom-schemas) | |
| 99 | | ap-bridge already emits JSON-LD namespaced extensions | ✅ live: `@context` + `atproto:uri`, `atproto:cid` | |
| 100 | | ap-bridge already emits `to` / `cc` / `contentMap` | ✅ live, hardcoded to Public | |
| 101 | | AS2 `source` `{content, mediaType}` already emitted | ✅ live, `text/plain` | |
| 102 | | `objectIdFor({host, rkey})` → `/ap/objects/<rkey>`, **rkey only** | ✅ read source | |
| 103 | | `rkeyFromObjectId` matches a **single** path segment | ✅ read source | |
| 104 | | **22 objects already cached at prsm.space** under that URI shape | ✅ Mastodon API | |
| 105 | | AP federation works end to end | ✅ 22 public statuses incl. today's | |
| 106 | |
| 107 | --- |
| 108 | |
| 109 | ## 3. Core model |
| 110 | |
| 111 | ### 3.1 The rkey pairing — the mechanism everything rests on |
| 112 | |
| 113 | **A canonical record and its atproto projection share one rkey, differing only by collection.** |
| 114 | |
| 115 | ``` |
| 116 | canonical at://<did>/net.favela.post/3msdnle65lk22 |
| 117 | projection at://<did>/app.bsky.feed.post/3msdnle65lk22 |
| 118 | AP object https://ap.7rnx.net/ap/objects/3msdnle65lk22 |
| 119 | ``` |
| 120 | |
| 121 | This is not a convenience. It resolves four problems at once: |
| 122 | |
| 123 | - **No mapping table.** Given the canonical, the projection is derivable, and vice versa. |
| 124 | - **The AP object URI never changes.** ap-bridge keeps deriving from the rkey, so the 22 already- |
| 125 | federated URIs keep resolving. The collection-in-path change discussed earlier is **not needed**. |
| 126 | - **ap-bridge's pure-function id-map survives** — it never fronts a second collection. |
| 127 | - **Delete and edit cascade mechanically** by rkey. |
| 128 | |
| 129 | ### 3.2 The core fields |
| 130 | |
| 131 | The intersection of what atproto and AS2 both express, checked field by field against both specs: |
| 132 | |
| 133 | ```json |
| 134 | { |
| 135 | "$type": "net.favela.post", |
| 136 | "createdAt": "2026-08-05T13:38:23.294Z", |
| 137 | "langs": ["en"], |
| 138 | |
| 139 | "source": { "content": "hello **world**", "mediaType": "text/markdown" }, |
| 140 | "text": "hello world", |
| 141 | "facets": [ /* byte offsets into `text` */ ], |
| 142 | |
| 143 | "reply": { "root": {…}, "parent": {…} }, |
| 144 | "embed": { /* images / quote / external */ }, |
| 145 | |
| 146 | "audience": ["https://www.w3.org/ns/activitystreams#Public"] |
| 147 | } |
| 148 | ``` |
| 149 | |
| 150 | `audience` is the only genuinely new field. Everything else is what is already written today, |
| 151 | because those parts of bsky's model are sound and reinventing them would be churn. |
| 152 | |
| 153 | ### 3.3 Two representation decisions, settled on recoverability |
| 154 | |
| 155 | **Body: plain text + byte-offset facets is canonical; markdown is the source; HTML is derived.** |
| 156 | |
| 157 | Markdown → HTML is deterministic. HTML → byte-offset facets is not reliably recoverable. So the |
| 158 | canonical machine-readable body is `text` + `facets`, and `source` carries what the human typed. |
| 159 | This is not "atproto won" — it is the direction that loses nothing. |
| 160 | |
| 161 | **Threading: `{root, parent}` is canonical, `inReplyTo` is derived.** atproto's pair carries strictly |
| 162 | more than AS2's single URI, and the projection down is trivial. |
| 163 | |
| 164 | ### 3.4 Markdown links *are* facets |
| 165 | |
| 166 | `[@alice](did:plc:xyz)` carries display text plus resolved target — exactly what a facet is. A |
| 167 | CommonMark parser yields exact source ranges, so facet offsets are **derived from the parse**, never |
| 168 | hand-maintained. This preserves the existing rule (favela-web 0.3.11) that a mention resolves to a |
| 169 | DID or is omitted, never guessed. |
| 170 | |
| 171 | --- |
| 172 | |
| 173 | ## 4. The P2P side is the rich side |
| 174 | |
| 175 | **The canonical record is the P2P record, and the P2P side carries everything**: markdown as |
| 176 | authored, the full extension set, no length ceiling, no facet flattening. bsky and ActivityPub are |
| 177 | the *edges*, and edges are lossy. |
| 178 | |
| 179 | This inverts the usual bridge posture, where the local client degrades to the lowest common |
| 180 | denominator of its targets — which is how `bridge-envelope`'s `content: string` would have capped |
| 181 | every projection, and why ap-bridge refused it. |
| 182 | |
| 183 | **Richness belongs to the representation, not to one app.** favela-web is the first client of the |
| 184 | P2P side, not the definition of it. `favela-cli`, `favela-native`, another peer's implementation, or |
| 185 | anything reading the record files off a shared overlay gets the same complete record. A client that |
| 186 | renders less is choosing to; nothing in the format holds it back. |
| 187 | |
| 188 | This is also what makes the format worth having. If the richest view were a single web app, the |
| 189 | extra fields would be that app's private state. Because the richest view is the *record*, any P2P |
| 190 | reader — including one nobody has written yet — sees the whole post. |
| 191 | |
| 192 | ### 4.1 Dialect — CommonMark, and extensions that carry the rest |
| 193 | |
| 194 | **CommonMark is the base. Extensions are markdown syntax carrying whatever plain markdown cannot.** |
| 195 | |
| 196 | The extension set is more expansive than any target network because it does not have to ask |
| 197 | permission: the canonical record keeps everything, and each bridge takes only what it can carry. |
| 198 | |
| 199 | **Unknown extensions need no special rule — markdown already degrades on its own.** A reader that |
| 200 | does not know `:::spoiler` renders the text inside it. Falling through to literal text *is* the |
| 201 | behaviour, not something this document has to invent. That is a property of the format, and it is |
| 202 | why the extension set can grow without coordination. |
| 203 | |
| 204 | **The dialect is already pinned, in `markdown-strip.md` §2.1: CommonMark 0.31.2 plus exactly |
| 205 | six extensions** — strikethrough, GFM tables, footnotes, `[[wiki-links]]`, block directives |
| 206 | (`:::name`), and hashtags. *"An implementation MUST NOT enable further extensions. A new one is a |
| 207 | spec change, because it changes the bytes of every record that uses it."* Fenced code is CommonMark |
| 208 | core, not an extension. Markdown links are mentions — `[@alice](did:plc:xyz)` carries display text |
| 209 | plus resolved target, so facets are *derived from the parse* when projecting (§3.4), never |
| 210 | hand-maintained. |
| 211 | |
| 212 | **The one exception: addressing.** Getting a spoiler wrong makes a post render plainly. Getting |
| 213 | "who can see this" wrong sends it to the wrong people. So `audience` is a **typed field, never |
| 214 | parsed out of loose text** — a spelling quirk must not be able to change who receives a post. |
| 215 | Everything else may be casual. |
| 216 | |
| 217 | ### 4.2 The strip — must be specified, not merely implemented |
| 218 | |
| 219 | `source.content` (markdown) → `text` (plain) must be **byte-deterministic across implementations**, |
| 220 | or two implementations produce different bsky records from one canonical record and `atproto:cid` |
| 221 | stops identifying content. |
| 222 | |
| 223 | The specification MUST fix, at minimum: emphasis markers removed and inner text kept; link syntax |
| 224 | reduced to its display text with a facet at that byte range; headings reduced to their text; code |
| 225 | fences reduced to their contents; block structure joined with `\n\n`; `[[wiki-link]]` reduced to its |
| 226 | label; directives removed with contents kept. Anything unspecified is a defect in this document. |
| 227 | |
| 228 | ### 4.3 Length |
| 229 | |
| 230 | A favela post has no length limit. `app.bsky.feed.post` is capped at 300 graphemes. |
| 231 | |
| 232 | **Rule: if the stripped text exceeds 300 graphemes, the bsky projection is truncated at a grapheme |
| 233 | boundary and a permalink to the favela post is appended.** This is standard POSSE behaviour and is |
| 234 | preferred over omitting the post, because silence on bsky is indistinguishable from a delivery |
| 235 | failure — a class of confusion this project has paid for repeatedly. |
| 236 | |
| 237 | The AP projection is **never** truncated; AS2 has no length limit. |
| 238 | |
| 239 | --- |
| 240 | |
| 241 | ## 5. Audience |
| 242 | |
| 243 | ### 5.1 Shape |
| 244 | |
| 245 | A list of URIs. `https://www.w3.org/ns/activitystreams#Public` means everyone. A community is its |
| 246 | room key as a URI; a list is its `at://` URI. |
| 247 | |
| 248 | URIs rather than protocol names (`["bsky","fediverse"]`) because a URI names *who*, not *where*. |
| 249 | Adding a protocol later adds no field — it is another audience that either includes a given |
| 250 | recipient or does not. Protocol names as flags would need a new field per protocol forever, and |
| 251 | would put knowledge of projections inside the record, which is the thing this design exists to |
| 252 | avoid. |
| 253 | |
| 254 | ### 5.2 What each projection does with it |
| 255 | |
| 256 | | projection | enforcement | |
| 257 | |---|---| |
| 258 | | **P2P (favela-web and any other client)** | not a projection at all — it is the substrate (§5.4). It renders everything it holds, and `audience` never withholds anything from it. | |
| 259 | | **ActivityPub** | **native.** `audience` maps onto `to`/`cc`. ap-bridge already emits these — hardcoded to Public. The change is to read the value instead of assuming it. | |
| 260 | | **atproto / bsky** | **none.** atproto has no addressing. The only lever is whether the `app.bsky.feed.post` projection is written at all. | |
| 261 | |
| 262 | ### 5.3 The honesty requirement — non-negotiable |
| 263 | |
| 264 | The atproto asymmetry is not a limitation of this design; it is a property of atproto, and the UI |
| 265 | must not conceal it. |
| 266 | |
| 267 | **Not writing a bsky projection means bsky does not INDEX the post. It does not mean the fediverse |
| 268 | or anyone else cannot read it.** The canonical record still rides the firehose and is fetchable by |
| 269 | anyone who fronts the DID. This is the same caveat DESIGN §10.2 already makes about local-only |
| 270 | ("unindexed by bsky, not unreplicated"), and it MUST be surfaced wherever audience is chosen. |
| 271 | |
| 272 | Composer copy must never imply erasure or privacy. Until encryption lands, the honest phrasing |
| 273 | remains "community, not secret". |
| 274 | |
| 275 | ### 5.4 ★ Local is the SUBSTRATE, not a scope — reach is N independent toggles |
| 276 | |
| 277 | **Every post is local.** The canonical record always exists on the P2P side; that is what authoring |
| 278 | one means. "Local" is therefore not a destination you opt into, and there is no way to opt out of |
| 279 | it. What the author actually chooses is **which projections to emit**. |
| 280 | |
| 281 | So reach is not a set of mutually exclusive scopes. It is one **independent toggle per projection**, |
| 282 | with the P2P side always on: |
| 283 | |
| 284 | ``` |
| 285 | local (canonical) ── always, not a choice |
| 286 | ├─ bsky ── toggle |
| 287 | └─ fediverse ── toggle |
| 288 | ``` |
| 289 | |
| 290 | Which yields the same four combinations, described honestly: |
| 291 | |
| 292 | | bsky | fediverse | what it is | canonical | `app.bsky.feed.post` | AS2 projection | |
| 293 | |---|---|---|---|---|---| |
| 294 | | ✅ | ✅ | public | ✅ | written | emitted | |
| 295 | | ✅ | ✗ | atproto only | ✅ | written | withheld | |
| 296 | | ✗ | ✅ | fediverse only | ✅ | **not written** | emitted | |
| 297 | | ✗ | ✗ | local only | ✅ | not written | withheld | |
| 298 | |
| 299 | **Why the framing matters, and why the earlier "four scopes" table was wrong.** Enumerating scopes |
| 300 | implies a named scope per combination, so adding a third network would mean **eight** scopes and a |
| 301 | UI listing all of them. Toggles do not: a new network adds **one** toggle and nothing else changes — |
| 302 | which is the same reason `audience` is a list of URIs rather than a field per protocol (§5.1). The |
| 303 | combinatorial explosion was hiding in the vocabulary, not in the mechanism. |
| 304 | |
| 305 | It also names the thing correctly for the UI. The question a composer should ask is not "what scope |
| 306 | is this post" but "who is this for", and the answer is always "me and my peers, plus whoever else I |
| 307 | choose". |
| 308 | |
| 309 | No new lexicons beyond `net.favela.post`, and no ap-bridge URI change: each toggle is just whether a |
| 310 | projection is produced. |
| 311 | |
| 312 | **`net.favela.local.post` becomes unnecessary** — local is the substrate, not a lexicon. Its existing |
| 313 | records are legacy (§8). |
| 314 | |
| 315 | ⚠ **Neither single-network combination is reachable today**, and for different reasons, which is the |
| 316 | concrete gap this design closes: |
| 317 | |
| 318 | - **atproto only** — ap-bridge reads `app.bsky.feed.post` unconditionally. There is no per-record |
| 319 | signal for it to skip one. |
| 320 | - **fediverse only** — the record must live somewhere bsky will not index, and ap-bridge must read |
| 321 | *that*. The only such collection today is `net.favela.local.post`, and teaching ap-bridge to |
| 322 | project it would make local-only posts federate — destroying the single guarantee that scope has. |
| 323 | |
| 324 | Both therefore require the record to carry the intent. That is `audience`, and it is the reason this |
| 325 | document exists. |
| 326 | |
| 327 | --- |
| 328 | |
| 329 | ## 6. Namespacing |
| 330 | |
| 331 | **AS2 side: already solved and shipped.** `@context` declares a prefix; prefixed terms carry the |
| 332 | extension. Live today: |
| 333 | |
| 334 | ```json |
| 335 | "@context": ["https://www.w3.org/ns/activitystreams", { "atproto": "https://atproto.com/ns#" }], |
| 336 | "atproto:uri": "at://…", "atproto:cid": "bafy…" |
| 337 | ``` |
| 338 | |
| 339 | **atproto side:** no `@context` equivalent. Protocol-specific extras live under a single dotted |
| 340 | container key mirroring NSID authority: |
| 341 | |
| 342 | ```json |
| 343 | "net.favela.ext": { "ap": { "summary": "cw: spoilers", "sensitive": true }, |
| 344 | "bsky": { "labels": { … } } } |
| 345 | ``` |
| 346 | |
| 347 | ⛔ **Never `$`-prefixed** — `$type`, `$link`, `$bytes` are atproto's reserved space. |
| 348 | |
| 349 | **The constraint is narrower than it first appears.** Namespacing only matters when attaching extras |
| 350 | to *someone else's* lexicon. On `net.favela.post` we own the namespace outright, so bare `audience` |
| 351 | is correct and cannot collide. |
| 352 | |
| 353 | ⛔ **Never hang favela data on `app.bsky.feed.post`.** The projection is bsky's view; anything bsky |
| 354 | cannot express is simply absent from it. This removes the collision question rather than managing |
| 355 | it, and it is why §2's "preservation is not guaranteed" finding does not endanger the design. |
| 356 | |
| 357 | --- |
| 358 | |
| 359 | ## 7. The double-write — resolved, not deferred |
| 360 | |
| 361 | **bsky can only see records that exist in the repo. So the `app.bsky.feed.post` projection must be |
| 362 | materialised and signed. There will be two records per public post.** This is a real cost and the |
| 363 | design does not pretend otherwise. |
| 364 | |
| 365 | **Why it is acceptable here:** |
| 366 | |
| 367 | - **Storage is negligible and measured.** Records average ~132 bytes; all 148 total ~32 KB. |
| 368 | Doubling is ~32 KB against a 400 MB substrate. Storage was never the concern. |
| 369 | - **Coherence is the real cost**, and §3.1's shared rkey makes the pairing mechanical rather than a |
| 370 | tracked relationship: one rkey, two collections, cascade on delete and edit. |
| 371 | - **It is confined to public posts.** Fediverse-only and local-only posts write one record. |
| 372 | |
| 373 | **What the implementation MUST guarantee:** |
| 374 | |
| 375 | 1. Delete of a canonical record deletes its projection in the same batch. A tombstone for one and |
| 376 | not the other is a divergence with no recovery path. |
| 377 | 2. Edit re-derives the projection from the canonical. The projection is never edited directly. |
| 378 | 3. The projection is **derived output**. Nothing reads it as a source of truth. If canonical and |
| 379 | projection disagree, the canonical wins and the projection is rebuilt. |
| 380 | 4. `sign()` materialises both before signing, so a commit never contains one without the other. |
| 381 | |
| 382 | --- |
| 383 | |
| 384 | ## 8. Migration — forward-only, nothing rewritten |
| 385 | |
| 386 | There are **148** existing `app.bsky.feed.post` records and an unknown number of |
| 387 | `net.favela.local.post` records. |
| 388 | |
| 389 | **They are not converted.** ⛔ **Never re-encode a record you did not author.** This project has |
| 390 | already destroyed records exactly this way — a second writer re-encoded two of wj62's posts, dropped |
| 391 | `langs`, and the bytes stopped hashing to their converged pointers. They were unrecoverable. That |
| 392 | hazard is *higher* under this design, because every extra field is an unknown field to somebody, and |
| 393 | the atproto spec warns about precisely this: *"when deserializing and re-serializing data, unknown |
| 394 | fields risk being stripped away."* |
| 395 | |
| 396 | **Legacy rule:** an `app.bsky.feed.post` with **no** canonical counterpart at the same rkey is a |
| 397 | legacy post. It is read as `audience: [Public]`, `source.mediaType: text/plain`, no extensions. It |
| 398 | renders, federates and threads exactly as it does today. Nothing rewrites it. |
| 399 | |
| 400 | **Two canonical vocabularies must not coexist indefinitely.** New writes go to `net.favela.post` |
| 401 | from the cutover. Legacy records age out by attrition and are never converted. The end state is one |
| 402 | vocabulary for everything authored after the cutover, and a bounded, shrinking legacy set — |
| 403 | distinguishable by a single lookup. |
| 404 | |
| 405 | --- |
| 406 | |
| 407 | ## 9. Read path |
| 408 | |
| 409 | This section exists because the write path is easy to design and the read path is where the work is. |
| 410 | It is **the largest unestimated part of this design** and is deliberately called out rather than |
| 411 | buried. |
| 412 | |
| 413 | Affected in favela-web: |
| 414 | |
| 415 | - `lib/scopes.js` — `SCOPE_COLLECTION` stops being a scope→collection map; audience replaces it. |
| 416 | - `lib/appview.js` — ingest folds `net.favela.post` **and** legacy collections, deduplicating by |
| 417 | rkey with canonical preferred. `INGEST_VERSION` bumps; a full re-index is required. |
| 418 | - `lib/render.js` — renders `source.content` as markdown when present, falling back to |
| 419 | `text` + `facets` for legacy. |
| 420 | - Threading, search, notifications, RSS/Atom, bookmarks — every path that assumes a post's |
| 421 | collection is `app.bsky.feed.post`. |
| 422 | - `lib/writer.js` — authors canonical + projection. |
| 423 | |
| 424 | Affected elsewhere: hpp's sign/materialize path (§7.4), ap-bridge's `POST_COLLECTION` and its |
| 425 | projection rule (audience-driven rather than collection-driven), atproto-store's `POST_COLLECTIONS`. |
| 426 | |
| 427 | --- |
| 428 | |
| 429 | ## 10. Interop loss |
| 430 | |
| 431 | Today any atproto client can read the repo directly and see posts. After the cutover, a client that |
| 432 | does not know `net.favela.post` sees **only the `app.bsky.feed.post` projections** — so public posts |
| 433 | remain fully readable by any atproto tool, and non-public posts do not. |
| 434 | |
| 435 | That is the correct trade and it is the point: a post addressed away from bsky should not be legible |
| 436 | as a bsky post. But it must be stated plainly, because it is a genuine reduction in what third-party |
| 437 | tooling can see. |
| 438 | |
| 439 | --- |
| 440 | |
| 441 | ## 10.5 Editing |
| 442 | |
| 443 | **Editing is already possible end to end and is blocked only by the absence of a UI.** Researched and |
| 444 | verified 2026-08-05. |
| 445 | |
| 446 | ### 10.5.1 The protocol allows it; bsky's application refuses it |
| 447 | |
| 448 | `putRecord` with the same rkey replaces the record: **same URI, new CID**. The PDS is |
| 449 | lexicon-agnostic and accepts it. But bsky's AppView **deliberately ignores post updates** — in their |
| 450 | own words, *"The Bluesky service prohibits updating posts, so appview intentionally ignores it… the |
| 451 | PDS is expected to be lexicon agnostic, so putRecord succeeds, but appview, which is in charge of |
| 452 | application logic, ignores it"* ([discussion #3038](https://github.com/bluesky-social/atproto/discussions/3038)). |
| 453 | |
| 454 | The tell is an inconsistency, not an error: **`getRecord` and `getPosts` reflect the edit; |
| 455 | `getPostThread` does not.** It is described there as *"a specification unique to bsky post"* — an |
| 456 | application policy, not a protocol limit. |
| 457 | |
| 458 | This is the SAME shape as the audience problem (§5), arriving independently: bsky's application |
| 459 | constraints became ours because its lexicon is our storage format. |
| 460 | |
| 461 | | surface | edit visible? | why | |
| 462 | |---|---|---| |
| 463 | | **P2P (canonical)** | ✅ | we own the appview | |
| 464 | | **ActivityPub** | ✅ | `Update{Note}` — see §10.5.3 | |
| 465 | | **bsky** | ⚠ partially | the record updates; the timeline does not | |
| 466 | |
| 467 | ### 10.5.2 We do NOT need atproto's edit-history workaround |
| 468 | |
| 469 | The community pattern is copy-on-write: each edit copies the old record to a new one carrying |
| 470 | `$orig`, while the live record accumulates a `$hist` array of strongRefs. rkey stays constant so |
| 471 | likes and reposts stay attached. Its author names race conditions, absent transactions, and dead |
| 472 | references in the chain as costs. |
| 473 | |
| 474 | **That pattern exists because atproto has no history. This substrate has two:** |
| 475 | |
| 476 | 1. **The firehose already carries the chain.** `atproto-store/lib/repo-manager.js:99` sets |
| 477 | `op.prev = priorCid` on every update, *"so a relay can invert without holding the old tree"*. The |
| 478 | previous CID is on the wire, per edit, today. |
| 479 | 2. **The overlay and the autobase hold prior versions natively.** |
| 480 | |
| 481 | So `$hist`/`$orig` would reimplement — worse, and inside the record — something the substrate gives |
| 482 | for free. ⛔ **Do not adopt it.** Edit history is a READ over existing state, not a new field. |
| 483 | |
| 484 | ### 10.5.3 ⛔ Every edit MUST re-project to ActivityPub with `updated` |
| 485 | |
| 486 | An `Update` whose object lacks an `updated` timestamp is **silently discarded** by Mastodon: it |
| 487 | accepts the POST, returns 2xx, and does nothing — no edit, no attachment re-fetch, `edited_at` stays |
| 488 | null. Required since Mastodon 3.5.0; unofficial, so reading the AS2 spec will never reveal it. |
| 489 | Verified live 2026-08-05 (ap-bridge 0.0.3 → 0.0.4). |
| 490 | |
| 491 | This costs nothing here: an edit changes the projection, so the fingerprint changes and `Update` |
| 492 | fires on its own. It is recorded because getting it wrong is invisible from every local surface. |
| 493 | |
| 494 | ### 10.5.4 The honesty requirement |
| 495 | |
| 496 | **The UI must not imply an edit reached bsky.** And bsky's failure mode is *worse than a clean |
| 497 | refusal*: because `getRecord` reflects the edit while `getPostThread` does not, the same post will |
| 498 | appear edited in some bsky contexts and stale in others. A user cannot be told "it did not sync" |
| 499 | because partly it did. |
| 500 | |
| 501 | Same rule as §5.3: state what is true per surface, never a single "edited" that means three |
| 502 | different things. |
| 503 | |
| 504 | ### 10.5.5 What is actually missing |
| 505 | |
| 506 | The write path is **complete**: |
| 507 | |
| 508 | - `atproto-store` — `action: 'update'` handled; `prev` recorded (`repo-manager.js:67,99`) |
| 509 | - `favela-web/lib/writer.js:173` — `putRecord(collection, rkey, record, { action = 'update' })` exists |
| 510 | - `hyper-proto-peer` — `applyWrite` handles `update` |
| 511 | |
| 512 | `lib/render.js` has **zero** mentions of editing a post, and FEATURES.md lists delete and |
| 513 | edit-profile as done with no post edit. So this is a **UI feature, not a protocol feature**: an edit |
| 514 | affordance on one's own post, a composer prefilled from the record, and an "edited" marker whose |
| 515 | history is a read over `prev` rather than a stored field. |
| 516 | |
| 517 | Under §5.4's toggle model an edit is unremarkable: the canonical record is replaced, the AP |
| 518 | projection re-emits as `Update`, and the bsky projection is re-materialised — where it will be |
| 519 | ignored, honestly and visibly. |
| 520 | |
| 521 | --- |
| 522 | |
| 523 | ## 11. Open questions |
| 524 | |
| 525 | Honest gaps. This design is not complete until these are closed. |
| 526 | (Editing was one of these; it is now settled in §10.5.) |
| 527 | |
| 528 | 1. ~~**The strip specification (§4.2)** is described but not written.~~ **CLOSED** — |
| 529 | `spec/markdown-strip.md` (220 lines) and `spec/vectors/markdown-strip.json` (36 vectors) were |
| 530 | written afterwards. `test/strip-vectors.test.mjs` validates the vectors themselves; no stripper |
| 531 | exists yet. |
| 532 | |
| 533 | 1b. **What signs a markdown record.** With the canonical record no longer a DAG-CBOR entry in a |
| 534 | signed atproto commit chain (§0), authorship and integrity need their own mechanism — a detached |
| 535 | per-record signature, or a native append-only log of `{rkey, contentHash, sig}` that peers |
| 536 | replicate. Mutability is not an obstacle: the signature covers the current bytes and is |
| 537 | re-issued on edit, exactly as atproto re-signs a commit on edit. **This is the next thing to |
| 538 | settle; everything in §9 (read path) hangs off it.** |
| 539 | 2. **Embed/attachment mapping** across three representations (atproto blob refs, AS2 `attachment`, |
| 540 | markdown image syntax) is not worked through. |
| 541 | 3. ~~**The extension dialect** (§4.1) names candidates but does not pin a set.~~ **CLOSED** — |
| 542 | pinned in `markdown-strip.md` §2.1 (CommonMark 0.31.2 + six named extensions). |
| 543 | 4. **Community/list audience URIs** — the URI form for a favela community room is unspecified. |
| 544 | 5. **`audience` semantics for replies** — does a reply inherit its parent's audience? Almost |
| 545 | certainly yes, but the failure mode of getting it wrong is publishing to the wrong people. |
| 546 | 6. **Whether the AP projection should carry `source.mediaType: text/markdown`** and let Mastodon |
| 547 | render it, or continue sending pre-rendered HTML. Live output currently sends both. |
| 548 | |
| 549 | --- |
| 550 | |
| 551 | ## 12. What this design does *not* claim |
| 552 | |
| 553 | - It does not make `audience` enforceable on atproto. Nothing can. |
| 554 | - It does not remove the double-write. It bounds it. |
| 555 | - It does not improve deliverability, availability or performance. It is a vocabulary decision. |
| 556 | - Its forcing function is thin today: one fediverse follower, one enforcement point. It is |
| 557 | justified by protocol churn and expressiveness, not by present demand. That should be weighed |
| 558 | before implementation, not after. |