Skip to main content

max / makenotwork

multithreaded: close three more removed_at/deleted_at drift sites Continues the Run #3 carried re-verify cluster. aac66820 fixed the first site (search's post_matches CTE); these are the rest. list_tracked_threads counted author-deleted posts toward the unread badge, and its has_mention EXISTS filtered neither removed_at nor deleted_at, so a mention inside a mod-removed post still lit the badge. That one was wrong today rather than latent: a reader following the flag lands on a thread with nothing to find. get_post_body_markdown_in_community filtered p.removed_at and t.deleted_at but not p.deleted_at, while its own doc comment claimed only live posts can be quoted. Same leak class as the search fix: the day an author-delete path ships, a deleted post's text could be re-surfaced by quoting it back. Both new tests were confirmed to fail against the unfixed queries. The cluster's other two items hold and needed no change: every moderation mutation still routes through require_mod_or_owner or require_mod_write, both of which check suspended_at beside the role, and Fan+ external images still load same-origin through /img-proxy under img-src 'self'. The escalation trigger the task named has now fired at four sites; filed as problem fd1c52ca. The missing auto-hide restore path is problem a5670731.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 14:07 UTC
Signed with PGP, not checked
Commit: 7e0bef0006a653283612dd9829b8345af1d8f3c1
Parent: aac6682
6 files changed, +209 insertions, -72 deletions
@@ -292,6 +292,131 @@
292 292 assert!(tracked[0].unread_count > 0, "Should have unread posts");
293 293 }
294 294
295 + /// `posts.deleted_at` is dormant today (migration 031), so this sets it directly
296 + /// rather than through a handler. That is the point: the filter has to already be
297 + /// in place on the day an author-delete path ships, or the badge starts counting
298 + /// posts the reader cannot open. `removed_at` covers the mod's action and is
299 + /// asserted alongside it so neither predicate can be dropped alone.
300 + #[tokio::test]
301 + async fn unread_count_excludes_removed_and_deleted_posts() {
302 + let mut h = TestHarness::new().await;
303 + let user_id = h.login_as("unreadfilter").await;
304 + let comm_id = h.create_community("Test", "test").await;
305 + let cat_id = h.create_category(comm_id, "General", "general").await;
306 + h.add_membership(user_id, comm_id, "member").await;
307 +
308 + let thread_id = h
309 + .create_thread_with_post(cat_id, user_id, "Filtered", "First post")
310 + .await;
311 +
312 + mt_db::mutations::track_thread(&h.db, user_id, thread_id)
313 + .await
314 + .unwrap();
315 + h.client.get(&format!("/p/test/general/{thread_id}")).await;
316 +
317 + let other_id = uuid::Uuid::new_v4();
318 + sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
319 + .bind(other_id)
320 + .bind("filterposter")
321 + .execute(&h.db)
322 + .await
323 + .unwrap();
324 +
325 + let removed = mt_db::mutations::create_post(&h.db, thread_id, other_id, "gone", "<p>gone</p>")
326 + .await
327 + .unwrap();
328 + let deleted = mt_db::mutations::create_post(&h.db, thread_id, other_id, "bye", "<p>bye</p>")
329 + .await
330 + .unwrap();
331 +
332 + let unread_before = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
333 + .await
334 + .unwrap()[0]
335 + .unread_count;
336 + assert_eq!(unread_before, 2, "both replies start unread");
337 +
338 + sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
339 + .bind(removed)
340 + .execute(&h.db)
341 + .await
342 + .unwrap();
343 + sqlx::query("UPDATE posts SET deleted_at = now() WHERE id = $1")
344 + .bind(deleted)
345 + .execute(&h.db)
346 + .await
347 + .unwrap();
348 +
349 + let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
350 + .await
351 + .unwrap();
352 + assert_eq!(
353 + tracked[0].unread_count, 0,
354 + "a mod-removed and an author-deleted post must both drop out of the badge"
355 + );
356 + }
357 +
358 + /// The mention flag reads posts too, so it carries the same filter as the unread
359 + /// count. A badge pointing at a removed post sends the reader to a thread with
360 + /// nothing to find.
361 + #[tokio::test]
362 + async fn mention_flag_excludes_removed_and_deleted_posts() {
363 + let mut h = TestHarness::new().await;
364 + let user_id = h.login_as("mentionfilter").await;
365 + let comm_id = h.create_community("Test", "test").await;
366 + let cat_id = h.create_category(comm_id, "General", "general").await;
367 + h.add_membership(user_id, comm_id, "member").await;
368 +
369 + let thread_id = h
370 + .create_thread_with_post(cat_id, user_id, "Mentioned", "First post")
371 + .await;
372 + mt_db::mutations::track_thread(&h.db, user_id, thread_id)
373 + .await
374 + .unwrap();
375 +
376 + let other_id = uuid::Uuid::new_v4();
377 + sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
378 + .bind(other_id)
379 + .bind("mentioner")
380 + .execute(&h.db)
381 + .await
382 + .unwrap();
383 +
384 + let post_id = mt_db::mutations::create_post(
385 + &h.db,
386 + thread_id,
387 + other_id,
388 + "@mentionfilter hi",
389 + "<p>@mentionfilter hi</p>",
390 + )
391 + .await
392 + .unwrap();
393 + sqlx::query("INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ($1, $2)")
394 + .bind(post_id)
395 + .bind(user_id)
396 + .execute(&h.db)
397 + .await
398 + .unwrap();
399 +
400 + let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
401 + .await
402 + .unwrap();
403 + assert!(tracked[0].has_mention, "live mention must flag");
404 +
405 + sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
406 + .bind(post_id)
407 + .execute(&h.db)
408 + .await
409 + .unwrap();
410 +
411 + let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0)
412 + .await
413 + .unwrap();
414 + assert!(
415 + !tracked[0].has_mention,
416 + "a mention inside a removed post must not flag"
417 + );
418 + }
419 +
295 420 #[tokio::test]
296 421 async fn stop_tracking_all() {
297 422 let mut h = TestHarness::new().await;
@@ -174,16 +174,20 @@
174 174 post_id: Uuid,
175 175 community_id: Uuid,
176 176 ) -> Result<Option<(Uuid, String)>, sqlx::Error> {
177 - // Only live posts can be quoted: a mod-removed post (or one in a soft-deleted
178 - // thread) must fail quote verification so its text cannot be re-surfaced into
179 - // a live post by quoting it back.
177 + // Only live posts can be quoted: a mod-removed post, an author-deleted one,
178 + // or one in a soft-deleted thread must fail quote verification so its text
179 + // cannot be re-surfaced into a live post by quoting it back. All three
180 + // conditions are spelled out because "live" is not one column: `removed_at`
181 + // is the mod action, `deleted_at` the author's, and either one alone would
182 + // let the other class through.
180 183 sqlx::query!(
181 184 "SELECT p.author_id, p.body_markdown
182 185 FROM posts p
183 186 JOIN threads t ON t.id = p.thread_id
184 187 JOIN categories c ON c.id = t.category_id
185 188 WHERE p.id = $1 AND c.community_id = $2
186 - AND p.removed_at IS NULL AND t.deleted_at IS NULL",
189 + AND p.removed_at IS NULL AND p.deleted_at IS NULL
190 + AND t.deleted_at IS NULL",
187 191 post_id,
188 192 community_id,
189 193 )
@@ -35,6 +35,11 @@
35 35 /// with a single LEFT JOIN rather than a per-row nested subquery, and the unread
36 36 /// count excludes mod-removed posts (`p.removed_at IS NULL`) so the badge can't
37 37 /// be inflated by removed content and can use the `idx_posts_not_removed` index.
38 + ///
39 + /// The mention flag carries the same filter as the unread count. Both point the
40 + /// reader at content, so a badge that outlives the post it names sends them to a
41 + /// thread with nothing to find. `deleted_at` is dormant today (migration 031),
42 + /// but the two predicates travel together everywhere else that reads posts.
38 43 #[tracing::instrument(skip_all)]
39 44 pub async fn list_tracked_threads(
40 45 pool: &PgPool,
@@ -52,6 +57,7 @@
52 57 (SELECT COUNT(*) FROM posts p
53 58 WHERE p.thread_id = tt.thread_id
54 59 AND p.removed_at IS NULL
60 + AND p.deleted_at IS NULL
55 61 AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)
56 62 ) AS "unread_count!",
57 63 EXISTS (
@@ -59,6 +65,8 @@
59 65 JOIN posts p ON p.id = pm.post_id
60 66 WHERE pm.mentioned_user_id = tt.user_id
61 67 AND p.thread_id = tt.thread_id
68 + AND p.removed_at IS NULL
69 + AND p.deleted_at IS NULL
62 70 ) AS "has_mention!",
63 71 tt.tracked_at AS "tracked_at: chrono::DateTime<chrono::Utc>"
64 72 FROM tracked_threads tt
@@ -1,6 +1,6 @@
1 1 {
2 2 "db_name": "PostgreSQL",
3 - "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND t.deleted_at IS NULL",
3 + "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND p.deleted_at IS NULL\n AND t.deleted_at IS NULL",
4 4 "describe": {
5 5 "columns": [
6 6 {
@@ -25,5 +25,5 @@
25 25 false
26 26 ]
27 27 },
28 - "hash": "88156f7230531c0aaa7ffdac3e70b0fc2655dfb176d446b93723a252ada349b0"
28 + "hash": "4d9de685a37677d8ec3211a14d314ee7dabd93d602f20c0d343ec9b79b51d2e1"
29 29 }
@@ -1,0 +1,66 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "SELECT tt.thread_id,\n t.title AS thread_title,\n co.name AS community_name,\n co.slug AS community_slug,\n cat.slug AS category_slug,\n (SELECT COUNT(*) FROM posts p\n WHERE p.thread_id = tt.thread_id\n AND p.removed_at IS NULL\n AND p.deleted_at IS NULL\n AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)\n ) AS \"unread_count!\",\n EXISTS (\n SELECT 1 FROM post_mentions pm\n JOIN posts p ON p.id = pm.post_id\n WHERE pm.mentioned_user_id = tt.user_id\n AND p.thread_id = tt.thread_id\n AND p.removed_at IS NULL\n AND p.deleted_at IS NULL\n ) AS \"has_mention!\",\n tt.tracked_at AS \"tracked_at: chrono::DateTime<chrono::Utc>\"\n FROM tracked_threads tt\n JOIN threads t ON t.id = tt.thread_id\n JOIN categories cat ON cat.id = t.category_id\n JOIN communities co ON co.id = cat.community_id\n LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id\n WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL\n ORDER BY t.last_activity_at DESC\n LIMIT $2 OFFSET $3",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "thread_id",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "thread_title",
14 + "type_info": "Text"
15 + },
16 + {
17 + "ordinal": 2,
18 + "name": "community_name",
19 + "type_info": "Text"
20 + },
21 + {
22 + "ordinal": 3,
23 + "name": "community_slug",
24 + "type_info": "Text"
25 + },
26 + {
27 + "ordinal": 4,
28 + "name": "category_slug",
29 + "type_info": "Text"
30 + },
31 + {
32 + "ordinal": 5,
33 + "name": "unread_count!",
34 + "type_info": "Int8"
35 + },
36 + {
37 + "ordinal": 6,
38 + "name": "has_mention!",
39 + "type_info": "Bool"
40 + },
41 + {
42 + "ordinal": 7,
43 + "name": "tracked_at: chrono::DateTime<chrono::Utc>",
44 + "type_info": "Timestamptz"
45 + }
46 + ],
47 + "parameters": {
48 + "Left": [
49 + "Uuid",
50 + "Int8",
51 + "Int8"
52 + ]
53 + },
54 + "nullable": [
55 + false,
56 + false,
57 + false,
58 + false,
59 + false,
60 + null,
61 + null,
62 + false
63 + ]
64 + },
65 + "hash": "518a5972549d912715539420229c6286b58ccae08e2b1211207fdb4a6996321e"
66 + }
@@ -1,66 +1,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "SELECT tt.thread_id,\n t.title AS thread_title,\n co.name AS community_name,\n co.slug AS community_slug,\n cat.slug AS category_slug,\n (SELECT COUNT(*) FROM posts p\n WHERE p.thread_id = tt.thread_id\n AND p.removed_at IS NULL\n AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)\n ) AS \"unread_count!\",\n EXISTS (\n SELECT 1 FROM post_mentions pm\n JOIN posts p ON p.id = pm.post_id\n WHERE pm.mentioned_user_id = tt.user_id\n AND p.thread_id = tt.thread_id\n ) AS \"has_mention!\",\n tt.tracked_at AS \"tracked_at: chrono::DateTime<chrono::Utc>\"\n FROM tracked_threads tt\n JOIN threads t ON t.id = tt.thread_id\n JOIN categories cat ON cat.id = t.category_id\n JOIN communities co ON co.id = cat.community_id\n LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id\n WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL\n ORDER BY t.last_activity_at DESC\n LIMIT $2 OFFSET $3",
4 - "describe": {
5 - "columns": [
6 - {
7 - "ordinal": 0,
8 - "name": "thread_id",
9 - "type_info": "Uuid"
10 - },
11 - {
12 - "ordinal": 1,
13 - "name": "thread_title",
14 - "type_info": "Text"
15 - },
16 - {
17 - "ordinal": 2,
18 - "name": "community_name",
19 - "type_info": "Text"
20 - },
21 - {
22 - "ordinal": 3,
23 - "name": "community_slug",
24 - "type_info": "Text"
25 - },
26 - {
27 - "ordinal": 4,
28 - "name": "category_slug",
29 - "type_info": "Text"
30 - },
31 - {
32 - "ordinal": 5,
33 - "name": "unread_count!",
34 - "type_info": "Int8"
35 - },
36 - {
37 - "ordinal": 6,
38 - "name": "has_mention!",
39 - "type_info": "Bool"
40 - },
41 - {
42 - "ordinal": 7,
43 - "name": "tracked_at: chrono::DateTime<chrono::Utc>",
44 - "type_info": "Timestamptz"
45 - }
46 - ],
47 - "parameters": {
48 - "Left": [
49 - "Uuid",
50 - "Int8",
51 - "Int8"
52 - ]
53 - },
54 - "nullable": [
55 - false,
56 - false,
57 - false,
58 - false,
59 - false,
60 - null,
61 - null,
62 - false
63 - ]
64 - },
65 - "hash": "78349b35320856cf18eb7e66393675af5a14063b83dc8366c801994b67cdc5a7"
66 - }