From 8cc984da50138d2a958aeafc44f3d9583920e8ed Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:37:35 -0700 Subject: [PATCH] test(threading): the agreement set has two members, and only one was pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review: resolveThreadRoot accepts replyToMessageId + threadRootId whenever explicit === COALESCE(parent.thread_root_id, parent.id) — the parent being anywhere in the aimed-at thread. Two members, not one: parent mid-thread (101 in thread 100) -> pinged: 101's author parent IS the root (replyTo 100, 100) -> pinged: the root's author The suite held only the first. The second is the shape V2PodChat's handleSend comment is actually written about, because it is the only one where the reply edge pings the ROOT's author, and it is structurally unrefusable: a root's thread_root_id is NULL, so derived falls through to parent.id and equals the named root by construction. thread_root_mismatch can never fire there. The client rule is the only thing stopping it, and nothing made that dependency visible. Adds the accept case plus a CONTROL one message over that still 400s, so the acceptance is the COALESCE fall-through and not a permissive resolver. Mutation-checked: gating the accept path reddens exactly the new test. Corrects the handleSend comment's stated reason — a reply edge pings the author of whatever it points at, which coincides with the root's author only in the second shape. Rule 15: the rule survives, the reason changes. 20 tests green (was 18). Co-Authored-By: Claude Opus 5 --- .../unit/models/threadRootResolver.test.js | 24 +++++++++++++++++++ frontend/src/v2/components/V2PodChat.tsx | 16 +++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/models/threadRootResolver.test.js b/backend/__tests__/unit/models/threadRootResolver.test.js index b4e519eb49..f10a8ce0ee 100644 --- a/backend/__tests__/unit/models/threadRootResolver.test.js +++ b/backend/__tests__/unit/models/threadRootResolver.test.js @@ -67,6 +67,30 @@ describe('explicit, the in-thread post shape', () => { test('explicit wins over derivation when both agree', async () => { expect(await resolveThreadRoot({ podId: POD, replyToMessageId: 101, threadRootId: 100 })).toBe(100); }); + + // @sprint-review 56879. "Agree" is a set with two members and this file only + // held one: above, the parent is a reply INSIDE the thread. The other member + // is the parent BEING the root, and it is the one V2PodChat's handleSend + // comment is written about, because it is the only shape where the reply + // edge pings the ROOT's author rather than some mid-thread author. + // + // It is also the one the resolver structurally cannot refuse. derived is + // COALESCE(parent.thread_root_id, parent.id), and a root's thread_root_id is + // NULL, so derived falls through to parent.id — which IS the named root. The + // two statements are equal by construction; thread_root_mismatch can never + // fire here no matter what the client sends. Nothing server-side stops it, + // so the client rule is the only thing that does, and this test is what + // makes that dependency visible rather than assumed. + test('replying to the root while aimed at its thread: accepted, and unrefusable', async () => { + expect(await resolveThreadRoot({ podId: POD, replyToMessageId: 100, threadRootId: 100 })).toBe(100); + }); + + test('CONTROL: the same pair one message over DOES disagree and 400s', async () => { + // Proves the acceptance above is the COALESCE fall-through and not a + // resolver that waves through any pair naming the same pod. + await expect(resolveThreadRoot({ podId: POD, replyToMessageId: 100, threadRootId: 200 })) + .rejects.toMatchObject({ code: 'thread_root_mismatch' }); + }); }); describe('validation — each of these is a 400, not a silent choice', () => { diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2PodChat.tsx index 8f0d1b0a66..15919c3c8c 100644 --- a/frontend/src/v2/components/V2PodChat.tsx +++ b/frontend/src/v2/components/V2PodChat.tsx @@ -860,8 +860,20 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, // It would not: resolveThreadRoot 400s only when the two DISAGREE // (thread_root_mismatch) and accepts them when they agree. An in-thread // post must carry no addressing edge because a reply edge pings the - // root's author (@ux-lead 56879) — that is the rule this enforces, and - // it is the client's to keep. + // author of whatever it points at (@ux-lead 56879) — that is the rule + // this enforces, and it is the client's to keep. + // + // "Agree" is a set, not a point: the backend accepts the pair whenever + // explicit === COALESCE(parent.thread_root_id, parent.id), i.e. whenever + // the parent is anywhere IN the thread being aimed at. Two members, and + // they differ in WHO gets pinged, because resolveImplicitReplyTarget + // resolves the author of replyToMessageId — the parent, not the root: + // parent mid-thread (reply 101 in thread 100) -> pings 101's author + // parent IS the root (replyTo 100, root 100) -> pings the root's author + // Only the second collapses onto "the root's author", which is why that + // is the shape this comment is really about — and it is structurally + // unrefusable: a root's COALESCE falls through to its own id, so the two + // statements can never disagree. @sprint-review 56879. const created = await sendMessage( text, 'text',