| … | … | @@ -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 |
| 4 | seam 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 | |
| 14 | A like on our post from someone with no favela feed is invisible. `DESIGN.md` §7.6 puts likes, |
| 15 | reposts and follows in **Tier C — never retained** — because *"a post with 50k likes must not become |
| 16 | 50k 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 |
| 19 | B2 is *fetch + secp256k1 verify + a file, per record*. Fifty thousand round trips is what is |
| 20 | unaffordable. 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 |
| 30 | Spacedust already ships exactly that tuple with no fetch and no verification. We were receiving it for |
| 31 | free and discarding it. |
| 32 | |
| 33 | ⇒ The change is not a new tier. It is removing an exclusion: **absorb B1 for count-bearing links, and |
| 34 | never 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 |
| 39 | records."* That holds for every edge a favela peer authors — the record is in **their** feed, and the |
| 40 | graph is names. |
| 41 | |
| 42 | A stranger's like is not ours to author. Writing one into our own log would be authoring as them, |
| 43 | which 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 |
| 45 | author"*). |
| 46 | |
| 47 | So 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 |
| 50 | gate a write, a signature, or a serve decision. Lose the whole thing and the system is still correct, |
| 51 | merely 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 |
| 54 | courtesy: a node named by an `at://` or `https://` URI *we do not hold*. §3.2's "a name is enough" is |
| 55 | what 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 | |
| 59 | The 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 |
| 61 | why: *"Bytes never enter the log. `put` writes the record to the store first and appends only the |
| 62 | pointer, so the core carries ~50 bytes per record instead of the record."* |
| 63 | |
| 64 | The deciding property is hyper-social #61 §3 item 5: |
| 65 | |
| 66 | > **log blocks are never reclaimed** |
| 67 | |
| 68 | Anything in the autobee is permanent, replicated in full to every peer holding the feed, and cannot be |
| 69 | evicted — 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" |
| 79 | needs no blob decode. If interaction reads ever become a hot path rather than an occasional one, that |
| 80 | is 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 |
| 83 | read actually makes. "12 likes" then costs no bytes at all, and a segment is fetched only when someone |
| 84 | asks *who*. |
| 85 | |
| 86 | ## 4. Layout |
| 87 | |
| 88 | ``` |
| 89 | log __seen__/<subject> -> { segments: [{ $oid, size, n, until }], count, kinds } |
| 90 | __actors__ -> { $oid, size } one shared intern table |
| 91 | bytes (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 |
| 104 | re-hash and re-replicate the whole thing every time — the obvious wrong turn, and the reason this is |
| 105 | chunked at all. Sealed segments are immutable, so they dedupe, replicate and archive like any other |
| 106 | content-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 |
| 113 | characters over exactly `234567abcdefghijklmnopqrstuvwxyz` — RFC 4648 base32, lowercase — so 24 × 5 = |
| 114 | 120 bits, no padding. Verified against every DID in this fleet, not taken from the spec. |
| 115 | |
| 116 | ``` |
| 117 | 50,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 |
| 123 | to 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 |
| 127 | byte on a small delta. The codec sorts rather than trusting the caller — unsorted input would produce |
| 128 | large deltas and silently cost more. |
| 129 | |
| 130 | ## 5. Reaching the phone |
| 131 | |
| 132 | The pointer converges through the log the device already replicates; the bytes come by `oid` over |
| 133 | hyper-overlay, on demand. A handset therefore learns *that* twelve people liked its post, and *who*, |
| 134 | without running an ingest — which is what it cannot do (`#71` §9: Spacedust "drops a subscriber that |
| 135 | lags 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 |
| 138 | package** (atproto-store, on atproto addressing, in hpp's log) and reverted. Same mechanism, favela |
| 139 | side, 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 | |
| 165 | 1. **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. |
| 168 | 2. **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. |
| 171 | 3. **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. |
| 173 | 4. **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. |