{"record":{"id":"d5e2dcdae34d7328","repo":"paperclipai/paperclip","slug":"external-chat-progress-state-is-invalid","errorCode":null,"errorMessage":"External chat progress state is invalid","messagePattern":"External chat progress state is invalid","errorType":"validation","errorClass":"UnsafeChatPublicationError","httpStatus":null,"severity":"error","filePath":"server/src/services/chat-publication-projection.ts","lineNumber":391,"sourceCode":"}\n\n/**\n * Builds the complete provider-bound payload. This is intentionally the only\n * API that accepts attachments or rich interaction metadata.\n */\nexport function projectSafeChatPublication(\n  input: ChatPublicationProjectionInput,\n): ProjectedSafeChatPublicationPayload {\n  if (\n    input.classification !== \"external\" ||\n    !PUBLICATION_SOURCES.has(input.source)\n  ) {\n    throw new UnsafeChatPublicationError(\n      \"Chat publication source must be explicitly classified for external delivery\",\n    );\n  }\n  if (input.progressState && !PROGRESS_STATES.has(input.progressState)) {\n    throw new UnsafeChatPublicationError(\n      \"External chat progress state is invalid\",\n    );\n  }\n  const attachmentIds = projectAttachmentIds(input.attachmentIds);\n  const interaction = input.interaction ? projectCard(input.interaction) : null;\n\n  return {\n    text: projectSafeChatPublicationText(input.text),\n    ...(attachmentIds ? { attachmentIds } : {}),\n    ...(input.progressState ? { progressState: input.progressState } : {}),\n    ...(interaction ?? {}),\n  };\n}\n","sourceCodeStart":373,"sourceCodeEnd":405,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/chat-publication-projection.ts#L373-L405","documentation":"projectSafeChatPublication builds the provider-bound payload for external chat delivery (Slack etc.). Before projecting, it validates every field against allowlists; this throw fires when the optional progressState field is present but is not one of the allowed states (queued, working, waiting_for_input, approval_needed, completed). It is a fail-closed guard: an UnsafeChatPublicationError is raised rather than letting a malformed progress value reach an external provider.","triggerScenarios":"Calling projectSafeChatPublication with input.progressState set to a string outside PROGRESS_STATES — e.g. a caller passes \"in_progress\", \"done\", \"\", a lowercase/uppercase variant like \"Working\", or a stale enum value after the PROGRESS_STATES set changed.","commonSituations":"A service layer maps an internal task status to a progress state with an incomplete switch/table that leaks raw internal status strings; a new progress state was added to a type but the PROGRESS_STATES allowlist was not updated; deserialized data from an older DB row or API payload carries a retired state value.","solutions":["Check the value of input.progressState at the call site and compare it against the allowed set (queued, working, waiting_for_input, approval_needed, completed) in chat-publication-projection.ts:77","Fix the internal-status-to-progress-state mapping so only whitelisted strings are forwarded, or omit progressState entirely when there is no valid mapping","If a new state is legitimately needed, add it to the PROGRESS_STATES set and to the SafeChatPublicationPayload type, keeping db/shared/server/ui contracts in sync","Add a unit test covering every progressState value the caller can produce"],"exampleFix":"// before\nawait publish({ source: \"agent_message\", classification: \"external\", progressState: task.status });\n// after\nconst PROGRESS_MAP = { pending: \"queued\", running: \"working\", blocked: \"waiting_for_input\", done: \"completed\" } as const;\nconst progressState = PROGRESS_MAP[task.status as keyof typeof PROGRESS_MAP];\nawait publish({ source: \"agent_message\", classification: \"external\", ...(progressState ? { progressState } : {}) });","handlingStrategy":"validation","validationCode":"const ALLOWED = new Set([\"queued\",\"working\",\"waiting_for_input\",\"approval_needed\",\"completed\"]);\nfunction isValidProgressState(v) { return v === undefined || (typeof v === \"string\" && ALLOWED.has(v)); }\nif (!isValidProgressState(input.progressState)) throw new Error(`invalid progressState: ${input.progressState}`);","typeGuard":"const isProgressState = (v: unknown): v is \"queued\"|\"working\"|\"waiting_for_input\"|\"approval_needed\"|\"completed\" =>\n  typeof v === \"string\" && [\"queued\",\"working\",\"waiting_for_input\",\"approval_needed\",\"completed\"].includes(v);","tryCatchPattern":null,"preventionTips":["Derive progressState from a typed union via an exhaustive status->state mapping, never forward raw strings","Keep the PROGRESS_STATES allowlist and the shared type in lockstep; add a test per state value","Omit progressState (undefined) when no mapping exists instead of guessing a value"],"tags":["validation","chat","whitelist","internal-invariant"],"backgroundTag":"invalid-enum-value","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-22T06:17:15.046Z"}