Files
PaperClipAI/server/src/services/native-runtime/external-chat-question-response.ts
T
Devin FoleyandPaperclip bf12b2d8ad fix: retry reviewed chat setup after temporary contention (#19787)
Retry reviewed chat preparation with the original accepted input.

Bound retries for authorization proof waits that expire before provider
work. Preserve the original answer receipt and queue later wakes
separately. Keep plan confirmation semantics by assigning the interaction
marker only to question answer delivery and legacy question payloads.

Verification: 433 tests across four backend suites, all 17 project-chat
browser tests, and all server source/test typecheck layers passed. The
plan approval regression was reproduced on the previous head before the
fix. All applicable CI passed and exact-head Greptile Apex scored 5/5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-07 02:14:42 -08:00

957 lines
42 KiB
TypeScript

import { photonAnswersMatch } from "../photon/interactions.js";
import { and, or, eq, lte, inArray, notExists, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentWakeupRequests,
chatActions,
chatConversations,
chatDeliveries,
chatEndpoints,
chatExternalPrincipals,
chatIdentityLinks,
chatVoiceSessions,
chatVoiceToolCalls,
chatVoiceReplies,
toolConnections,
chatMessageLinks,
chatPublications,
heartbeatRuns,
issueComments,
issueQuestionResponseDeliveries,
issueThreadInteractions,
} from "@paperclipai/db";
import type { AskUserQuestionsInteraction } from "@paperclipai/shared";
import {
parseChatQuestionFormSubmitTokenPayload,
validateChatQuestionFormSubmission,
} from "../chat-question-forms.js";
import { questionResponseDeliveryValues } from "../question-response-delivery.js";
import { voiceSessionStore, type VoiceTransaction, currentVoiceCredentialFingerprint } from "../voice/voice-session-store.js";
import { nativeSha256 } from "./canonical.js";
import { EXTERNAL_CHAT_ANSWER_ADMISSION_KEYS, resolveQuestionResponseRetryAdmission } from "./external-chat-question-response-retry.js";
import { INTERACTION_CONTINUATION_INFRA_RETRY_REASON } from "../../modules/run-dispatch/index.js";
export const EXTERNAL_CHAT_QUESTION_RESPONSE_KEY =
"paperclipExternalChatQuestionResponse";
type Binding = {
companyId: string;
issueId: string;
runId: string;
agentId: string;
};
type LockMode = "blocking" | "nonblocking" | "read";
type Marker = {
schema: "paperclip.external_chat_question_response.v1";
interactionId: string;
responseDeliveryId: string;
sourceRunId: string;
sourceCommentId: string;
endpointId: string;
conversationId: string;
bindingSha256: string;
};
type ResolvedQuestionResponse = {
marker: Marker;
provider: typeof chatEndpoints.$inferSelect.provider;
/** Server-recorded current answer time, already bound into the marker hash. */
answeredAtMs: number;
authorizationContext: Record<string, unknown>;
/** Authoritative answered interactions, oldest first; not persisted answer text. */
interactionIds: string[];
};
// Keep authorization work/lock duration bounded. A ninth linked answer is
// denied, never admitted with a silently truncated ancestor history.
const MAX_QUESTION_RESPONSE_CHAIN_DEPTH = 8;
type Chain = {
runIds: Set<string>;
interactionIds: Set<string>;
deliveryIds: Set<string>;
actionIds: Set<string>;
};
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const ids = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((id): id is string => typeof id === "string")
: [];
function completedMultiChoiceMatches(question: Record<string, unknown>, answer: Record<string, unknown>, payload: Record<string, unknown>): boolean {
const selected = ids(payload.selectionValues);
const answered = ids(answer.optionIds);
const mappings = Array.isArray(payload.selectionOptions) ? payload.selectionOptions.map(record) : [];
const options = Array.isArray(question.options) ? question.options.map(record) : [];
if (question.selectionMode !== "multi" || question.allowOther === true ||
!Array.isArray(payload.selectionValues) || selected.length !== payload.selectionValues.length ||
!Array.isArray(answer.optionIds) || answered.length !== answer.optionIds.length ||
new Set(selected).size !== selected.length || new Set(answered).size !== answered.length ||
mappings.length !== options.length || new Set(mappings.map(item => item.value)).size !== mappings.length ||
new Set(mappings.map(item => item.optionId)).size !== mappings.length ||
(question.required !== false && selected.length === 0) ||
selected.length !== answered.length ||
mappings.some(item => typeof item.value !== "string" || typeof item.optionId !== "string" || !options.some(option => option.id === item.optionId && option.freeText !== true))) return false;
return selected.every((value, index) => mappings.find(item => item.value === value)?.optionId === answered[index]);
}
function completedQuestionFormMatchesInteraction(
interaction: AskUserQuestionsInteraction,
action: typeof chatActions.$inferSelect,
): boolean {
const payload = parseChatQuestionFormSubmitTokenPayload(action.payload);
const result = record(action.result);
const answers = record(interaction.result).answers;
const resolvedAt =
interaction.resolvedAt instanceof Date
? interaction.resolvedAt
: new Date(interaction.resolvedAt ?? Number.NaN);
if (
!payload ||
interaction.status !== "answered" ||
action.providerActionId !== payload.formActionId ||
payload.interactionId !== interaction.id ||
result.code !== "question_form_answered" ||
result.interactionId !== interaction.id ||
!Number.isFinite(resolvedAt.getTime()) ||
!Array.isArray(answers) ||
answers.length !== payload.fields.length
)
return false;
const answerByQuestionId = new Map<string, Record<string, unknown>>();
for (const value of answers) {
const answer = record(value);
const questionId =
typeof answer.questionId === "string" ? answer.questionId : null;
if (!questionId || answerByQuestionId.has(questionId)) return false;
answerByQuestionId.set(questionId, answer);
}
const values: Record<string, string> = {};
for (const field of payload.fields) {
const answer = answerByQuestionId.get(field.questionId);
if (!answer) return false;
const optionIds = ids(answer.optionIds);
if (
!Array.isArray(answer.optionIds) ||
optionIds.length !== answer.optionIds.length
)
return false;
if (field.kind === "single_select") {
if (
optionIds.length > 1 ||
(answer.otherText !== undefined &&
answer.otherText !== null &&
answer.otherText !== "")
)
return false;
const option = optionIds[0]
? field.options.find((candidate) => candidate.optionId === optionIds[0])
: null;
if (optionIds.length === 1 && !option) return false;
values[field.fieldId] = option?.value ?? "";
continue;
}
if (
optionIds.length !== 0 ||
(answer.otherText !== undefined &&
answer.otherText !== null &&
typeof answer.otherText !== "string")
)
return false;
values[field.fieldId] =
typeof answer.otherText === "string" ? answer.otherText : "";
}
const validation = validateChatQuestionFormSubmission({
callbackId: payload.formActionId,
privateMetadata: payload.formActionId,
interaction: { ...interaction, status: "pending" },
payload,
values,
// Recheck the durable answer time against the opaque token's lifetime;
// continuing later must not depend on the current wall clock.
now: resolvedAt,
});
return (
validation.ok &&
nativeSha256(validation.answers) === nativeSha256(answers)
);
}
/**
* Resolve a durable provider answer or an authenticated Board fallback,
* never a caller-supplied wake marker. This
* is routing/reading authority only: it never checks out a task or resolves a
* review. Callers retain their issue/run/actor and current destination checks.
*/
export async function resolveExternalChatQuestionResponse(
tx: Db,
binding: Binding,
contextSnapshot: unknown,
lockMode: LockMode = "blocking",
mint = false,
): Promise<ResolvedQuestionResponse | null> {
return resolveQuestionResponseChain(
tx,
binding,
contextSnapshot,
lockMode,
mint,
{
runIds: new Set(),
interactionIds: new Set(),
deliveryIds: new Set(),
actionIds: new Set(),
},
);
}
async function resolveQuestionResponseChain(
tx: Db,
binding: Binding,
contextSnapshot: unknown,
lockMode: LockMode,
mint: boolean,
chain: Chain,
): Promise<ResolvedQuestionResponse | null> {
if (
chain.runIds.has(binding.runId) ||
chain.runIds.size >= MAX_QUESTION_RESPONSE_CHAIN_DEPTH
)
return null;
chain.runIds.add(binding.runId);
const context = record(contextSnapshot);
if (
context.source !== "issue.interaction.respond" ||
context.issueId !== binding.issueId ||
(context.wakeReason !== "issue_commented" && context.wakeReason !== INTERACTION_CONTINUATION_INFRA_RETRY_REASON) ||
context.interactionKind !== "ask_user_questions" ||
context.interactionStatus !== "answered" ||
context.externalChatContinuation !== true ||
typeof context.interactionId !== "string" ||
typeof context.sourceRunId !== "string" ||
typeof context.sourceCommentId !== "string" ||
context.wakeCommentId !== context.sourceCommentId ||
ids(context.wakeCommentIds).length !== 1 ||
ids(context.wakeCommentIds)[0] !== context.sourceCommentId
)
return null;
const runQuery = tx
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, binding.runId),
eq(heartbeatRuns.companyId, binding.companyId),
eq(heartbeatRuns.agentId, binding.agentId),
),
);
const [run] = await (lockMode === "read"
? runQuery
: lockMode === "nonblocking"
? runQuery.for("update", { noWait: true })
: runQuery.for("update"));
if (!run?.wakeupRequestId) return null;
const admitted = record(run.contextSnapshot);
for (const key of [...EXTERNAL_CHAT_ANSWER_ADMISSION_KEYS, "wakeReason"]) {
if (
nativeSha256(admitted[key] ?? null) !== nativeSha256(context[key] ?? null)
)
return null;
}
const admission = await resolveQuestionResponseRetryAdmission(tx, run, lockMode);
if (!admission?.wakeupRequestId) return null;
const query = tx
.select({
interaction: issueThreadInteractions,
delivery: issueQuestionResponseDeliveries,
wake: agentWakeupRequests,
source: heartbeatRuns,
})
.from(issueThreadInteractions)
.innerJoin(
issueQuestionResponseDeliveries,
and(
eq(
issueQuestionResponseDeliveries.interactionId,
issueThreadInteractions.id,
),
eq(
issueQuestionResponseDeliveries.companyId,
issueThreadInteractions.companyId,
),
eq(
issueQuestionResponseDeliveries.issueId,
issueThreadInteractions.issueId,
),
eq(
issueQuestionResponseDeliveries.sourceRunId,
issueThreadInteractions.sourceRunId,
),
),
)
.innerJoin(
heartbeatRuns,
and(
eq(heartbeatRuns.id, issueThreadInteractions.sourceRunId),
eq(heartbeatRuns.companyId, issueThreadInteractions.companyId),
),
)
.innerJoin(
agentWakeupRequests,
and(
eq(agentWakeupRequests.id, admission.wakeupRequestId),
eq(agentWakeupRequests.companyId, issueThreadInteractions.companyId),
eq(agentWakeupRequests.agentId, binding.agentId),
eq(agentWakeupRequests.runId, admission.id),
),
)
.where(
and(
eq(issueThreadInteractions.id, context.interactionId),
eq(issueThreadInteractions.companyId, binding.companyId),
eq(issueThreadInteractions.issueId, binding.issueId),
eq(issueThreadInteractions.sourceRunId, context.sourceRunId),
),
);
const rows = await (lockMode === "read"
? query
: lockMode === "nonblocking"
? query.for("update", { noWait: true })
: query.for("update"));
if (rows.length !== 1) return null;
const { interaction, delivery, source, wake } = rows[0]!;
if (
chain.interactionIds.has(interaction.id) ||
chain.deliveryIds.has(delivery.id) ||
chain.runIds.has(source.id)
)
return null;
chain.interactionIds.add(interaction.id);
chain.deliveryIds.add(delivery.id);
const sourceContext = record(source.contextSnapshot);
const sourceWake = record(sourceContext.paperclipWake);
// A follow-up question inherits no authority from its marker alone. Rebuild
// every parent proof from current durable state until the direct-chat root.
const parent =
sourceContext.source === "issue.interaction.respond"
? await resolveQuestionResponseChain(
tx,
{
companyId: binding.companyId,
agentId: binding.agentId,
issueId: binding.issueId,
runId: source.id,
},
sourceContext,
lockMode,
false,
chain,
)
: null;
if (sourceContext.source === "issue.interaction.respond" && !parent)
return null;
const provider =
parent?.provider ??
(
["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon", "speko"] as const
).find(
(candidate) =>
sourceContext.source === `chat:${candidate}` ||
sourceContext.source === `chat:${candidate}:recovery`,
);
const sourceIds = ids(sourceContext.wakeCommentIds);
const wakePayload = record(wake.payload);
// Legacy CLI adapters create the same durable provider interactions through
// the API. Their task binding lives in the saved wake rather than nativeIssueId.
// Keep the source task, answer receipt, actor and provider proofs below intact.
const sourceTaskMatches = source.runtimeMode === "native"
? source.nativeIssueId === binding.issueId
: source.runtimeMode === "legacy"
&& source.nativeIssueId === null
&& record(sourceWake.issue).id === binding.issueId;
if (
!provider ||
source.agentId !== binding.agentId ||
(!sourceTaskMatches && !(provider === "speko" && source.runtimeMode === "legacy" && source.nativeIssueId === null)) ||
sourceContext.issueId !== binding.issueId ||
(source.status !== "succeeded" &&
!(
source.status === "cancelled" &&
source.errorCode === "external_chat_continuation"
)) ||
sourceWake.externalChatProvider !== provider ||
!(
(sourceContext.paperclipHarnessCheckedOut === true &&
sourceWake.checkedOutByHarness === true) ||
(sourceContext.paperclipExternalChatExecutionBound === true &&
sourceWake.externalChatExecutionBound === true)
) ||
sourceIds.length !== 1 ||
sourceIds[0] !== context.sourceCommentId ||
(parent !== null &&
parent.marker.sourceCommentId !== context.sourceCommentId) ||
interaction.kind !== "ask_user_questions" ||
interaction.status !== "answered" ||
interaction.createdByAgentId !== binding.agentId ||
!interaction.resolvedByUserId ||
(interaction.sourceCommentId !== null &&
interaction.sourceCommentId !== context.sourceCommentId) ||
interaction.resolvedByAgentId !== null ||
!interaction.resolvedAt ||
record(interaction.result).cancelled === true ||
wake.requestedByActorType !== "user" ||
wake.requestedByActorId !== interaction.resolvedByUserId ||
wake.idempotencyKey !== `question-response:${interaction.id}` ||
["skipped", "cancelled"].includes(wake.status) ||
wakePayload.issueId !== binding.issueId ||
wakePayload.interactionId !== interaction.id ||
wakePayload.sourceRunId !== source.id ||
wakePayload.sourceCommentId !== context.sourceCommentId ||
wakePayload.externalChatContinuation !== true ||
!(
(delivery.status === "fallback_queued" &&
delivery.deliveryMode === "wake_fallback" &&
delivery.targetRunId === admission.id) ||
(delivery.status === "delivering" &&
delivery.targetRunId === null &&
delivery.deliveryMode === null)
)
)
return null;
let expected: ReturnType<typeof questionResponseDeliveryValues>;
try {
expected = questionResponseDeliveryValues(
interaction as unknown as AskUserQuestionsInteraction,
);
} catch {
return null;
}
if (
delivery.payloadSha256 !== expected.payloadSha256 ||
delivery.correlationId !== expected.correlationId
)
return null;
if (provider === "speko") {
// A model-selected interaction ID is insufficient. Require the exact
// canonical answer, a presented publication, and the durable signed-tool
// receipt committed atomically with that answer. No synthetic identity link.
const proofs = await tx.select({ receipt: chatVoiceToolCalls, session: chatVoiceSessions,
reply: chatVoiceReplies, publication: chatPublications, endpoint: chatEndpoints, connection: toolConnections,
conversation: chatConversations }).from(chatVoiceToolCalls)
.innerJoin(chatVoiceSessions, and(eq(chatVoiceSessions.id, chatVoiceToolCalls.sessionId), eq(chatVoiceSessions.companyId, binding.companyId)))
.innerJoin(chatVoiceReplies, and(eq(chatVoiceReplies.sessionId, chatVoiceSessions.id), eq(chatVoiceReplies.companyId, binding.companyId)))
.innerJoin(chatPublications, and(eq(chatPublications.id, chatVoiceReplies.publicationId), eq(chatPublications.companyId, binding.companyId)))
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatVoiceSessions.endpointId), eq(chatEndpoints.companyId, binding.companyId)))
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, binding.companyId)))
.innerJoin(chatConversations, and(eq(chatConversations.id, chatVoiceSessions.conversationId), eq(chatConversations.companyId, binding.companyId)))
.where(and(eq(chatVoiceToolCalls.companyId, binding.companyId), eq(chatVoiceToolCalls.tool, "answer_question"),
sql`${chatVoiceToolCalls.response}->>'interactionId' = ${interaction.id}`,
sql`${chatPublications.payload}->>'interactionId' = ${interaction.id}`,
eq(chatVoiceSessions.issueId, binding.issueId),
// Terminal acknowledgment publications use the same interaction ID.
// Only a question actually delivered before its answer can be proof.
lte(chatVoiceReplies.deliveredAt, interaction.resolvedAt)));
if (proofs.length === 0 && interaction.resolvedByRunId === null) {
// The authenticated task-page answer commits the canonical interaction
// and response delivery too. Bind it to the original voice requester;
// do not require hearing the question or fabricate a signed-tool receipt.
const origins = await tx.select({ session: chatVoiceSessions, endpoint: chatEndpoints,
connection: toolConnections, conversation: chatConversations, comment: issueComments,
inbound: chatDeliveries, link: chatMessageLinks }).from(chatMessageLinks)
.innerJoin(issueComments, and(eq(issueComments.id, chatMessageLinks.commentId), eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId)))
.innerJoin(chatDeliveries, and(eq(chatDeliveries.id, chatMessageLinks.deliveryId), eq(chatDeliveries.companyId, binding.companyId), eq(chatDeliveries.endpointId, chatMessageLinks.endpointId), eq(chatDeliveries.conversationId, chatMessageLinks.conversationId)))
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatDeliveries.principalId), eq(chatExternalPrincipals.companyId, binding.companyId), eq(chatExternalPrincipals.provider, "speko")))
.innerJoin(chatVoiceSessions, and(sql`${chatExternalPrincipals.externalId} = 'voice:' || ${chatVoiceSessions.id}::text`, eq(chatVoiceSessions.companyId, binding.companyId), eq(chatVoiceSessions.endpointId, chatMessageLinks.endpointId), eq(chatVoiceSessions.conversationId, chatMessageLinks.conversationId), eq(chatVoiceSessions.issueId, binding.issueId)))
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatVoiceSessions.endpointId), eq(chatEndpoints.companyId, binding.companyId)))
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, binding.companyId)))
.innerJoin(chatConversations, and(eq(chatConversations.id, chatVoiceSessions.conversationId), eq(chatConversations.companyId, binding.companyId), eq(chatConversations.endpointId, chatEndpoints.id)))
.where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.commentId, context.sourceCommentId), eq(chatMessageLinks.direction, "inbound")));
if (origins.length !== 1) return null;
const { session, endpoint, connection, conversation, comment, inbound, link } = origins[0]!;
if (comment.deletedAt || inbound.state !== "processed" || comment.authorUserId !== interaction.resolvedByUserId
|| session.callerId !== interaction.resolvedByUserId || !["member", "instance_admin", "local_board"].includes(session.callerAuthority)
|| session.assignedAgentId !== binding.agentId || endpoint.assignedAgentId !== binding.agentId
|| endpoint.provider !== "speko" || !["active", "verifying"].includes(endpoint.status)
|| !connection.enabled || connection.status !== "active" || connection.transport !== "voice"
|| session.generation !== record(endpoint.setup).runtimeGeneration
|| session.credentialFingerprint !== await currentVoiceCredentialFingerprint(tx, binding.companyId, connection.credentialSecretRefs)
|| !["active", "waiting"].includes(conversation.state) || conversation.issueId !== binding.issueId
|| (parent && (parent.marker.endpointId !== endpoint.id || parent.marker.conversationId !== conversation.id))) return null;
try {
const store = voiceSessionStore(tx, { allowLocalBoard: session.callerAuthority === "local_board" });
await store.authorizePrincipal(tx as unknown as VoiceTransaction, binding.companyId, endpoint.id, `voice:${session.id}`);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "55P03") throw error;
return null;
}
const marker: Marker = {
schema: "paperclip.external_chat_question_response.v1", interactionId: interaction.id,
responseDeliveryId: delivery.id, sourceRunId: source.id, sourceCommentId: context.sourceCommentId,
endpointId: endpoint.id, conversationId: conversation.id,
bindingSha256: nativeSha256({ binding: {companyId: binding.companyId, issueId: binding.issueId, runId: binding.runId, agentId: binding.agentId}, responseDeliveryId: delivery.id, payloadSha256: delivery.payloadSha256,
sourceRunId: source.id, sourceCommentId: context.sourceCommentId, sourceOrigin: sourceContext.source,
...(parent ? { sourceQuestionResponseSha256: parent.marker.bindingSha256 } : {}),
interaction: { id: interaction.id, payload: interaction.payload, result: interaction.result,
resolvedAt: interaction.resolvedAt.toISOString(), resolvedByUserId: interaction.resolvedByUserId },
boardResponse: { sessionId: session.id, callerId: session.callerId, generation: session.generation,
credentialFingerprint: session.credentialFingerprint, inboundId: inbound.id, linkId: link.id },
conversationGeneration: conversation.sessionGeneration, wakeId: wake.id }),
};
if (!mint && nativeSha256(record(context[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY])) !== nativeSha256(marker)) return null;
return { marker, provider, answeredAtMs: interaction.resolvedAt.getTime(),
interactionIds: [...(parent?.interactionIds ?? []), interaction.id],
authorizationContext: { ...context, source: "chat:speko", paperclipHarnessCheckedOut: false, paperclipExternalChatExecutionBound: true } };
}
if (proofs.length !== 1) return null;
const proof = proofs[0]!;
const { receipt, session, reply, publication, endpoint, connection, conversation } = proof;
const [original] = await tx.select({ comment: issueComments, inbound: chatDeliveries, link: chatMessageLinks })
.from(chatMessageLinks).innerJoin(issueComments, eq(issueComments.id, chatMessageLinks.commentId))
.innerJoin(chatDeliveries, eq(chatDeliveries.id, chatMessageLinks.deliveryId))
.where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.endpointId, endpoint.id),
eq(chatMessageLinks.conversationId, conversation.id), eq(chatMessageLinks.direction, "inbound"),
eq(chatMessageLinks.commentId, context.sourceCommentId), eq(issueComments.companyId, binding.companyId),
eq(issueComments.issueId, binding.issueId), eq(chatDeliveries.companyId, binding.companyId)));
if (!original || original.comment.deletedAt || original.inbound.state !== "processed"
|| original.comment.authorUserId !== interaction.resolvedByUserId
|| session.callerId !== interaction.resolvedByUserId || !["member", "instance_admin", "local_board"].includes(session.callerAuthority)
|| session.assignedAgentId !== binding.agentId || endpoint.assignedAgentId !== binding.agentId
|| endpoint.provider !== "speko" || !["active", "verifying"].includes(endpoint.status)
|| !connection.enabled || connection.status !== "active" || connection.transport !== "voice"
|| session.generation !== record(endpoint.setup).runtimeGeneration
|| session.credentialFingerprint !== await currentVoiceCredentialFingerprint(tx, binding.companyId, connection.credentialSecretRefs)
|| !["active", "waiting"].includes(conversation.state) || conversation.issueId !== binding.issueId
|| publication.conversationId !== conversation.id || publication.endpointId !== endpoint.id || publication.issueId !== binding.issueId
|| publication.state !== "published" || !publication.providerMessageId || !reply.deliveredAt
|| reply.deliveredAt > interaction.resolvedAt || receipt.response.status !== "answered"
|| receipt.response.resultSha256 !== nativeSha256(interaction.result)
|| (parent && (parent.marker.endpointId !== endpoint.id || parent.marker.conversationId !== conversation.id))) return null;
const marker: Marker = {
schema: "paperclip.external_chat_question_response.v1", interactionId: interaction.id,
responseDeliveryId: delivery.id, sourceRunId: source.id, sourceCommentId: context.sourceCommentId,
endpointId: endpoint.id, conversationId: conversation.id,
bindingSha256: nativeSha256({ binding: { companyId: binding.companyId, issueId: binding.issueId, runId: binding.runId, agentId: binding.agentId }, responseDeliveryId: delivery.id, payloadSha256: delivery.payloadSha256,
sourceRunId: source.id, sourceCommentId: context.sourceCommentId, sourceOrigin: sourceContext.source,
...(parent ? { sourceQuestionResponseSha256: parent.marker.bindingSha256 } : {}),
interaction: { id: interaction.id, payload: interaction.payload, result: interaction.result,
resolvedAt: interaction.resolvedAt.toISOString(), resolvedByUserId: interaction.resolvedByUserId },
voiceResponse: { receiptId: receipt.id, fingerprint: receipt.fingerprint, response: receipt.response,
sessionId: session.id, callerId: session.callerId, generation: session.generation, credentialFingerprint: session.credentialFingerprint },
publicationId: publication.id, replyId: reply.id, inboundId: original.inbound.id, linkId: original.link.id,
conversationGeneration: conversation.sessionGeneration, wakeId: wake.id }),
};
if (!mint && nativeSha256(record(context[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY])) !== nativeSha256(marker)) return null;
return { marker, provider, answeredAtMs: interaction.resolvedAt.getTime(),
interactionIds: [...(parent?.interactionIds ?? []), interaction.id],
authorizationContext: { ...context, source: "chat:speko", paperclipHarnessCheckedOut: false, paperclipExternalChatExecutionBound: true } };
}
const actionQuery = tx
.select({
action: chatActions,
publication: chatPublications,
link: chatMessageLinks,
inbound: chatDeliveries,
comment: issueComments,
conversation: chatConversations,
endpoint: chatEndpoints,
identity: chatIdentityLinks,
})
.from(chatActions)
.innerJoin(
chatPublications,
and(
sql`${chatActions.payload}->>'publicationId' = ${chatPublications.id}::text`,
eq(chatPublications.companyId, chatActions.companyId),
eq(chatPublications.endpointId, chatActions.endpointId),
eq(chatPublications.conversationId, chatActions.conversationId),
),
)
.innerJoin(
chatMessageLinks,
and(
eq(chatMessageLinks.companyId, chatActions.companyId),
eq(chatMessageLinks.endpointId, chatActions.endpointId),
eq(chatMessageLinks.conversationId, chatActions.conversationId),
eq(chatMessageLinks.commentId, context.sourceCommentId),
eq(chatMessageLinks.direction, "inbound"),
),
)
.innerJoin(
chatDeliveries,
and(
eq(chatDeliveries.id, chatMessageLinks.deliveryId),
eq(chatDeliveries.companyId, chatActions.companyId),
eq(chatDeliveries.endpointId, chatActions.endpointId),
eq(chatDeliveries.conversationId, chatActions.conversationId),
or(eq(chatActions.kind, "photon_interaction"), eq(chatDeliveries.principalId, chatActions.principalId)),
),
)
.innerJoin(
issueComments,
and(
eq(issueComments.id, chatMessageLinks.commentId),
eq(issueComments.companyId, chatActions.companyId),
eq(issueComments.issueId, binding.issueId),
),
)
.innerJoin(
chatConversations,
and(
eq(chatConversations.id, chatActions.conversationId),
eq(chatConversations.companyId, chatActions.companyId),
eq(chatConversations.endpointId, chatActions.endpointId),
),
)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.id, chatActions.endpointId),
eq(chatEndpoints.companyId, chatActions.companyId),
),
)
.innerJoin(
chatIdentityLinks,
and(
eq(chatIdentityLinks.companyId, chatActions.companyId),
eq(chatIdentityLinks.endpointId, chatActions.endpointId),
eq(chatIdentityLinks.principalId, chatActions.principalId),
),
)
.where(
and(
eq(chatActions.companyId, binding.companyId),
inArray(chatActions.kind, ["question_answer", "question_form_submit", "photon_interaction"]),
eq(chatActions.status, "processed"),
sql`${chatActions.payload}->>'interactionId' = ${interaction.id}`,
sql`${chatActions.result}->>'interactionId' = ${interaction.id}`,
),
);
// GitHub and Slack forms without native controls use the ordinary
// authenticated Board answer route, whose pending→answered CAS
// creates the response delivery above atomically. Do not fabricate a provider
// action: bind that distinct receipt to the original linked person instead.
const boardQuery = tx
.select({
action: sql<null>`null`,
publication: chatPublications,
link: chatMessageLinks,
inbound: chatDeliveries,
comment: issueComments,
conversation: chatConversations,
endpoint: chatEndpoints,
identity: chatIdentityLinks,
})
.from(chatPublications)
.innerJoin(
chatMessageLinks,
and(
eq(chatMessageLinks.companyId, chatPublications.companyId),
eq(chatMessageLinks.endpointId, chatPublications.endpointId),
eq(chatMessageLinks.conversationId, chatPublications.conversationId),
eq(chatMessageLinks.commentId, context.sourceCommentId),
eq(chatMessageLinks.direction, "inbound"),
),
)
.innerJoin(
chatDeliveries,
and(
eq(chatDeliveries.id, chatMessageLinks.deliveryId),
eq(chatDeliveries.companyId, chatMessageLinks.companyId),
eq(chatDeliveries.endpointId, chatMessageLinks.endpointId),
eq(chatDeliveries.conversationId, chatMessageLinks.conversationId),
),
)
.innerJoin(
issueComments,
and(
eq(issueComments.id, chatMessageLinks.commentId),
eq(issueComments.companyId, chatMessageLinks.companyId),
eq(issueComments.issueId, binding.issueId),
),
)
.innerJoin(
chatConversations,
and(
eq(chatConversations.id, chatPublications.conversationId),
eq(chatConversations.companyId, chatPublications.companyId),
eq(chatConversations.endpointId, chatPublications.endpointId),
),
)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.id, chatPublications.endpointId),
eq(chatEndpoints.companyId, chatPublications.companyId),
eq(chatEndpoints.provider, provider),
),
)
.innerJoin(
chatIdentityLinks,
and(
eq(chatIdentityLinks.companyId, chatDeliveries.companyId),
eq(chatIdentityLinks.endpointId, chatDeliveries.endpointId),
eq(chatIdentityLinks.principalId, chatDeliveries.principalId),
),
)
.where(
and(
eq(chatPublications.companyId, binding.companyId),
eq(chatPublications.issueId, binding.issueId),
sql`${chatPublications.idempotencyKey} = 'interaction:' || ${interaction.id} || ':' || ${chatEndpoints.id}::text`,
notExists(
tx
.select({ id: chatActions.id })
.from(chatActions)
.where(
and(
eq(chatActions.companyId, binding.companyId),
sql`${chatActions.payload}->>'interactionId' = ${interaction.id}`,
inArray(chatActions.kind, [
"question_answer",
"question_form_submit",
]),
),
),
),
),
);
// Slack native actions remain authoritative when present. The Board query
// excludes every native answer token, including unfinished ones; its result
// is re-read under the same identity and row locks below.
const boardAnswer = provider === "github" ||
(provider === "slack" && (await boardQuery).length === 1);
const evidenceQuery = boardAnswer ? boardQuery : actionQuery;
let expectedPrincipalId: string | null = null;
if (lockMode !== "read") {
// Identity changes take this advisory lock before their row lock. Resolve
// the candidate without row locks, acquire that same policy fence, then
// reread everything under locks. A changed candidate is never adopted.
const candidates = await evidenceQuery;
if (candidates.length !== 1 || !candidates[0]!.inbound.principalId)
return null;
expectedPrincipalId = candidates[0]!.inbound.principalId;
const key = `chat-identity:${binding.companyId}:${expectedPrincipalId}`;
if (lockMode === "blocking") {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`,
);
} else {
const [lock] = await tx.execute(
sql`select pg_try_advisory_xact_lock(hashtextextended(${key}, 0)) as acquired`,
);
if (!lock?.acquired)
throw Object.assign(
new Error("paperclip_external_chat_wait_authorization_contended"),
{ code: "55P03" },
);
}
}
const actions = await (lockMode === "read"
? evidenceQuery
: lockMode === "nonblocking"
? evidenceQuery.for("update", { noWait: true })
: evidenceQuery.for("update"));
// One exact provider response, never whichever responder happened to be last.
if (
actions.length !== 1 ||
(expectedPrincipalId !== null &&
actions[0]!.inbound.principalId !== expectedPrincipalId)
)
return null;
const {
action,
publication,
link,
inbound,
comment,
conversation,
endpoint,
identity,
} = actions[0]!;
if (action) {
if (chain.actionIds.has(action.id)) return null;
chain.actionIds.add(action.id);
}
if (
parent &&
(parent.marker.endpointId !== endpoint.id ||
parent.marker.conversationId !== conversation.id)
)
return null;
const boardRuntime = record(record(inbound.normalizedEvent).runtimeContext);
const boardGeneration = record(endpoint.setup).runtimeGeneration;
if (!action) {
const card = record(record(publication.payload).card);
const links = Array.isArray(card.actions) ? card.actions.map(record) : [];
if (
!boardAnswer ||
interaction.resolvedByRunId !== null ||
publication.payload.progressState !== "waiting_for_input" ||
card.kind !== "question" ||
(card.actions !== undefined && !Array.isArray(card.actions)) ||
links.some((link) => link.type !== "link") ||
!publication.publishedAt ||
publication.publishedAt > interaction.resolvedAt ||
(provider === "github" && (
typeof boardGeneration !== "number" ||
!Number.isSafeInteger(boardGeneration) ||
boardGeneration < 0 ||
boardRuntime.generation !== boardGeneration ||
typeof boardRuntime.credentialFingerprint !== "string" ||
!/^[a-f0-9]{64}$/.test(boardRuntime.credentialFingerprint)
))
)
return null;
} else if (action.kind === "question_answer") {
const answers = record(interaction.result).answers;
const selectedAnswer = Array.isArray(answers)
? answers
.map(record)
.find((answer) => answer.questionId === action.payload.questionId)
: null;
const questions = record(interaction.payload).questions;
const selectedQuestion = Array.isArray(questions)
? questions
.map(record)
.find((question) => question.id === action.payload.questionId)
: null;
if (
record(action.result).interactionStatus !== "answered" ||
!selectedAnswer ||
!selectedQuestion ||
!Array.isArray(selectedQuestion.options) ||
!Array.isArray(answers) || answers.length !== 1 ||
!Array.isArray(questions) || questions.length !== 1 ||
Boolean(selectedAnswer.otherText) ||
(Array.isArray(action.payload.selectionOptions)
? provider !== "slack" || !completedMultiChoiceMatches(selectedQuestion, selectedAnswer, action.payload)
: selectedQuestion.selectionMode !== "single" ||
ids(selectedAnswer.optionIds).length !== 1 ||
ids(selectedAnswer.optionIds)[0] !== action.payload.optionId ||
!selectedQuestion.options.some(option => record(option).id === action.payload.optionId))
)
return null;
} else if (action.kind === "photon_interaction") {
if (provider !== "imessage-photon" || !photonAnswersMatch(interaction as unknown as AskUserQuestionsInteraction, action.result) || action.payload.version !== 1 || action.payload.sessionGeneration !== conversation.sessionGeneration || typeof action.payload.expiresAt !== "string" || Date.parse(action.payload.expiresAt) <= interaction.resolvedAt.getTime() || !Number.isFinite(Date.parse(action.payload.expiresAt))) return null;
} else if (
!completedQuestionFormMatchesInteraction(
interaction as unknown as AskUserQuestionsInteraction,
action,
)
) {
return null;
}
if (
inbound.state !== "processed" ||
comment.deletedAt !== null ||
(action?.kind !== "photon_interaction" && comment.authorUserId !== interaction.resolvedByUserId) ||
conversation.issueId !== binding.issueId ||
!["active", "waiting"].includes(conversation.state) ||
endpoint.provider !== provider ||
endpoint.assignedAgentId !== binding.agentId ||
(endpoint.status !== "active" &&
!(provider === "imessage-photon" && endpoint.status === "verifying" && record(endpoint.setup).step === "test")) ||
publication.state !== "published" ||
!publication.providerMessageId ||
publication.issueId !== binding.issueId ||
record(publication.payload).interactionId !== interaction.id ||
identity.status !== "linked" ||
identity.paperclipUserId !== interaction.resolvedByUserId
)
return null;
const marker: Marker = {
schema: "paperclip.external_chat_question_response.v1",
interactionId: interaction.id,
responseDeliveryId: delivery.id,
sourceRunId: source.id,
sourceCommentId: comment.id,
endpointId: endpoint.id,
conversationId: conversation.id,
bindingSha256: nativeSha256({
binding: {
companyId: binding.companyId,
issueId: binding.issueId,
runId: binding.runId,
agentId: binding.agentId,
},
responseDeliveryId: delivery.id,
payloadSha256: delivery.payloadSha256,
sourceRunId: source.id,
sourceIds,
sourceOrigin: sourceContext.source,
// Omit this field for a direct-chat parent so deployed v1 proofs remain
// byte-compatible. A chained proof commits to every validated ancestor.
...(parent
? { sourceQuestionResponseSha256: parent.marker.bindingSha256 }
: {}),
interaction: {
id: interaction.id,
payload: interaction.payload,
result: interaction.result,
resolvedAt: interaction.resolvedAt.toISOString(),
resolvedByUserId: interaction.resolvedByUserId,
createdByAgentId: interaction.createdByAgentId,
},
wake: {
id: wake.id,
actor: wake.requestedByActorId,
payload: wake.payload,
},
...(action
? {
action: {
id: action.id,
principalId: action.principalId,
payload: action.payload,
result: action.result,
},
}
: {
boardResponse: {
schema: `paperclip.${provider}_board_question_response.v1`,
principalId: inbound.principalId,
...(provider === "github" ? {
runtimeGeneration: boardGeneration,
credentialFingerprint: boardRuntime.credentialFingerprint,
} : {}),
},
}),
publication: {
id: publication.id,
providerMessageId: publication.providerMessageId,
},
linkId: link.id,
inboundId: inbound.id,
comment: { id: comment.id, body: comment.body },
conversation: {
id: conversation.id,
generation: conversation.sessionGeneration,
},
endpointId: endpoint.id,
identityId: identity.id,
}),
};
if (
!mint &&
nativeSha256(record(context[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY])) !==
nativeSha256(marker)
)
return null;
return {
marker,
provider,
answeredAtMs: interaction.resolvedAt.getTime(),
interactionIds: [...(parent?.interactionIds ?? []), interaction.id],
authorizationContext: {
...context,
source: `chat:${provider}`,
paperclipHarnessCheckedOut: false,
paperclipExternalChatExecutionBound: true,
},
};
}