OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social commits / 2969e4a

docs: observed interactions — a file, not the autobee, and why
A like from a stranger is invisible because DESIGN §7.6 puts count-bearing links in Tier C. That rationale is about the ROUND TRIP, not the bytes — and a like record carries nothing its pointer does not, so there was never anything to fetch. Measured: 50,000 interactions encode to 0.50 MB against 2.80 MB naive. The storage decision turns on one property — #61 §3: log blocks are never reclaimed. Anything in the autobee is permanent and replicated in full; sealed segments in the overlay are evictable, which a derived index must be. The count lives in the pointer, so the common read costs no bytes at all. What we store is an OBSERVATION, never an edge: a strangers like is not ours to author. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2969e4aa13072d59bf5f646bc96e2b7497c6bdf8
by plankatron <fleeky@gmail.com> · 2026-09-16T00:07:56.000Z

Changed files (1) +175 -0

docs/OBSERVED-INTERACTIONS.md +175 -0
……@@ -0,0 +1,175 @@
1# Observed interactions — keeping what a stranger did, without authoring it
2
3**Status:** design. The codec is built (`favela-web/lib/interaction-codec.js`, 7 tests) and the drain
4seam exists (`link-drain.js`, `noteInteraction`). Nothing is stored yet.
5**Date:** 2026-09-15
6**Depends on:** `favela-markdown/spec/interaction.md` (edges, §3.2 foreign naming), `EDGE-GRAPH.md`
7(the record graph), `favela-web/DESIGN.md` §7.5–§7.6.1 (tiering, the derived-index invariant).
8**Answers:** why a like from a Bluesky stranger is invisible today, and where it should live instead.
9
10---
11
12## 1. The problem, and why it is not the one §7.6 solved
13
14A like on our post from someone with no favela feed is invisible. `DESIGN.md` §7.6 puts likes,
15reposts and follows in **Tier C — never retained** — because *"a post with 50k likes must not become
1650k retained records"*.
17
18⛔ **That is true of the RECORD and the record is not what we want.** §7.6.1 names the real cost: Tier
19B2 is *fetch + secp256k1 verify + a file, per record*. Fifty thousand round trips is what is
20unaffordable. The bytes never were.
21
22⛔ **AND A LIKE'S RECORD CARRIES NOTHING ITS POINTER DOES NOT.** Measured on a real one from wj62:
23
24```json
25{"$type":"app.bsky.feed.like","subject":{"cid":"bafyrei…","uri":"at://…/3mpqegxhgn22n"},"createdAt":"2026-07-03T15:25:28.096Z"}
26```
27
28`$type` is the collection. `subject.uri` is **our own post** — the key, not a per-like fact.
29`subject.cid` addresses a record we do not keep. The only per-like facts are **who** and **when**, and
30Spacedust already ships exactly that tuple with no fetch and no verification. We were receiving it for
31free and discarding it.
32
33⇒ The change is not a new tier. It is removing an exclusion: **absorb B1 for count-bearing links, and
34never fetch B2 — not as a budget compromise but because there is nothing there to fetch.**
35
36## 2. ⛔ What we are storing is an OBSERVATION, not an edge
37
38`interaction.md` §0.1: *"The graph needs no engine. It is what you get when records name other
39records."* That holds for every edge a favela peer authors — the record is in **their** feed, and the
40graph is names.
41
42A stranger's like is not ours to author. Writing one into our own log would be authoring as them,
43which hyper-social #61 §4 removed the room primitive to avoid, and which
44`record-vocabulary.md` §8 forbids in the general case (*"never re-encode a record you did not
45author"*).
46
47So what we hold is: **we observed that this actor did this to this record of ours, at this time.**
48
49⚠ That makes it a DERIVED INDEX, and `DESIGN.md` §7.5's invariant binds it absolutely: it may never
50gate a write, a signature, or a serve decision. Lose the whole thing and the system is still correct,
51merely less informed — which is the test §7.5 demands, and this passes it.
52
53⚠ It is also why `EDGE-GRAPH.md`'s **foreign object** node type is load-bearing rather than a
54courtesy: a node named by an `at://` or `https://` URI *we do not hold*. §3.2's "a name is enough" is
55what lets the graph point at something that will never be a file here.
56
57## 3. ⛔ A FILE, not the autobee — and the reason is permanence
58
59The substrate rule is already stated twice in code: `PointerSubstrate` for records,
60`BlobPointerStore` for blobs, both **pointers in the log, bytes in the overlay**. `feed-base.js` says
61why: *"Bytes never enter the log. `put` writes the record to the store first and appends only the
62pointer, so the core carries ~50 bytes per record instead of the record."*
63
64The deciding property is hyper-social #61 §3 item 5:
65
66> **log blocks are never reclaimed**
67
68Anything in the autobee is permanent, replicated in full to every peer holding the feed, and cannot be
69evicted — ever, including long after the post stopped mattering.
70
71| | autobee entries | sealed files + pointer |
72|---|---|---|
73| 50,000 likes | ~50,000 ops, 2.5–5 MB of log | **0.50 MB** of bytes, **50** log entries |
74| reclaimable | never | yes — it is a derived index |
75| replicated to co-owners | in full, always | pointer always, bytes on demand |
76| query | ordered range scan | decode a segment |
77
78⚠ **The honest counter-argument.** autobee gives ordered range reads, so "who liked this, paged"
79needs no blob decode. If interaction reads ever become a hot path rather than an occasional one, that
80is the reason to revisit — but it is a measurement nobody has, and the permanence cost is certain.
81
82★ **The count goes in the POINTER.** Four bytes, in the log entry, answering the query almost every
83read actually makes. "12 likes" then costs no bytes at all, and a segment is fetched only when someone
84asks *who*.
85
86## 4. Layout
87
88```
89log __seen__/<subject> -> { segments: [{ $oid, size, n, until }], count, kinds }
90 __actors__ -> { $oid, size } one shared intern table
91bytes (overlay, content-addressed — sealed segments and the actor table)
92```
93
94- **`<subject>`** is one of our own records, named canonically (`interaction.md` §3.1). An observation
95 about somebody else's post is not ours to keep and is out of scope (§7).
96- **`__`-prefixed**, so `RepoBase.splitKey` and `PointerSubstrate` both refuse it as a record and it
97 can never reach a signed commit. Same reservation `__blob__/<cid>` already relies on.
98- **`count`** is the cheap answer. **`kinds`** breaks it down (like / repost / follow) so a UI needs no
99 segment to render three numbers.
100
101### 4.1 Sealing
102
103⛔ **A segment is sealed at N tuples and never rewritten.** Rewriting one blob per incoming like would
104re-hash and re-replicate the whole thing every time — the obvious wrong turn, and the reason this is
105chunked at all. Sealed segments are immutable, so they dedupe, replicate and archive like any other
106content-addressed file; only the small tail is ever rewritten.
107
108⚠ N is not yet chosen (§8). It trades tail-rewrite cost against segment count in the pointer.
109
110### 4.2 The codec — built and measured
111
112`favela-web/lib/interaction-codec.js`. A `did:plc` is **15 bytes wearing 32**: the suffix is always 24
113characters over exactly `234567abcdefghijklmnopqrstuvwxyz` — RFC 4648 base32, lowercase — so 24 × 5 =
114120 bits, no padding. Verified against every DID in this fleet, not taken from the spec.
115
116```
11750,000 interactions on one post
118 naive strings 2.80 MB 32-byte DID ascii + 24-byte ISO timestamp
119 this codec 0.50 MB 15-byte DID, interned, varint delta timestamps
120```
121
122⛔ **Anything that is not a did:plc is TAGGED and stored verbatim**, never coerced. A DID that decoded
123to a different DID would attribute a like to the wrong person, which is worse than storing 40 bytes.
124`did:web` and fediverse actor URIs go through the text tag.
125
126⚠ Timestamps are delta-encoded after sorting, because likes arrive in bursts and a varint spends one
127byte on a small delta. The codec sorts rather than trusting the caller — unsorted input would produce
128large deltas and silently cost more.
129
130## 5. Reaching the phone
131
132The pointer converges through the log the device already replicates; the bytes come by `oid` over
133hyper-overlay, on demand. A handset therefore learns *that* twelve people liked its post, and *who*,
134without running an ingest — which is what it cannot do (`#71` §9: Spacedust "drops a subscriber that
135lags and a handset under doze will lag").
136
137⚠ This is the shape the session of 2026-09-15 first built as `__inbound__` pointers **in the wrong
138package** (atproto-store, on atproto addressing, in hpp's log) and reverted. Same mechanism, favela
139side, tuples instead of per-record pointers.
140
141## 6. What this does not change
142
143- **Not authority.** Every authoritative edge still lives in its author's feed or on their home
144 network. §7.5's invariant holds: this gates nothing.
145- **Not the record graph.** `EDGE-GRAPH.md` renders records naming records; this supplies the foreign
146 nodes it already has a type for.
147- **Not the fetch.** Zero fetches, zero verifications — `link-drain.js` hands the sink a pointer and
148 nothing else, asserted in its tests.
149- **Not naming.** Foreign subjects are named by origin URI per §3.2, decided and unchanged.
150
151## 7. Deliberately out of scope
152
153- **Observations about posts that are not ours.** The bound in §7.6 is "one hop from our own records",
154 and keeping the world's likes on the world's posts is the unbounded crawl it exists to refuse.
155- **Counts that claim to be totals.** They are what we saw. `#45` §2 already commits to per-observer
156 counts and `interaction.md` §9 item 6 says they will disagree between peers. Rendering ours beside a
157 network count is honest; summing them is not.
158- **Replacing Constellation.** It answers about the whole network; this answers about us. A cold
159 archive still backfills from it.
160- **Retention of foreign RECORDS** (replies, quotes). That is Tier B2 and already specified; this is
161 only the count-bearing half §7.6 excluded.
162
163## 8. Open
164
1651. **Segment size N.** Tail-rewrite cost against pointer size. Wants a measurement of real inbound
166 rates, which at current volume is single digits — so any N is fine and the honest answer is to pick
167 one and revisit if it ever matters.
1682. **One actor table, or one per subject?** Shared is ~3x smaller (the same people like many of your
169 posts) and makes one archive file depend on another, which an archive has to answer for. The codec
170 currently interns per-subject.
1713. **Un-like.** A tombstone tuple, or rewrite the tail segment? Sealed segments say tombstone; §4.3 of
172 `interaction.md` already says reversal is a tombstone everywhere else.
1734. **Who writes it.** The ingest runs where a socket can stay up. That is not the phone, and it is
174 currently favela-web, which the android boundary test forbids the handset from importing. The
175 writer's home is an open placement question and should be settled before this is built.