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',