| … | … | @@ -0,0 +1,278 @@ |
| 1 | # Interaction — edges, subjects, and the inbox |
| 2 | |
| 3 | **Status:** design. Nothing built. |
| 4 | **Date:** 2026-09-05 |
| 5 | **Depends on:** `spec/design.md` (the canonical-path decision), `spec/library.md` (the codec surface), |
| 6 | `spec/record-vocabulary.md` (`net.favela.post`), `favela-identity/spec/identity.md` (users, bridges). |
| 7 | **Closes:** the gap that `design.md` §1 left open — it demoted atproto for **posts** and left it |
| 8 | canonical for **everything else**. |
| 9 | |
| 10 | --- |
| 11 | |
| 12 | ## 0. The governing decision |
| 13 | |
| 14 | **An interaction is a record. It is named, it is authored, and both networks are projections of it.** |
| 15 | |
| 16 | Every interaction in this stack is today *natively an atproto record*: `projectLike` takes an |
| 17 | `app.bsky.feed.like` and derives AS2 from it (`ap-bridge/lib/as2-project.js:164-177`). That is |
| 18 | exactly the condition `design.md` §1 names — *"atproto became favela's canonical P2P path by |
| 19 | default, not by decision"* — still running in the half nobody specified. |
| 20 | |
| 21 | ### 0.1 The frame |
| 22 | |
| 23 | There is **one log of named records**, and everything else is a projection of it, derived on read |
| 24 | and never replicated. |
| 25 | |
| 26 | - **The log** — the shared StateBase autobase: `name -> { $oid, size }`. Names are |
| 27 | `<collection>/<rkey>`. **Authorship lives here** and nowhere else. |
| 28 | - **The bytes** — content-addressed files in hyper-overlay. A record's content. |
| 29 | - **A projection** — a pure function from records to some other representation: a timeline, a |
| 30 | notification list, `app.bsky.feed.like`, an AS2 `Like`. |
| 31 | |
| 32 | ⛔ **A projection must never constrain the log.** ActivityPub never did — it derives on read and |
| 33 | holds its own key. atproto did: it dictated the encoding, the signing and the storage layout, and |
| 34 | that is the whole of the imbalance this repo exists to correct. The rule is checkable: **if adding |
| 35 | a network changes the record, it was done wrong.** |
| 36 | |
| 37 | **The graph needs no engine.** It is what you get when records name other records. |
| 38 | |
| 39 | --- |
| 40 | |
| 41 | ## 1. Verified vs assumed |
| 42 | |
| 43 | Read in source on 2026-09-05. Nothing outside this table is verified. |
| 44 | |
| 45 | | claim | where | |
| 46 | |---|---| |
| 47 | | Likes/reposts/follows already exist as atproto records across the stack (14 / 10 / 10 references) | grep of `ap-bridge/lib`, `hyper-proto-peer/lib`, `favela-web/lib`, `atproto-room/lib`, `atproto-store/lib` | |
| 48 | | `app.bsky.feed.like -> Like`, `feed.repost -> Announce`, un-X -> `Undo` of the original activity | `as2-project.js:164,187,210` | |
| 49 | | Un-X needs the ORIGINAL activity's id, *"which is why activity ids are derived from the rkey rather than a counter: the same record always yields the same id"* | `as2-project.js:207-209` | |
| 50 | | An `Announce` not addressed to Public + followers *"is accepted, returns 2xx and appears in nobody's timeline"* | `as2-project.js:182-183` | |
| 51 | | Activity and object ids are pure functions of the rkey | `records.js:27,38` | |
| 52 | | ⚠ That purity *"stops being a function the moment we front more than one DID"* | `records.js:72` | |
| 53 | | Inbound AP already routes Follow/Like/Announce/Create/Update/Delete/Undo/Accept | `router.js:194-206,239,291` | |
| 54 | | Notifications are proxied to **bsky's AppView** with a service-auth token | `hyper-proto-peer/lib/notifications.js:1-27` | |
| 55 | | A delete is a positive TOMBSTONE, because absence is ambiguous in a replicated log | `repo-base.js:22,76-79`, `pointer-substrate.js:60-64` | |
| 56 | | Reserved `__`-prefixed collections can never be materialized into a commit | `repo-base.js:41-48` | |
| 57 | | The signer takes an in-memory `[{collection, rkey, record}]` and encodes it itself | `atproto-room/lib/signer.js:18-33` | |
| 58 | | A mention target is stored as a DID in canonical bytes | `record-vocabulary.md:166,208` | |
| 59 | | One rkey, three names: canonical / bsky projection / AP object | `record-vocabulary.md` §3.1 | |
| 60 | |
| 61 | --- |
| 62 | |
| 63 | ## 2. What an edge is |
| 64 | |
| 65 | ``` |
| 66 | (author, verb, subject, time) |
| 67 | ``` |
| 68 | |
| 69 | - **author** — a `User` (`identity.md` §5), never a DID. Resolved to a DID at projection time. |
| 70 | - **verb** — from the pinned set in §5. Not extensible without a spec change. |
| 71 | - **subject** — a name (§3). The thing acted upon. |
| 72 | - **time** — a value, **not** part of identity (§4.2). |
| 73 | |
| 74 | ### 2.1 An edge is a record with an empty body |
| 75 | |
| 76 | **Decision to take, not yet taken — see §9 #1.** The recommendation is that an edge is an ordinary |
| 77 | `favela-markdown` record: JSON frontmatter, empty body. |
| 78 | |
| 79 | Why that is the simple answer rather than the silly one: |
| 80 | |
| 81 | - **One codec.** One parse, one serialize, one round-trip law (`library.md` §7). No second format, |
| 82 | no second `EXT`, no second decode path in `getRaw`. |
| 83 | - **A reply stops being a special case.** A reply is an edge *with* a body. Edge and post become the |
| 84 | same kind, differing only in whether the body is empty — which resolves `design.md` §8 #2 (the |
| 85 | markdown-native spelling of threading) by making it not a separate question. |
| 86 | - **Human-readable on disk**, which is the property the whole pivot exists to buy. |
| 87 | |
| 88 | ⚠ **The cost is file count, not bytes.** Records average ~132 bytes (`record-vocabulary.md` §7), so |
| 89 | the autobase pointer set stays trivial; but likes are far more numerous than posts, and each one is |
| 90 | a file plus an overlay registration. A heavy account is tens of thousands of tiny files. That is a |
| 91 | real operational number and it should be measured before this is locked, not after. |
| 92 | |
| 93 | The alternative — an edge as a pointer-valued log entry with no bytes — is already expressible |
| 94 | (a tombstone is exactly that, `pointer-substrate.js:60-63`). It is cheaper and it costs the |
| 95 | reply-is-an-edge unification. §9 #1. |
| 96 | |
| 97 | --- |
| 98 | |
| 99 | ## 3. ⛔ Naming the subject — the one irreversible decision |
| 100 | |
| 101 | Once records exist, their subject names cannot be rewritten: `record-vocabulary.md` §8 forbids it |
| 102 | absolutely, and §5 of `identity.md` forbids embedding a DID for the same reason. |
| 103 | |
| 104 | ### 3.1 Our own records: use the canonical name |
| 105 | |
| 106 | An edge to one of our own records names it **canonically** — `net.favela.post/<rkey>`. The |
| 107 | projections rewrite it, because §3.1's rkey pairing already makes all three names pure functions of |
| 108 | each other. No mapping table, and the AP object URI keeps resolving. |
| 109 | |
| 110 | ### 3.2 Foreign records: the origin URI, as observed |
| 111 | |
| 112 | `at://did:plc:xyz/app.bsky.feed.post/3k...` · `https://mastodon.social/users/x/statuses/1234` |
| 113 | |
| 114 | Stored verbatim, with the network it was observed on. No import ceremony, no local subject registry |
| 115 | — **a name is enough**, and the same name always denotes the same thing. |
| 116 | |
| 117 | ### 3.3 Cross-network identity is an EDGE, not a table |
| 118 | |
| 119 | When one object is known by two names, that is an `alias` edge (§5) — not a mapping table to |
| 120 | maintain. Ours are *derived* (§3.1); foreign ones are *asserted* when observed on both networks. |
| 121 | |
| 122 | **A DID rotation therefore adds an edge; it never rewrites a record.** This is the same mechanism |
| 123 | that fixes the mention problem in `record-vocabulary.md:166,208`, rather than a special case for it. |
| 124 | |
| 125 | ### 3.4 ⛔ OPEN — favela has no native URI scheme |
| 126 | |
| 127 | `at://` is atproto's. A peer's own posts are addressed by DID, and `identity.md` §5 has just removed |
| 128 | the DID from the author position precisely so it can rotate. **So there is currently no way for one |
| 129 | favela peer to name another favela peer's record natively.** Every native edge would have to fall |
| 130 | back to `at://<did>/...`, reintroducing exactly what was removed. |
| 131 | |
| 132 | This blocks native peer-to-peer interaction and must be settled first. It is **not** settled here. |
| 133 | Options, none chosen: |
| 134 | |
| 135 | | | shape | problem | |
| 136 | |---|---|---| |
| 137 | | **a** | `favela://<userId>/<collection>/<rkey>` | `userId` is local to one registry; not globally resolvable (`identity.md` §9) | |
| 138 | | **b** | `favela://<identity-root-pubkey>/...` | globally unique and rotation-stable, but names a key, not an account | |
| 139 | | **c** | keep `at://<did>/...` and rely on alias edges to survive rotation | resolution becomes a graph walk; ambiguous if two aliases conflict | |
| 140 | |
| 141 | --- |
| 142 | |
| 143 | ## 4. Identity, reversal and time |
| 144 | |
| 145 | ### 4.1 An edge's identity is its content |
| 146 | |
| 147 | `(author, verb, subject)` — content-addressed. `as2-project.js:207-209` already discovered this |
| 148 | locally, as a workaround for `Undo` needing a stable activity id. It is not a workaround; it is what |
| 149 | identity by content means, and it should be the default rather than a derivation trick. |
| 150 | |
| 151 | ### 4.2 Time is a value, not identity |
| 152 | |
| 153 | Consequence, to accept deliberately: **like -> unlike -> like is one edge going absent and |
| 154 | returning, not three events.** If a distinguishable history of that is ever wanted it is a separate |
| 155 | record kind, not a change here. |
| 156 | |
| 157 | ### 4.3 Reversal is a tombstone |
| 158 | |
| 159 | Un-like, un-repost, un-follow write the existing TOMBSTONE (`repo-base.js:22,76-79`). Absence is |
| 160 | ambiguous in a replicated log; the tombstone is positive evidence. The AS2 projection of a tombstone |
| 161 | on an edge is `Undo` of that edge's activity, which is exactly what `projectUndo:210-224` already |
| 162 | builds — and it works because §4.1 makes the id reproducible. |
| 163 | |
| 164 | ### 4.4 Visibility and reach apply to edges |
| 165 | |
| 166 | `as2-project.js:182-183` is the warning, already written down: an `Announce` addressed wrongly is |
| 167 | *accepted, 2xx, and appears in nobody's timeline*. That is the same silent-success shape the post |
| 168 | design already guards against, and edges inherit the guard unchanged: `maxReach(visibility)` is |
| 169 | computed once in the codec (`library.md` §6.1) and refuses the impossible. |
| 170 | |
| 171 | **`reach` on an edge names which projections run** — not which protocols are true. A record with no |
| 172 | projections is local-only, which unifies the two ways that is expressible today |
| 173 | (`net.favela.local.*` as a collection, or an empty reach). |
| 174 | |
| 175 | --- |
| 176 | |
| 177 | ## 5. The verb set — pinned |
| 178 | |
| 179 | Adding a verb is a **spec change**, per the same rule as directives (`library.md` §5). It is not a |
| 180 | plugin point and there is no registry. |
| 181 | |
| 182 | | verb | subject | bsky | fediverse | |
| 183 | |---|---|---|---| |
| 184 | | `like` | a record | `app.bsky.feed.like` | `Like` | |
| 185 | | `repost` | a record | `app.bsky.feed.repost` | `Announce` (Public + followers, §4.4) | |
| 186 | | `follow` | an actor | `app.bsky.graph.follow` | `Follow` -> `Accept` | |
| 187 | | `block` | an actor | `app.bsky.graph.block` | `Block` (not federated by Mastodon) | |
| 188 | | `reply` | a record | `app.bsky.feed.post` + `reply:{root,parent}` | `inReplyTo` | |
| 189 | | `alias` | a record or actor | **none** — internal (§3.3) | **none** — internal | |
| 190 | |
| 191 | `reply` is the edge that is also a document (§2.1). `alias` never projects; it is how this node |
| 192 | knows two names denote one thing, and asserting it is a claim by its author like any other. |
| 193 | |
| 194 | **Every verb states its loss.** `project()` returns `{output, loss[]}` (`library.md` §4), and these |
| 195 | add entries: `block-not-federated`, `alias-internal`, and `subject-unresolvable` — the last being |
| 196 | what `projectLike` currently expresses by returning `null` (`as2-project.js:165-168`), which is a |
| 197 | silence, and §5.3's honesty requirement forbids silences. |
| 198 | |
| 199 | --- |
| 200 | |
| 201 | ## 6. Inbound — a claim is not a record |
| 202 | |
| 203 | An edge authored elsewhere arrives as a **claim**. It becomes a record in *this* node's view only |
| 204 | after verification. This is the boundary that "everything is a record" glosses over, and it is also |
| 205 | the spam story. |
| 206 | |
| 207 | | source | arrives as | verified by | |
| 208 | |---|---|---| |
| 209 | | a favela peer | a push on the subject's swarm topic | dereferencing the author's log — the pointer is in an attested writer's core | |
| 210 | | the fediverse | an HTTP inbox POST | HTTP Signatures, already implemented (`http-signature.js`, `remote-key.js`) | |
| 211 | | bsky | a firehose frame | the signed commit | |
| 212 | |
| 213 | **The native path is the AP path's shape.** A DID is already a swarm topic and `atproto-did-peer` |
| 214 | already does announce-push discovery, so A pushes to B's topic and B verifies against A's log — a |
| 215 | claim plus dereference, which is precisely AS2's inbox model. Designing the native mechanism to look |
| 216 | like AP's makes `bridge:fediverse` close to a pass-through and leaves `bridge:bsky` as the lossy one. |
| 217 | |
| 218 | ⚠ **Verification is not free.** Dereferencing means replicating enough of the author's log to check |
| 219 | the pointer. An unverified claim must be storable-but-not-a-record, or the inbox is a write amplifier |
| 220 | for anyone who can find the topic. Rate limiting and the storable-claim lifetime are **open** (§9 #3). |
| 221 | |
| 222 | --- |
| 223 | |
| 224 | ## 7. Notifications are a projection |
| 225 | |
| 226 | `hyper-proto-peer/lib/notifications.js:1-27` proxies `app.bsky.notification.listNotifications` to |
| 227 | **bsky's AppView**. Today a favela user learns that someone interacted with them by asking |
| 228 | Bluesky's servers. |
| 229 | |
| 230 | Under §0.1 this is not an integration problem. **A notification is a projection of the inbound edges |
| 231 | whose subject is you** — and because an edge is an edge whatever network carried it, atproto and AP |
| 232 | inbound land in one surface without being merged. That is exactly what hyper-social #41 §4 and #45 |
| 233 | gap 2 both ask for, and it needs no AppView (#45 §6). |
| 234 | |
| 235 | It also makes the read path cheap: `library.md` §8's `ImportedView` already exists to give foreign |
| 236 | content one shape, and an inbound edge is the same idea applied to relationships. |
| 237 | |
| 238 | --- |
| 239 | |
| 240 | ## 8. What this does not change |
| 241 | |
| 242 | - **Not the substrate.** Names in the autobase, bytes in the overlay, tombstones, `$oid` integrity — |
| 243 | all unchanged, exactly as `design.md` §3 claims for posts. |
| 244 | - **Not authorship.** An edge is authored the same way a post is: the pointer lands in an |
| 245 | attestation-gated multi-writer autobase (`design.md` §4). No new signature. |
| 246 | - **Not delivery.** Projection is pure; posting to inboxes with retries, backoff and a dead-letter |
| 247 | queue (hyper-social #29 item 3) is stateful and node-local. A bridge is **two** things — a pure |
| 248 | projection and a stateful courier — and conflating them makes the purity claim false. |
| 249 | |
| 250 | --- |
| 251 | |
| 252 | ## 9. Open |
| 253 | |
| 254 | 1. **Edge as empty-body record vs. pointer-valued log entry** (§2.1). Measure the file count for a |
| 255 | realistic like volume before locking it. |
| 256 | 2. **⛔ The native URI scheme** (§3.4). Blocks native peer-to-peer interaction. Nothing else in this |
| 257 | document can be built first. |
| 258 | 3. **Inbox admission** (§6) — rate limiting, and how long an unverified claim is retained. |
| 259 | 4. **Where an edge's `reach` defaults from.** A like on a public post is presumably public; a like on |
| 260 | a followers-only post must not project. Almost certainly inherited from the subject, but the |
| 261 | failure mode is publishing to the wrong people, so it must be explicit — the same reasoning as |
| 262 | `record-vocabulary.md` §11 #5. |
| 263 | 5. **Multi-DID id purity.** `records.js:72` warns the AP id functions stop being pure *"the moment we |
| 264 | front more than one DID"*, and favela DIDs are co-owned communities. Edges make this sharper, |
| 265 | because an edge names a subject that may belong to another fronted DID. |
| 266 | 6. **Counts.** Like/repost counts are per-node derived values, never records. They will disagree |
| 267 | between peers. `favela-web` already renders local and network follower counts without summing |
| 268 | them (hyper-social #45 §2) — edges should inherit that honesty rather than inventing a total. |
| 269 | |
| 270 | --- |
| 271 | |
| 272 | ## 10. What this does not claim |
| 273 | |
| 274 | - It does not make edges enforceable on atproto. Nothing can; atproto has no addressing. |
| 275 | - It does not solve discovery (hyper-social #45 gap 1). That is a separate product problem and |
| 276 | nothing here helps it. |
| 277 | - It does not specify the courier — retries, backoff, dead-letter — only that it is not a projection. |
| 278 | - It does not settle the naming scheme, which is the one thing that cannot be changed later. |