Severity: MEDIUM
Component: crates/marmot-app (src/directory/cache.rs, src/directory/methods.rs)
Summary
The bounded directory search graph (directory_search_graph_follows) is deliberately populated without promoting a contact-list author into directory_users, so follow edges stay available for bounded search while avoiding the unbounded social-graph crawl (mdk#687 / mdk#418). But every write into directory_users unconditionally re-snapshots the entry into the search graph with follows: Some(entry.follows.clone()). For a user promoted via the ordinary paths (profile / message-sender / relay-list / key-package), entry.follows is empty, so the snapshot deletes that account's previously-recorded search-graph follow edges and marks follows_known = true.
The result is nondeterministic, silent loss of exactly the follow data the search graph exists to preserve.
Location
crates/marmot-app/src/directory/cache.rs:183 — put_with_reason_locked unconditionally calls Self::put_search_graph_snapshot(conn, entry, now)? on every directory_users write.
crates/marmot-app/src/directory/cache.rs:262-279 — put_search_graph_snapshot always passes follows: Some(entry.follows.clone()).
crates/marmot-app/src/directory/cache.rs:338-398 — put_search_graph_record_locked: because record.follows is Some([]) (not None), it takes the replace_follow_rows(..., &[]) branch (DELETE all rows + insert nothing) and writes follows_known = 1.
crates/marmot-app/src/directory/cache.rs:80-89 — entry() reads directory_users only; a search-graph-only account returns None.
crates/marmot-app/src/directory/methods.rs:687-701 — directory_entry_for_account_id_with_handles reads only directory_users + shared storage, never the search graph.
crates/marmot-app/src/directory/methods.rs:822-837 — remember_directory_follow_edges_for_search (the search-graph-only writer whose edges get clobbered).
crates/marmot-app/src/directory/methods.rs:839-849 — remember_directory_profile builds entry via empty_directory_record (empty follows) for a not-yet-known user.
Concrete failure scenario (single relay batch, order-dependent)
- Alice's kind-3 contact list is ingested while Alice is not a known directory user →
remember_directory_follow_edges_for_search(alice, [...]) records "alice → follows" in directory_search_graph_follows with follows_known = 1.
- In the same or a later batch, Alice's kind-0 profile (or a message from Alice) is processed →
remember_directory_profile / remember_directory_message_sender builds an entry from empty_directory_record (Alice is not in directory_users, so directory_entry_for_account_id returns None) → save_directory_entry → put_with_reason_locked → put_search_graph_snapshot with follows = Some([]) → replace_follow_rows("directory_search_graph_follows", &[]) deletes Alice's search-graph follow edges and leaves follows_known = 1 (now falsely asserting "Alice follows nobody").
Whether the edges survive depends purely on the relative processing order of kind-0 vs kind-3 within a batch: kind-0-after-kind-3 loses them; kind-0-before-kind-3 keeps them (the later remember_directory_follow_edges_for_search then also fills entry.follows). This is nondeterministic, silent recall degradation of directory search.
Impact
Silent, order-dependent loss of discovered follow edges, plus a false follows_known = true that makes search_graph_record return an empty follow set (rather than treating follows as unknown). This defeats the bounded-search design the search graph exists to provide.
Suggested fix
In put_search_graph_snapshot, pass follows: None when the promoted entry carries no follows (so put_search_graph_record_locked preserves the existing search-graph edges and existing follows_known state), or only snapshot follows when !entry.follows.is_empty(). More generally, a directory_users write should not be able to regress independently-sourced search-graph follow edges.
Dedup
Distinct from #884 (own-profile publish revert of kind:0 extra fields), #867 (directory extra-field ingest cap), and #744 (leaked inflight directory fetch entry). Closed #411 validates directory events before caching but does not touch this promotion-time clobber. No open/closed issue covers the search-graph follow-edge deletion on directory-user promotion.
Filed from a meticulous automated code-review pass; verified against source and deduplicated against the existing open/closed issue set.
Severity: MEDIUM
Component:
crates/marmot-app(src/directory/cache.rs,src/directory/methods.rs)Summary
The bounded directory search graph (
directory_search_graph_follows) is deliberately populated without promoting a contact-list author intodirectory_users, so follow edges stay available for bounded search while avoiding the unbounded social-graph crawl (mdk#687 / mdk#418). But every write intodirectory_usersunconditionally re-snapshots the entry into the search graph withfollows: Some(entry.follows.clone()). For a user promoted via the ordinary paths (profile / message-sender / relay-list / key-package),entry.followsis empty, so the snapshot deletes that account's previously-recorded search-graph follow edges and marksfollows_known = true.The result is nondeterministic, silent loss of exactly the follow data the search graph exists to preserve.
Location
crates/marmot-app/src/directory/cache.rs:183—put_with_reason_lockedunconditionally callsSelf::put_search_graph_snapshot(conn, entry, now)?on everydirectory_userswrite.crates/marmot-app/src/directory/cache.rs:262-279—put_search_graph_snapshotalways passesfollows: Some(entry.follows.clone()).crates/marmot-app/src/directory/cache.rs:338-398—put_search_graph_record_locked: becauserecord.followsisSome([])(notNone), it takes thereplace_follow_rows(..., &[])branch (DELETE all rows + insert nothing) and writesfollows_known = 1.crates/marmot-app/src/directory/cache.rs:80-89—entry()readsdirectory_usersonly; a search-graph-only account returnsNone.crates/marmot-app/src/directory/methods.rs:687-701—directory_entry_for_account_id_with_handlesreads onlydirectory_users+ shared storage, never the search graph.crates/marmot-app/src/directory/methods.rs:822-837—remember_directory_follow_edges_for_search(the search-graph-only writer whose edges get clobbered).crates/marmot-app/src/directory/methods.rs:839-849—remember_directory_profilebuildsentryviaempty_directory_record(empty follows) for a not-yet-known user.Concrete failure scenario (single relay batch, order-dependent)
remember_directory_follow_edges_for_search(alice, [...])records "alice → follows" indirectory_search_graph_followswithfollows_known = 1.remember_directory_profile/remember_directory_message_senderbuilds an entry fromempty_directory_record(Alice is not indirectory_users, sodirectory_entry_for_account_idreturnsNone) →save_directory_entry→put_with_reason_locked→put_search_graph_snapshotwithfollows = Some([])→replace_follow_rows("directory_search_graph_follows", &[])deletes Alice's search-graph follow edges and leavesfollows_known = 1(now falsely asserting "Alice follows nobody").Whether the edges survive depends purely on the relative processing order of kind-0 vs kind-3 within a batch: kind-0-after-kind-3 loses them; kind-0-before-kind-3 keeps them (the later
remember_directory_follow_edges_for_searchthen also fillsentry.follows). This is nondeterministic, silent recall degradation of directory search.Impact
Silent, order-dependent loss of discovered follow edges, plus a false
follows_known = truethat makessearch_graph_recordreturn an empty follow set (rather than treating follows as unknown). This defeats the bounded-search design the search graph exists to provide.Suggested fix
In
put_search_graph_snapshot, passfollows: Nonewhen the promoted entry carries no follows (soput_search_graph_record_lockedpreserves the existing search-graph edges and existingfollows_knownstate), or only snapshot follows when!entry.follows.is_empty(). More generally, adirectory_userswrite should not be able to regress independently-sourced search-graph follow edges.Dedup
Distinct from #884 (own-profile publish revert of kind:0
extrafields), #867 (directoryextra-field ingest cap), and #744 (leaked inflight directory fetch entry). Closed #411 validates directory events before caching but does not touch this promotion-time clobber. No open/closed issue covers the search-graph follow-edge deletion on directory-user promotion.Filed from a meticulous automated code-review pass; verified against source and deduplicated against the existing open/closed issue set.