{"record":{"id":"b1e523d9067710ea","repo":"mastra-ai/mastra","slug":"github-subscription-subscription-id-is-missing","errorCode":null,"errorMessage":"GitHub subscription ${subscription.id} is missing its session binding.","messagePattern":"GitHub subscription (.+?) is missing its session binding\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"mastracode/factory/src/integrations/github/webhook.ts","lineNumber":337,"sourceCode":"    terminal,\n    metadata: {\n      ...metadata,\n      pullRequestNumber: metadata.pullRequestNumber,\n      repositoryId: metadata.repositoryId,\n      installationId: metadata.installationId,\n    },\n    payload,\n  };\n}\n\nasync function resolveSubscriptionSession(\n  controller: MountedMastraCode['controller'],\n  subscription: GithubSignalSubscriptionRow,\n  github?: GithubWebhookDispatchIntegration,\n) {\n  const { sessionId, resourceId, threadId } = subscription;\n  if (!sessionId || !resourceId || !threadId) {\n    throw new Error(`GitHub subscription ${subscription.id} is missing its session binding.`);\n  }\n  // Read the thread straight from storage before touching sessions. This answers\n  // two questions at once, and `queryThreadById` does it without constructing a\n  // session (so no workspace or sandbox is provisioned just to make the check).\n  //\n  // First: do we even have this thread? A pull request's events can reach a\n  // deployment that never owned the subscribed thread, and delivery must not\n  // fabricate a session for a thread that lives somewhere else.\n  //\n  // Second: which resource owns it? The subscription records the Factory project\n  // as its `resourceId`, but an unscoped session is registered under its own id,\n  // so the stored value routinely names a resource that does not own the thread.\n  // The thread row is the authoritative answer; the stored id is only a fallback.\n  const thread = await controller.queryThreadById({ threadId });\n  if (!thread) return undefined;\n  const ownerResourceId = thread.resourceId || resourceId;\n  const scope = subscription.sessionScope || undefined;\n  let session = await controller.getSessionByResource(ownerResourceId, scope);","sourceCodeStart":319,"sourceCodeEnd":355,"githubUrl":"https://github.com/mastra-ai/mastra/blob/75dd419e613fe9c39f846ffc500716141b74fda6/mastracode/factory/src/integrations/github/webhook.ts#L319-L355","documentation":"resolveSubscriptionSession maps an incoming GitHub webhook-triggered signal subscription back to its Factory session. Every subscription row must carry sessionId, resourceId, and threadId; any missing field means the subscription was persisted incompletely and cannot be resolved to a runnable session. The code throws with the subscription ID so the broken row can be located and repaired.","triggerScenarios":"A GitHub PR webhook fires and dispatchGithubWebhook resolves a subscription row (from listPullRequestSubscriptionsForWebhook) whose sessionId, resourceId, or threadId column is null/empty when resolveSubscriptionSession runs.","commonSituations":"A subscription was created before the session-binding columns were added (schema migration gap); the session that created the subscription crashed mid-persist; manual DB edits or a partial write left the row incomplete; subscription created via an older code path that did not capture threadId.","solutions":["Inspect the subscription row by ID in integration storage and fill in the missing sessionId/resourceId/threadId, or retire the broken subscription","Re-create the subscription from a healthy Factory session so all three binding fields persist","Add a NOT NULL / validation constraint or creation-path check so subscriptions are never written without a full session binding","In the webhook dispatcher, catch this error per-subscription and skip/log it instead of failing the whole delivery"],"exampleFix":"// before\nconst { sessionId, resourceId, threadId } = subscription;\nif (!sessionId || !resourceId || !threadId) throw new Error(`...missing its session binding.`);\n// after (defensive dispatch)\ntry {\n  const session = await resolveSubscriptionSession(controller, subscription, github);\n} catch (err) {\n  logger.warn('Skipping subscription %s: %s', subscription.id, (err as Error).message);\n  await retireSubscription(subscription.id, status);\n  return null;\n}","handlingStrategy":"validation","validationCode":"function subscriptionIsFullyBound(s: GithubSignalSubscriptionRow): boolean {\n  return Boolean(s.sessionId && s.resourceId && s.threadId);\n}\nif (!subscriptionIsFullyBound(subscription)) {\n  logger.warn('Skipping unbound subscription', { id: subscription.id });\n  return null;\n}","typeGuard":"function hasSessionBinding(s: GithubSignalSubscriptionRow): s is GithubSignalSubscriptionRow &\n  { sessionId: string; resourceId: string; threadId: string } {\n  return typeof s.sessionId === 'string' && s.sessionId.length > 0 &&\n    typeof s.resourceId === 'string' && s.resourceId.length > 0 &&\n    typeof s.threadId === 'string' && s.threadId.length > 0;\n}","tryCatchPattern":"try {\n  const session = await resolveSubscriptionSession(controller, subscription, github);\n} catch (err) {\n  if ((err as Error).message.includes('missing its session binding')) {\n    await retireSubscription(subscription.id, 'closed');\n    logger.warn('Retired subscription with missing binding', { id: subscription.id });\n    return;\n  }\n  throw err;\n}","preventionTips":["Add NOT NULL constraints on sessionId/resourceId/threadId in the subscription table","Validate full binding before persisting new subscriptions","Run a migration backfill to retire or repair legacy rows missing bindings","Catch per-subscription resolution errors in the webhook dispatcher so one bad row doesn't block delivery"],"tags":["github","webhook","data-integrity"],"backgroundTag":"missing-session-binding","analyzedSha":"75dd419e613fe9c39f846ffc500716141b74fda6","analyzedAt":"2026-08-30T00:15:31.844Z","schemaVersion":2},"datasetVersion":"2026-08-30T03:17:51.788Z"}