Skip to content

Commit 9de48c3

Browse files
committed
feat: guard raw parent context transport
1 parent a56392c commit 9de48c3

8 files changed

Lines changed: 137 additions & 1 deletion

src/engagement/candidateBoundary.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Mention } from "../poller/mentionsMapper.js";
22
import type { CanonicalEvent, TriggerType } from "../canonical/types.js";
33
import type { TimelineCandidate } from "./types.js";
44
import { buildConversationParentRef, type ConversationBundle } from "./conversationBundle.js";
5+
import { buildRawConversationContextTokens } from "./conversationContextTransport.js";
56

67
export interface RawTriggerInput {
78
triggerType: "mention" | "timeline";
@@ -81,6 +82,7 @@ export function buildRawTriggerInputFromMention(
8182
parentRef: buildConversationParentRef({
8283
tweetId: parentTweetId,
8384
conversationId: mention.conversation_id ?? undefined,
85+
authorId: mention.in_reply_to_user_id ?? undefined,
8486
}),
8587
discoveredAt: mention.created_at ?? "unknown",
8688
rawText: mention.text,
@@ -138,6 +140,7 @@ export function toCanonicalExecutionInput(
138140
const triggerType: TriggerType = candidate.triggerType === "mention" ? "mention" : "reply";
139141
const bundleContext =
140142
typeof bundle?.sourceMetadata?.context === "string" ? bundle.sourceMetadata.context : undefined;
143+
const parentRef = bundle?.parentRef ?? candidate.parentRef;
141144

142145
return {
143146
event_id: candidate.candidateId,
@@ -148,7 +151,8 @@ export function toCanonicalExecutionInput(
148151
text: candidate.normalizedText,
149152
parent_text: null,
150153
quoted_text: null,
151-
conversation_context: [],
154+
// Raw transport only: fixed tokens from direct parent fields, no interpretation.
155+
conversation_context: buildRawConversationContextTokens({ parentRef }),
152156
cashtags: signals.cashtags,
153157
hashtags: signals.hashtags,
154158
urls: signals.urls,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { ConversationParentRef } from "./conversationBundle.js";
2+
3+
/**
4+
* Raw conversation-context transport lives here and nowhere else.
5+
* The contract is intentionally narrow: emit fixed, append-only tokens from
6+
* already-available direct parent fields only.
7+
*/
8+
export const ALLOWED_CONVERSATION_CONTEXT_TOKEN_PREFIXES = [
9+
"parent_tweet_id",
10+
"parent_conversation_id",
11+
"parent_author_id",
12+
] as const;
13+
14+
/**
15+
* Forbidden categories are policy reminders, not emitted tokens.
16+
* They are listed here to keep transport raw and non-semantic.
17+
*/
18+
export const FORBIDDEN_CONVERSATION_CONTEXT_TOKEN_CATEGORIES = [
19+
"dialogue quality judgments",
20+
"participation judgments",
21+
"risk judgments",
22+
"social or intimacy judgments",
23+
"relevance or scoring judgments",
24+
"inferred debate state",
25+
"inferred author role",
26+
"inferred thread openness or closedness",
27+
"semantic labels",
28+
] as const;
29+
30+
export type AllowedConversationContextTokenPrefix =
31+
(typeof ALLOWED_CONVERSATION_CONTEXT_TOKEN_PREFIXES)[number];
32+
33+
export interface ConversationContextTransportInput {
34+
parentRef?: ConversationParentRef;
35+
}
36+
37+
export function buildRawConversationContextTokens(input: ConversationContextTransportInput): string[] {
38+
const parentRef = input.parentRef;
39+
if (!parentRef) {
40+
return [];
41+
}
42+
43+
const tokens: string[] = [];
44+
if (parentRef.tweetId) {
45+
tokens.push(`parent_tweet_id:${parentRef.tweetId}`);
46+
}
47+
if (parentRef.conversationId) {
48+
tokens.push(`parent_conversation_id:${parentRef.conversationId}`);
49+
}
50+
if (parentRef.authorId) {
51+
tokens.push(`parent_author_id:${parentRef.authorId}`);
52+
}
53+
54+
return tokens;
55+
}
56+
57+
export function isAllowedConversationContextToken(token: string): token is `${AllowedConversationContextTokenPrefix}:${string}` {
58+
return ALLOWED_CONVERSATION_CONTEXT_TOKEN_PREFIXES.some((prefix) => token.startsWith(`${prefix}:`));
59+
}

src/worker/pollMentions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export async function processCanonicalMention(
340340
const engagementCandidate = buildEngagementCandidate(rawTriggerInput);
341341
const conversationBundle = maybeBuildConversationBundle({
342342
candidate: engagementCandidate,
343+
parentRef: rawTriggerInput.parentRef,
343344
sourceTweet: {
344345
tweetId: engagementCandidate.tweetId,
345346
conversationId: engagementCandidate.conversationId,

src/worker/pollTimelineEngagement.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ export async function runTimelineEngagementIteration(): Promise<TimelineIteratio
371371
const engagementCandidate = buildEngagementCandidate(rawTriggerInput);
372372
const conversationBundle = maybeBuildConversationBundle({
373373
candidate: engagementCandidate,
374+
parentRef: rawTriggerInput.parentRef,
374375
sourceTweet: {
375376
tweetId: engagementCandidate.tweetId,
376377
conversationId: engagementCandidate.conversationId,

tests/engagement/candidateBoundary.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("candidateBoundary", () => {
2626
const candidate = buildEngagementCandidate(raw);
2727
const bundle = maybeBuildConversationBundle({
2828
candidate,
29+
parentRef: raw.parentRef,
2930
sourceTweet: {
3031
tweetId: candidate.tweetId,
3132
conversationId: candidate.conversationId,
@@ -50,10 +51,16 @@ describe("candidateBoundary", () => {
5051
expect(candidate.parentRef?.tweetId).toBe("parent-mention-1");
5152
expect(bundle?.sourceTweet?.tweetId).toBe("mention-1");
5253
expect(bundle?.parentRef?.tweetId).toBe("parent-mention-1");
54+
expect(bundle?.parentRef?.authorId).toBe("bot-1");
5355
expect(bundle?.authorContext?.authorHandle).toBe("Alice");
5456
expect(canonical.event_id).toBe("mention-1");
5557
expect(canonical.trigger_type).toBe("mention");
5658
expect(canonical.author_handle).toBe("@alice");
59+
expect(canonical.conversation_context).toEqual([
60+
"parent_tweet_id:parent-mention-1",
61+
"parent_conversation_id:conv-1",
62+
"parent_author_id:bot-1",
63+
]);
5764
expect(canonical.context).toBeUndefined();
5865
});
5966

@@ -98,6 +105,7 @@ describe("candidateBoundary", () => {
98105
const candidate = buildEngagementCandidate(raw);
99106
const bundle = maybeBuildConversationBundle({
100107
candidate,
108+
parentRef: raw.parentRef,
101109
sourceTweet: {
102110
tweetId: candidate.tweetId,
103111
conversationId: candidate.conversationId,
@@ -126,6 +134,10 @@ describe("candidateBoundary", () => {
126134
expect(canonical.event_id).toBe("timeline:tweet-1");
127135
expect(canonical.trigger_type).toBe("reply");
128136
expect(canonical.author_handle).toBe("@bob");
137+
expect(canonical.conversation_context).toEqual([
138+
"parent_tweet_id:parent-tweet-1",
139+
"parent_conversation_id:conv-2",
140+
]);
129141
expect(canonical.context).toBeUndefined();
130142
});
131143

@@ -154,6 +166,10 @@ describe("candidateBoundary", () => {
154166
expect(bundle?.parentRef?.tweetId).toBe("parent-2");
155167
expect(bundle?.authorContext?.authorId).toBe("author-3");
156168
expect(bundle?.sourceMetadata?.authorHandle).toBe("alice");
169+
expect(toCanonicalExecutionInput(candidate, bundle).conversation_context).toEqual([
170+
"parent_tweet_id:parent-2",
171+
"parent_conversation_id:conv-3",
172+
]);
157173
});
158174

159175
it("keeps the bundle sparse when no cheap parent hint is present", () => {
@@ -185,6 +201,7 @@ describe("candidateBoundary", () => {
185201
expect(candidate.parentRef).toBeUndefined();
186202
expect(bundle?.parentRef).toBeUndefined();
187203
expect(bundle?.sourceTweet?.tweetId).toBe("mention-3");
204+
expect(toCanonicalExecutionInput(candidate, bundle).conversation_context).toEqual([]);
188205
});
189206

190207
it("keeps mention discoveredAt conservative when created_at is missing", () => {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
ALLOWED_CONVERSATION_CONTEXT_TOKEN_PREFIXES,
4+
FORBIDDEN_CONVERSATION_CONTEXT_TOKEN_CATEGORIES,
5+
buildRawConversationContextTokens,
6+
isAllowedConversationContextToken,
7+
} from "../../src/engagement/conversationContextTransport.js";
8+
9+
describe("conversationContextTransport", () => {
10+
it("keeps the allowed token vocabulary small and raw", () => {
11+
expect(ALLOWED_CONVERSATION_CONTEXT_TOKEN_PREFIXES).toEqual([
12+
"parent_tweet_id",
13+
"parent_conversation_id",
14+
"parent_author_id",
15+
]);
16+
expect(FORBIDDEN_CONVERSATION_CONTEXT_TOKEN_CATEGORIES).toContain("risk judgments");
17+
expect(FORBIDDEN_CONVERSATION_CONTEXT_TOKEN_CATEGORIES).toContain("participation judgments");
18+
});
19+
20+
it("emits only raw direct-parent transport tokens when parent fields are present", () => {
21+
const tokens = buildRawConversationContextTokens({
22+
parentRef: {
23+
tweetId: "tweet-parent-1",
24+
conversationId: "conv-parent-1",
25+
authorId: "author-parent-1",
26+
},
27+
});
28+
29+
expect(tokens).toEqual([
30+
"parent_tweet_id:tweet-parent-1",
31+
"parent_conversation_id:conv-parent-1",
32+
"parent_author_id:author-parent-1",
33+
]);
34+
expect(tokens.every((token) => isAllowedConversationContextToken(token))).toBe(true);
35+
expect(isAllowedConversationContextToken("thread_open:true")).toBe(false);
36+
expect(isAllowedConversationContextToken("risk:high")).toBe(false);
37+
expect(isAllowedConversationContextToken("author_type:researcher")).toBe(false);
38+
});
39+
40+
it("stays sparse when no direct parent context is available", () => {
41+
expect(buildRawConversationContextTokens({})).toEqual([]);
42+
expect(buildRawConversationContextTokens({ parentRef: undefined })).toEqual([]);
43+
});
44+
});

tests/integration/pipeline/mentionPipelineConsentFlow.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const buildRawTriggerInputMock = vi.fn((mention: Mention, source: "mentions" | "
2222
tweetId: mention.referenced_tweets?.find((ref) => ref.type === "replied_to" || ref.type === "quoted")
2323
?.id,
2424
conversationId: mention.conversation_id ?? undefined,
25+
authorId: mention.in_reply_to_user_id ?? undefined,
2526
}
2627
: undefined,
2728
discoveredAt: mention.created_at ?? new Date().toISOString(),
@@ -257,9 +258,15 @@ describe("mention pipeline consent flow", () => {
257258
expect(buildRawTriggerInputMock).toHaveBeenCalledTimes(1);
258259
expect(buildEngagementCandidateMock).toHaveBeenCalledTimes(1);
259260
expect(maybeBuildConversationBundleMock).toHaveBeenCalledTimes(1);
261+
expect(maybeBuildConversationBundleMock.mock.calls[0]?.[0].parentRef).toMatchObject({
262+
tweetId: "bot-1",
263+
conversationId: "conv-1",
264+
authorId: "bot-1",
265+
});
260266
expect(maybeBuildConversationBundleMock.mock.calls[0]?.[0].candidate.parentRef).toMatchObject({
261267
tweetId: "bot-1",
262268
conversationId: "conv-1",
269+
authorId: "bot-1",
263270
});
264271
expect(assembleSignalProfileMock).toHaveBeenCalledTimes(1);
265272
expect(toCanonicalExecutionInputMock).toHaveBeenCalledTimes(1);

tests/worker/pollTimelineEngagement.integration.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,9 @@ describe("timeline engagement worker", () => {
405405
expect(handleEventMock).toHaveBeenCalledTimes(1);
406406
expect(replyMock).toHaveBeenCalledTimes(1);
407407
expect(maybeBuildConversationBundleMock).toHaveBeenCalledTimes(1);
408+
expect(maybeBuildConversationBundleMock.mock.calls[0]?.[0].parentRef).toMatchObject({
409+
conversationId: "conv-1",
410+
});
408411
expect(maybeBuildConversationBundleMock.mock.calls[0]?.[0].candidate.parentRef).toMatchObject({
409412
conversationId: "conv-1",
410413
});

0 commit comments

Comments
 (0)