OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / 7ef8836

spec: interaction — edges are records, and the subject-naming decision
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7ef8836e5d0a9bc1b3731257a390de78f34a74d0
by plankatron <fleeky@gmail.com> · 2026-09-05T18:50:55.000Z

Changed files (1) +278 -0

spec/interaction.md +278 -0
……@@ -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
8canonical 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
16Every 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
18exactly the condition `design.md` §1 names — *"atproto became favela's canonical P2P path by
19default, not by decision"* — still running in the half nobody specified.
20
21### 0.1 The frame
22
23There is **one log of named records**, and everything else is a projection of it, derived on read
24and 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
33holds its own key. atproto did: it dictated the encoding, the signing and the storage layout, and
34that is the whole of the imbalance this repo exists to correct. The rule is checkable: **if adding
35a 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
43Read 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
79Why 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
89the autobase pointer set stays trivial; but likes are far more numerous than posts, and each one is
90a file plus an overlay registration. A heavy account is tens of thousands of tiny files. That is a
91real operational number and it should be measured before this is locked, not after.
92
93The 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
95reply-is-an-edge unification. §9 #1.
96
97---
98
99## 3. ⛔ Naming the subject — the one irreversible decision
100
101Once records exist, their subject names cannot be rewritten: `record-vocabulary.md` §8 forbids it
102absolutely, 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
106An edge to one of our own records names it **canonically** — `net.favela.post/<rkey>`. The
107projections rewrite it, because §3.1's rkey pairing already makes all three names pure functions of
108each 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
114Stored 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
119When one object is known by two names, that is an `alias` edge (§5) — not a mapping table to
120maintain. 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
123that 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
128the DID from the author position precisely so it can rotate. **So there is currently no way for one
129favela peer to name another favela peer's record natively.** Every native edge would have to fall
130back to `at://<did>/...`, reintroducing exactly what was removed.
131
132This blocks native peer-to-peer interaction and must be settled first. It is **not** settled here.
133Options, 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
148locally, as a workaround for `Undo` needing a stable activity id. It is not a workaround; it is what
149identity 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
153Consequence, to accept deliberately: **like -> unlike -> like is one edge going absent and
154returning, not three events.** If a distinguishable history of that is ever wanted it is a separate
155record kind, not a change here.
156
157### 4.3 Reversal is a tombstone
158
159Un-like, un-repost, un-follow write the existing TOMBSTONE (`repo-base.js:22,76-79`). Absence is
160ambiguous in a replicated log; the tombstone is positive evidence. The AS2 projection of a tombstone
161on an edge is `Undo` of that edge's activity, which is exactly what `projectUndo:210-224` already
162builds — 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
168design already guards against, and edges inherit the guard unchanged: `maxReach(visibility)` is
169computed 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
172projections 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
179Adding a verb is a **spec change**, per the same rule as directives (`library.md` §5). It is not a
180plugin 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
192knows 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
195add entries: `block-not-federated`, `alias-internal`, and `subject-unresolvable` — the last being
196what `projectLike` currently expresses by returning `null` (`as2-project.js:165-168`), which is a
197silence, and §5.3's honesty requirement forbids silences.
198
199---
200
201## 6. Inbound — a claim is not a record
202
203An edge authored elsewhere arrives as a **claim**. It becomes a record in *this* node's view only
204after verification. This is the boundary that "everything is a record" glosses over, and it is also
205the 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`
214already does announce-push discovery, so A pushes to B's topic and B verifies against A's log — a
215claim plus dereference, which is precisely AS2's inbox model. Designing the native mechanism to look
216like 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
219the pointer. An unverified claim must be storable-but-not-a-record, or the inbox is a write amplifier
220for 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
228Bluesky's servers.
229
230Under §0.1 this is not an integration problem. **A notification is a projection of the inbound edges
231whose subject is you** — and because an edge is an edge whatever network carried it, atproto and AP
232inbound land in one surface without being merged. That is exactly what hyper-social #41 §4 and #45
233gap 2 both ask for, and it needs no AppView (#45 §6).
234
235It also makes the read path cheap: `library.md` §8's `ImportedView` already exists to give foreign
236content 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
2541. **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.
2562. **⛔ The native URI scheme** (§3.4). Blocks native peer-to-peer interaction. Nothing else in this
257 document can be built first.
2583. **Inbox admission** (§6) — rate limiting, and how long an unverified claim is retained.
2594. **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.
2635. **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.
2666. **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.