Skip to main content

didbot_policy_records/
named.rs

1//! Which accounts a record names.
2//!
3//! A write names somebody whenever it carries their DID: a reply's parent
4//! and root, a quote embed, a mention facet, the subject of a follow, a
5//! like or a repost, the parent on a registration. `plan/ai-preference.md`
6//! asks the question this module answers — *given a record, which DIDs does
7//! it name?* — because a rule about being named needs the list before it
8//! can look anybody up.
9//!
10//! # Why this walks the record instead of holding a table per collection
11//!
12//! A table of "these fields of these collections carry a DID" is a list
13//! that goes out of date on its own. A collection nobody added a row for
14//! names nobody, silently, and so does a field added to a lexicon after the
15//! row was written — and both failures point the same way: a write that
16//! should have been checked is not. Walking the record instead makes the
17//! answer structural. There is no row to forget, a new lexicon is covered
18//! the day it exists, and a field that starts carrying a DID is covered by
19//! the same code that covered the old one.
20//!
21//! The cost is that it names somebody a narrower reading would not: a DID
22//! in a field that is about them rather than addressed to them still
23//! counts. That is the direction to err in for a rule about not naming
24//! people, and it is the reading `plan/ai-preference.md` settles on.
25
26use serde_json::Value;
27
28/// How deep into a record this walks.
29///
30/// **Reasonable range: 4 to 32.** A record is already bounded by its
31/// lexicon and by the store's own limits, so this is a second bound rather
32/// than the one that matters: it stops a pathological `unknown` field from
33/// costing a walk proportional to whatever somebody nested inside it. Ten
34/// clears every shape this project's own lexicons and `app.bsky.*` reach —
35/// a quote of a quote's embedded record is six.
36pub const MAX_DEPTH: usize = 10;
37
38/// Every account `record` names, by DID, in the order they are first met
39/// and without repeats.
40///
41/// A string is a DID if it is one, and an `at://` URI names the DID in its
42/// authority. `$type` is never read: it is an NSID, not an account, and a
43/// lexicon named after something that parses as a DID would otherwise name
44/// it on every record.
45#[must_use]
46pub fn named_accounts(record: &Value) -> Vec<String> {
47    let mut found = Vec::new();
48    walk(record, 0, &mut found);
49    found
50}
51
52fn walk(value: &Value, depth: usize, found: &mut Vec<String>) {
53    if depth > MAX_DEPTH {
54        return;
55    }
56    match value {
57        Value::String(text) => {
58            if let Some(did) = did_in(text) {
59                if !found.iter().any(|held| held == did) {
60                    found.push(did.to_owned());
61                }
62            }
63        }
64        Value::Array(items) => {
65            for item in items {
66                walk(item, depth + 1, found);
67            }
68        }
69        Value::Object(fields) => {
70            for (name, field) in fields {
71                if name == "$type" {
72                    continue;
73                }
74                walk(field, depth + 1, found);
75            }
76        }
77        _ => {}
78    }
79}
80
81/// The handle `record` itself calls each account it mentions by, as
82/// `(did, handle)` pairs in the order they are first met.
83///
84/// A mention is a facet: an object holding a `text` string and a `facets`
85/// list, each facet a byte range of that text and features naming a DID.
86/// The span a feature's facet covers, less its leading `@`, is the handle
87/// the writer used for that DID, when it is one. The writer chose it and
88/// nothing checks that the DID answers to it, so it is only ever used to
89/// tell the writer who their own record named. Like [`named_accounts`],
90/// this reads the shape and never `$type`.
91#[must_use]
92pub fn named_handles(record: &Value) -> Vec<(String, String)> {
93    let mut found = Vec::new();
94    walk_handles(record, 0, &mut found);
95    found
96}
97
98fn walk_handles(value: &Value, depth: usize, found: &mut Vec<(String, String)>) {
99    if depth > MAX_DEPTH {
100        return;
101    }
102    match value {
103        Value::Array(items) => {
104            for item in items {
105                walk_handles(item, depth + 1, found);
106            }
107        }
108        Value::Object(fields) => {
109            if let (Some(Value::String(text)), Some(Value::Array(facets))) =
110                (fields.get("text"), fields.get("facets"))
111            {
112                for facet in facets {
113                    mentioned(text, facet, found);
114                }
115            }
116            for (name, field) in fields {
117                if name != "$type" {
118                    walk_handles(field, depth + 1, found);
119                }
120            }
121        }
122        _ => {}
123    }
124}
125
126/// The `(did, handle)` pairs one facet over `text` carries.
127fn mentioned(text: &str, facet: &Value, found: &mut Vec<(String, String)>) {
128    let index = facet.get("index");
129    let start = index
130        .and_then(|i| i.get("byteStart"))
131        .and_then(Value::as_u64);
132    let end = index.and_then(|i| i.get("byteEnd")).and_then(Value::as_u64);
133    let (Some(start), Some(end)) = (start, end) else {
134        return;
135    };
136    let (Ok(start), Ok(end)) = (usize::try_from(start), usize::try_from(end)) else {
137        return;
138    };
139    let Some(span) = text.get(start..end) else {
140        return;
141    };
142    let Some(handle) = span.strip_prefix('@') else {
143        return;
144    };
145    if didbot_identity::validate_handle(handle).is_err() {
146        return;
147    }
148    let Some(Value::Array(features)) = facet.get("features") else {
149        return;
150    };
151    for feature in features {
152        let Some(did) = feature.get("did").and_then(Value::as_str) else {
153            continue;
154        };
155        if didbot_identity::validate_did(did).is_err() || found.iter().any(|(held, _)| held == did)
156        {
157            continue;
158        }
159        found.push((did.to_owned(), handle.to_owned()));
160    }
161}
162
163/// The DID a string is, or the DID an `at://` URI's authority is.
164fn did_in(text: &str) -> Option<&str> {
165    let candidate = match text.strip_prefix("at://") {
166        Some(rest) => rest.split('/').next().unwrap_or(rest),
167        None => text,
168    };
169    didbot_identity::validate_did(candidate)
170        .is_ok()
171        .then_some(candidate)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use serde_json::json;
178
179    /// A mention's handle is the text its facet spans, and only when that
180    /// text is `@` and a handle: a link facet over a URL names nobody.
181    #[test]
182    fn a_mention_names_its_account_by_the_handle_its_text_spans() {
183        let post = json!({
184            "text": "hi @wren.example, see https://x.example",
185            "facets": [
186                {
187                    "index": { "byteStart": 3, "byteEnd": 16 },
188                    "features": [{ "$type": "app.bsky.richtext.facet#mention", "did": "did:web:wren.example" }],
189                },
190                {
191                    "index": { "byteStart": 22, "byteEnd": 39 },
192                    "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://x.example" }],
193                },
194                {
195                    "index": { "byteStart": 0, "byteEnd": 2 },
196                    "features": [{ "did": "did:web:not-a-handle.example" }],
197                },
198            ],
199        });
200        assert_eq!(
201            named_handles(&post),
202            [("did:web:wren.example".to_owned(), "wren.example".to_owned())]
203        );
204    }
205
206    /// One post can name people four ways at once, and each way is a DID
207    /// this has to find: a reply's two parents, a quote, and a mention.
208    #[test]
209    fn a_post_names_its_parents_its_quote_and_its_mentions() {
210        let post = json!({
211            "$type": "app.bsky.feed.post",
212            "text": "@wren.example",
213            "createdAt": "2026-09-11T00:00:00.000Z",
214            "reply": {
215                "root": { "uri": "at://did:web:root.example/app.bsky.feed.post/3lb", "cid": "bafyfake" },
216                "parent": { "uri": "at://did:web:parent.example/app.bsky.feed.post/3lc", "cid": "bafyfake" },
217            },
218            "embed": {
219                "$type": "app.bsky.embed.record",
220                "record": { "uri": "at://did:web:quoted.example/app.bsky.feed.post/3ld", "cid": "bafyfake" },
221            },
222            "facets": [{
223                "index": { "byteStart": 0, "byteEnd": 13 },
224                "features": [{ "$type": "app.bsky.richtext.facet#mention", "did": "did:web:wren.example" }],
225            }],
226        });
227        assert_eq!(
228            named_accounts(&post),
229            [
230                "did:web:root.example",
231                "did:web:parent.example",
232                "did:web:quoted.example",
233                "did:web:wren.example",
234            ]
235        );
236    }
237
238    /// The subject of a follow is a bare DID; the subject of a like or a
239    /// repost is an at-uri whose authority is one.
240    #[test]
241    fn a_follow_a_like_and_a_repost_each_name_one_account() {
242        for (record, named) in [
243            (
244                json!({ "$type": "app.bsky.graph.follow", "subject": "did:web:wren.example" }),
245                "did:web:wren.example",
246            ),
247            (
248                json!({
249                    "$type": "app.bsky.feed.like",
250                    "subject": { "uri": "at://did:web:wren.example/app.bsky.feed.post/3lb", "cid": "bafyfake" },
251                }),
252                "did:web:wren.example",
253            ),
254            (
255                json!({
256                    "$type": "app.bsky.feed.repost",
257                    "subject": { "uri": "at://did:web:wren.example/app.bsky.feed.post/3lb", "cid": "bafyfake" },
258                }),
259                "did:web:wren.example",
260            ),
261        ] {
262            assert_eq!(named_accounts(&record), [named], "{record}");
263        }
264    }
265
266    /// A record about nobody names nobody, so the write path does no
267    /// lookup for it at all.
268    #[test]
269    fn a_record_carrying_no_did_names_nobody() {
270        let post = json!({
271            "$type": "app.bsky.feed.post",
272            "text": "the tide is out",
273            "createdAt": "2026-09-11T00:00:00.000Z",
274            "facets": [{
275                "index": { "byteStart": 0, "byteEnd": 6 },
276                "features": [{ "$type": "app.bsky.richtext.facet#tag", "tag": "tides" }],
277            }],
278        });
279        assert!(named_accounts(&post).is_empty());
280    }
281
282    /// A `$type` is an NSID and never an account, and a record nested
283    /// deeper than the walk goes stops costing anything.
284    #[test]
285    fn a_type_is_never_an_account_and_the_walk_is_bounded() {
286        let mut nested = json!("did:web:deep.example");
287        for _ in 0..MAX_DEPTH + 2 {
288            nested = json!({ "inner": nested });
289        }
290        assert!(named_accounts(&nested).is_empty());
291
292        let shallow =
293            json!({ "$type": "did:web:not.an.account", "subject": "did:web:wren.example" });
294        assert_eq!(named_accounts(&shallow), ["did:web:wren.example"]);
295    }
296}