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"; 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; /** 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; interactionIds: Set; deliveryIds: Set; actionIds: Set; }; const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; const ids = (value: unknown): string[] => Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []; 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>(); 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 = {}; 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 GitHub's 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 { 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 { 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.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 [ "source", "issueId", "interactionId", "interactionKind", "interactionStatus", "sourceRunId", "sourceCommentId", "externalChatContinuation", "wakeCommentId", "wakeCommentIds", ]) { if ( nativeSha256(admitted[key] ?? null) !== nativeSha256(context[key] ?? null) ) 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, run.wakeupRequestId), eq(agentWakeupRequests.companyId, issueThreadInteractions.companyId), eq(agentWakeupRequests.agentId, binding.agentId), eq(agentWakeupRequests.runId, binding.runId), ), ) .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 === binding.runId) || (delivery.status === "delivering" && delivery.targetRunId === null && delivery.deliveryMode === null) ) ) return null; let expected: ReturnType; 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 has no executable question callback. Its published link leads to // 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`, 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, "github"), ), ) .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", ]), ), ), ), ), ); const evidenceQuery = provider === "github" ? 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 ( provider !== "github" || 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 || 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 || selectedQuestion.selectionMode !== "single" || ids(selectedAnswer.optionIds).length !== 1 || ids(selectedAnswer.optionIds)[0] !== action.payload.optionId || !Array.isArray(selectedQuestion.options) || !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.github_board_question_response.v1", principalId: inbound.principalId, 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, }, }; }