ℹ️ Adding a
cascade-toolsgadget (CLI command consumed by agents)? Seesrc/gadgets/README.mdfor the authoring contract — declarative metadata (cliAliases,fileInputAlternatives,examples), the structured error envelope, and the single-entrypoint invariant. PM provider registration (this file) is a separate surface.
CASCADE's PM providers (Trello, JIRA, Linear, and any future Asana/GitLab/ClickUp) are built on a provider manifest pattern. One file describes the provider end-to-end; one registry iterates manifests; a behavioral conformance harness guarantees each manifest satisfies its declared contracts.
This document is the canonical guide for adding a new PM provider. The history of how the contract evolved lives in CHANGELOG.md.
A new PM provider is ONE manifest backed by ONE provider folder + ONE wizard folder.
src/integrations/pm/<provider>/
index.ts // registerPMProvider(<provider>Manifest) on module load
manifest.ts // the PMProviderManifest object
client.ts // provider API client (GraphQL, REST, etc.)
adapter.ts // PMProvider implementation
router-adapter.ts // RouterPlatformAdapter implementation
triggers/ // trigger handlers for webhook events
webhook.ts // parseWebhookPayload + (optional) custom signature verifier
platform-client.ts// PlatformCommentClient (ack comments)
web/src/components/projects/pm-providers/<provider>/
index.ts // registerProviderWizard(<provider>ProviderWizard) on module load
wizard.ts // ProviderWizardDefinition (steps, save transform, edit hydration)
state.ts // provider-owned WizardState slice, actions, reducer, defaults
hooks.ts // provider-owned discovery/mutation/webhook hooks + auth wrappers
webhook-step.tsx // optional provider webhook UX composition around shared steps
steps.tsx // optional custom-step components only
The registries are the only surface the rest of the codebase sees. A new provider wires itself through the backend PM barrel and the frontend PM-provider barrel; it does not edit shared wizard orchestration (pm-wizard.tsx, pm-wizard-hooks.ts, or pm-wizard-common-steps.tsx). The shared dashboard state file only composes provider-owned state slices and remains the temporary explicit exception documented in step 4 below.
See src/integrations/pm/manifest.ts for the authoritative type. Summary:
| Field | What it does |
|---|---|
id |
Stable slug (kebab/lowercase). Used as the webhook route segment, job type, and registry key. |
label |
Human-readable name shown in the dashboard provider-select. |
category |
Literal 'pm'. |
credentialRoles |
List of credential slots (api_key, webhook_secret, etc.) with env-var keys + optional flag. |
webhookRoute |
Conventionally /${id}/webhook. Enforced by the conformance harness. |
verifyWebhookSignature |
(rawBody, headers, secret) => boolean. Use makeHmacSha256Verifier from _shared/webhook-verifier.ts unless your provider has a non-standard signing scheme. |
routerAdapter |
Your RouterPlatformAdapter implementation — handles parsing, dispatching, and ack. |
extractProjectIdFromJob |
(jobData) => Promise<projectId | null>. Must return null for jobs belonging to other providers. Forgetting this invariant caused the Linear-worker-without-credentials bug (PR #1118). |
pmIntegration |
Your PMIntegration implementation — the agent-facing provider API. |
triggerHandlers |
Array of TriggerHandler instances for webhook events. |
platformClientFactory |
(projectId) => PlatformCommentClient. Used by the router to post ack comments; must pull auth headers from _shared/auth-headers.ts. |
isSelfAuthoredHook? |
Optional — returns true when the event was authored by CASCADE itself (for loop prevention). |
createLabel? |
Optional — enables the wizard's "Create label" button. Called via the generic pm.discovery.createLabel tRPC endpoint; signature is ({credentials, containerId, name, color?}) => {id, name, color}. |
createCustomField? |
Optional — enables wizard-driven custom-field creation. Called via pm.discovery.createCustomField; signature is ({credentials, containerId, name}) => {id, name, type}. JIRA fields are global (the hook ignores containerId). |
configToCredentials? |
Optional — promotes non-secret connection fields from project_integrations.config into the credentials bag createDiscoveryProvider consumes. Signature: (config: unknown) => Record<string, string>. Invoked only on the projectId path of pm.discovery.*; project_credentials values win on key collisions. Declare this when your provider stores tenant/host info in config instead of credentials (JIRA's baseUrl → base_url). Without it, edit-mode wizard re-verification constructs a client with empty host info. |
| Field | What it does |
|---|---|
configSchema?: z.ZodType |
Declarative Zod schema for the persisted integration config. The conformance harness asserts round-trip identity — the #1138/#1142 bug class (projectId stripped by Zod twice) becomes a CI failure instead of a production outage. |
configFixture? |
Sample config used by the harness's round-trip asserter. Must parse against configSchema. |
discoveryCapabilities? |
{ teams?, boards?, labels?, states?, projects?, containers?, customFields?, currentUser? }. Each flag means "adapter.discover(capability, args) returns a list of that shape" (or a single {id, name, displayName?} object for currentUser). The generic pm.discover tRPC endpoint dispatches through this registry. |
createDiscoveryProvider? |
(opts) => PMProvider. Factory producing a discovery-scoped adapter outside a project context (wizard setup, before the config is saved). Receives raw credentials from the wizard. |
wizardSpec? |
{ steps: Array<StandardStep | CustomStep> }. Declarative step list the shared wizard generator renders. Standard kinds: credentials, container-pick, status-mapping, label-mapping, webhook-url-display, project-scope, custom-field-mapping. |
lifecycle? |
{ enabled: true, fixtureKey: string }. Opts into the behavioral conformance harness's full lifecycle scenario. fixtureKey is looked up in the test-local LIFECYCLE_FIXTURES registry — the manifest doesn't import from tests/helpers/. |
Discovery must return the complete provider list. A discovery capability that backs a wizard picker (e.g.
container-pickforprojects/boards/teams) must return every item from the provider, not just the first page. The dashboard picker filters client-side (the sharedComboboxdoes the search locally), so a provider adapter that returns a truncated first page silently hides everything past it — the operator can neither see nor search for the missing entries. Provider adapters must therefore paginate the underlying API until it reports the last page. The reference case is JIRA'sjiraClient.searchProjects()(src/jira/client.ts): JIRA's/rest/api/3/project/searchendpoint is paginated, so the method loops onisLast/startAt(with aMAX_PROJECT_PAGESsafety cap) to collect all projects before returning. (A server-sidequeryparam + async debounced picker is the scalable follow-up for orgs with thousands of items, but full pagination is the correct baseline.)
See web/src/components/projects/pm-providers/types.ts. Summary:
| Field | What it does |
|---|---|
id |
Must match the backend manifest id. |
label |
Shown in the provider-select dropdown. |
steps |
Array of { id, title, Component, isComplete }. The generic wizard renders them in order. |
buildIntegrationConfig |
Transforms wizard state into the integration config payload sent at save time. |
buildEditState |
Hydrates edit-mode state from saved config + configured credential keys. This is provider-owned and must never return raw credential values. |
isSetupComplete |
(state) => boolean. True when the wizard can be saved. |
useProviderHooks? |
Optional — composes the provider's React hooks (discovery, label creation, custom-field creation) inside a shell component. Return value flows into each step's providerHooks prop. |
ManifestProviderWizardSection (web/src/components/projects/pm-providers/manifest-section.tsx) is the shell component that hosts the unconditional useProviderHooks call — it's only mounted when a manifest is registered for the active provider, so React's rules-of-hooks hold.
Frontend provider ownership is intentionally broad:
- State slice — provider credential/config fields, action types, reducer cases, defaults, and provider-specific selectors live in
pm-providers/<provider>/state.ts.pm-wizard-state.tscomposes those slices and is the only shared dashboard state surface that may need a new-provider edit while the shared state type still aggregates all provider fields. - Config serialization —
ProviderWizardDefinition.buildIntegrationConfiginpm-providers/<provider>/wizard.tsis the only save-payload transform for that provider. - Edit hydration —
ProviderWizardDefinition.buildEditStaterestores saved provider config into wizard state and sets stored-credential flags from configured credential keys; sharedpm-wizard.tsxonly dispatches the returned partial state. - Auth metadata —
auth,credentialPersistence, andformatVerificationDisplaylive in the provider wizard definition. Shared verification, credential readiness, mutation auth, and save code read this metadata instead of branching by provider. - Hooks and webhook UX — discovery, label/custom-field creation, webhook list normalization, webhook create/delete controls, signing-secret fields, and provider-specific mutation wrappers live under
pm-providers/<provider>/hooks.tsand provider step adapters such aswebhook-step.tsx. Sharedpm-wizard-hooks.tsstays provider-agnostic.
Single-source-of-truth utilities live in src/integrations/pm/_shared/:
auth-headers.ts—linearAuthHeader,githubAuthHeader,jiraAuthHeader. The session that produced spec 006 shipped aBearer-prefix bug from three divergent copies of the Linear builder (PR #1119). Use the shared function.webhook-verifier.ts—makeHmacSha256Verifier({ headerName, headerPrefix? })for the common case. Opt-out semantics (secret =null→ alwaystrue) preserve existing router behavior. JIRA (hex +sha256=prefix) and Linear (hex, no prefix) both consume this factory.label-id-resolver.ts—resolveLabelId(slot, mapping, ctx)validates UUIDs before passing labelIds to APIs that require them (Linear). Returnsnulland logs a warn for misconfigurations.project-id-extractor.ts—extractProjectIdFromJobViaRegistry(jobData)iterates the registry. Used bysrc/router/worker-env.tsbefore its (now-minimal) legacy branches.
JIRA has one additional shared host resolver outside _shared/ — resolveJiraApiBaseUrl (src/jira/api-host.ts), the JIRA analogue of the shared auth-header helper. See the next section.
JIRA supports classic unscoped site tokens and Atlassian API tokens with scopes ("scoped" tokens). The mode is chosen by the optional authType field on the JIRA integration config (jiraConfigSchema in src/integrations/pm/jira/config-schema.ts): 'basic' (or absent) and 'scoped'.
authTypeis a non-secret connection setting, not a credential role — it mirrorsbaseUrland lives inproject_integrations.config, neverproject_credentials. Absent ⇒'basic', so every pre-existing JIRA config stays valid untouched (normalizeJiraAuthTypemaps absent/unknown values back to'basic').- Both modes authenticate with HTTP Basic (
email:api_token) — confirmed live in MNG-1735.authTypeselects the REST v3 host, not a Basic-vs-Bearer scheme. There is no scoped-Bearer branch.
Every JIRA REST v3 call site must route through the single shared resolver resolveJiraApiBaseUrl(creds) (src/jira/api-host.ts) so scoped tokens hit the Atlassian gateway consistently and no divergent host-selection copies exist. This is the JIRA analogue of the auth-headers.ts provenance rule:
authType |
Resolved REST v3 base | Detail |
|---|---|---|
basic / absent |
tenant site URL (creds.baseUrl) |
Classic site-token behavior, unchanged. |
scoped |
Atlassian gateway https://api.atlassian.com/ex/jira/{cloudId} |
cloudId resolved from ${baseUrl}/_edge/tenant_info (always the site URL, never the gateway) with the same Basic scoped token, cached per baseUrl via jiraClient.getCloudId. |
The in-worker jiraClient (src/jira/client.ts), the router-side JiraPlatformClient (src/router/platformClients/jira.ts), the webhook-management router (src/api/routers/webhooks/jira.ts), and the wizard's discovery factory (jiraManifest.createDiscoveryProvider / configToCredentials) all resolve the effective base this way. The worker/CLI credential scope carries the mode across process boundaries via the CASCADE_JIRA_AUTH_TYPE env var (injected by secretBuilder.augmentProjectSecrets). accessible-resources is intentionally not used as a cloudId fallback — it is OAuth 2.0 / 3LO guidance and returns 401 for scoped API tokens (MNG-1735).
- Scopes. Read/write Jira work (classic OAuth
read:jira-work+write:jira-work). Programmatic webhook management (/rest/api/3/webhook) additionally needs webhook scopes — classic OAuthmanage:jira-webhook, or granularread:field:jira+read:project:jira+write:webhook:jira. A scoped token without those scopes (or a non-app caller) gets401/403;jiraCreateWebhooksurfaces an actionable message pointing at manual registration (Jira System → WebHooks) rather than a raw status dump. - Ack reactions are unavailable under scoped tokens. The "eyes" reaction uses the internal
/rest/reactions/1.0/API, which is not confirmed on the scoped gateway (the gateway probe returned401 scope does not match; MNG-1735). Underscopedauth bothjiraClient.addCommentReactionandJiraPlatformClient.postReactiondegrade quietly (one log line, then skip). The reaction is cosmetic and must never fail a run; comments, transitions, and label writes are unaffected.
CASCADE historically assumed one project per PM board. The JIRA router resolved
a project by first match on projectKey, so a second project on the same key
never received an event — no error, no log, nothing in the webhook decision
reason to suggest a second project existed. Spec 024 replaces that guess with an
explicit decision over the whole sibling set.
An optional field on the JIRA integration config says which issues on a shared key belong to a project:
kind is label or component. Matching is exact and case-sensitive —
JIRA labels are case-sensitive, and a near-miss must fail loudly rather than
route another team's work to you.
If no project on a key declares a discriminator, the key keeps pre-024
first-match routing — plus a WARN and a Sentry capture tagged
pm_shared_key_unconfigured naming the project that wins and the ones being
shadowed (once per key per process).
This is deliberate. A pre-024 deployment with two projects on one key can only be in that state — the field did not exist — and applying the strict matrix to it would turn "one project works, the other is shadowed" into "nothing works at all", and before plan 5 there was no wizard field to fix it with. The shadowing bug stays, but stops being silent. The strict matrix engages the moment any project on the key declares a discriminator.
Save-time validation follows the same rule: a project already registered on a key may be re-saved even in a legacy duplicate state (it did not create the conflict), while a new project claiming a taken key without a discriminator is still rejected.
Once opted in, resolveProjectAmongSiblings (pm/_shared/project-routing.ts)
decides. It is pure and provider-agnostic, so Trello and Linear can adopt it
unchanged:
| Situation | Outcome |
|---|---|
| One sibling, no discriminator | route — the 1:1 topology, untouched |
| Exactly one discriminator matches | route to that sibling |
| More than one matches | skip ambiguous |
| Nothing matches, exactly one sibling has no discriminator | route to it (the default) |
| Nothing matches, several siblings have no discriminator | skip ambiguous |
| Nothing matches, every sibling is discriminated | skip no_match |
A lone sibling that has a discriminator does not get the 1:1 shortcut: it asked to be scoped, so an issue outside its scope is not handed to it merely for lack of a rival. Nothing is ever awarded by position — that is the silent misrouting this module exists to end.
JiraRouterAdapter.resolveProjectWithReason (the optional adapter hook added by
spec 024) returns the resolver's message, and webhook-processor records it as
the webhook-log decision reason. That is the operator's diagnosis surface:
it names the key and every discriminator evaluated, instead of the previous
No project config for identifier CLFX — which was actively misleading, since
config existed and simply matched nothing.
The hook is optional. Adapters that never share an identifier omit it and keep today's message verbatim.
Ambiguity additionally captures Sentry under tag pm_routing_ambiguous,
deduped per issue per process — the same misconfiguration re-fires on every
webhook for that issue.
An event with no owning project also gets no ack reaction: acknowledging something about to be skipped tells the operator the opposite of the truth.
Routing alone makes sharing only half true. A scoped project must also see and produce only its own slice, or its agents pick up the sibling team's work and the issues it creates carry nothing to route their future events back.
Reads — listWorkItems appends the discriminator to its JQL:
| Discriminator | Clause |
|---|---|
label / team-be |
AND labels = "team-be" |
component / Backend |
AND component = "Backend" |
The clause lands before the ORDER BY — JQL requires the sort clause last,
so an appended-at-the-end filter is a syntax error, not a filter. Values are
double-quoted for the same reason the status clause quotes them: an unquoted
multi-word value parses as syntax rather than as a value. With no discriminator
the query is byte-identical to before spec 024.
Writes — createWorkItem stamps every item the project creates, so friction
reports, alert cards, and split children all route back on their own. A label
discriminator is dedup-appended to the caller's labels (a retry, or a caller
that already knows the config, must not produce it twice); a component
discriminator sets components. The components key is omitted entirely when
unset, because JIRA rejects components: [] on projects that have none
configured.
Matching is exact and case-sensitive on both sides, so a label stamped on write is found by the read clause verbatim.
⚠️ A component discriminator must name a component that already exists on the JIRA project. Unlike labels, which JIRA creates on first use, an unknown component makescreateIssuefail with a 400 — and the resulting error currently blames the issue-type mapping, because that is whatenrichCreateIssueErroris written to diagnose. Create the component in JIRA first, or use a label discriminator.
The discriminator value is validated at save time: no " or \ (both would
break out of the quoted JQL value), and no whitespace for a label
discriminator, since JIRA refuses to write such a label and the read clause
would then match nothing.
Two teams, one JIRA board.
- Decide the attribute that tells the teams' issues apart. A label is the
usual choice — JIRA creates labels on demand. A component also works but
must already exist on the JIRA project; JIRA will not create it, and the
resulting
createIssuefailure reports an issue-type problem rather than a component one. - In each project's PM wizard, open Team routing and set the kind and value
(
team-backend,Payments API, …). Leave it as None on at most one project — that project becomes the key's default and receives issues carrying no other team's discriminator. A new project claiming a key that already has a default is rejected at save time; two projects that were already on the key before spec 024 are grandfathered and keep first-match routing with a loudpm_shared_key_unconfiguredwarning until one of them declares a discriminator (see Sharing is opt-in above). - Have the teams apply the discriminator when they file. Issues carrying neither are skipped with a decision reason naming the key and every discriminator evaluated — visible in the webhook log, not silent.
Work CASCADE creates is stamped automatically, so friction reports, alert cards and split children route back to the project that made them without anyone labelling them by hand.
Two projects, one GitHub repository.
- On the SCM tab of each project, set Repository role. Exactly one is the Primary; it receives events for pull requests CASCADE did not create — a human's PR, or one opened before any project claimed it.
- Everything else routes by link: a PR a project opened belongs to that project whatever the primary is, so each project's own work comes back to it.
A repository with no primary is rejected at save time, because every unlinked event would then go nowhere.
assertJiraTopologyValid (pm/_shared/topology-validation.ts) runs inside
upsertProjectIntegration, so an unroutable topology cannot be saved from any
surface. Two rules, both derived from what the resolver can decide:
- A key may have at most one project without a discriminator (its default).
- No two projects may claim the same discriminator.
A label and a component sharing a value do not collide — they are different facts about an issue. Re-saving an existing project does not conflict with itself. Rejections name the conflicting project and say what to add.
This validator throws
TRPCErrorfrom the repository layer — a deliberate layering exception, documented at the call site, so the message stays operator-facing wherever the save originates.
Every runtime surface (router, worker, CLI bootstrap, dashboard) imports a single canonical entrypoint:
import './integrations/entrypoint.js'; // registers every PM + SCM + alerting integrationsrc/integrations/entrypoint.ts is a side-effect-only module that imports each category's barrel — PM via ./pm/index.js, SCM via ../github/register.js, alerting via ../sentry/register.js. The entrypoint exists because forgetting to register a provider in one surface but not others shipped four production bugs during Linear's rollout (#1097, #1118, #1131, #1134). The single-entrypoint invariant is guarded by tests/unit/integrations/entrypoint-usage.test.ts, which greps every process-entry file and fails if the import is missing.
The PM barrel (src/integrations/pm/index.ts):
- Imports each provider's
index.js(side effect:registerPMProvider(manifest)). - Iterates
listPMProviders()and mirrors each manifest'spmIntegrationinto the cross-categoryintegrationRegistry— sointegration-validation.tsand the capability resolver see PM providers alongside SCM + alerting.
SCM (GitHub) and alerting (Sentry) integrations remain on the legacy IntegrationModule pattern — the manifest pattern is PM-only. Both self-register via their own register.ts side-effect modules, transitively pulled in by the entrypoint.
pmRegistry (src/pm/registry.ts) still exists as a read-only delegate over pmProviderRegistry — the ~9 unmigrated call sites (webhook handlers, manual runner, credential scope, lifecycle, GitHub adapter) keep working without changes. Prefer getPMProvider(id) / listPMProviders() from src/integrations/pm/registry.ts in new code.
tests/unit/integrations/pm-conformance.test.ts iterates listPMProviders() and runs a shared test pack against every manifest:
idis URL-safe kebab/lowercasecategoryis'pm'webhookRoutefollows the/${id}/webhookconventionrouterAdapter.type === id- At least one required credential role
- Credential roles have unique
rolestrings extractProjectIdFromJobreturnsnullfor foreign job typesextractProjectIdFromJobreturns the projectId for{ type: id, projectId }triggerHandlershave unique namesplatformClientFactory(projectId)returns an object withpostComment+deleteCommentpmIntegration.typeis wireddispatchPMAck(the consolidated PM-ack helper atsrc/router/pm-ack-dispatch.ts) reaches this provider without throwing
Declaring the optional hardened-contract fields opts a manifest into further groups: configSchema + configFixture → round-trip identity (parse → serialize → re-parse → deep-equal); discoveryCapabilities → each declared capability returns a list from adapter.discover(k, args); lifecycle → the full runLifecycleScenario (create → list → move → checklist → comment → delete) against the fixture registered under fixtureKey; createDiscoveryProvider → powers the generic pm.discover endpoint. Manifests that omit a field skip that group.
A TestProvider fixture in tests/helpers/testPMProvider.ts is the minimal reference implementation — copy its shape when starting a new provider. The harness runs against TestProvider + Trello + JIRA + Linear.
Router-side PM acknowledgment posting (the comment that says "🔧 On it" on the PM card when a PM-focused agent like backlog-manager starts work, triggered from a GitHub webhook) goes through one code path: dispatchPMAck in src/router/pm-ack-dispatch.ts. That helper looks up the provider in the manifest registry and invokes manifest.platformClientFactory(projectId).postComment(workItemId, message) directly — no pmType literal branching anywhere on the dispatch surface.
It replaced two parallel helpers that had drifted apart — one had Trello + JIRA branches but no Linear branch, so Linear projects silently skipped the ack. Both legacy call sites now delegate to dispatchPMAck.
A new PM provider lands the dispatch path for free the moment its manifest is registered — no edits to pm-ack-dispatch.ts or to either of the call sites. Failure modes:
- Provider's
platformClientFactoryreturns a client whosepostCommentthrows → conformance harness'sdispatchPMAck reaches this provider without throwingassertion fails in CI with a precise per-provider message. - A future maintainer adds
if (pmType === 'asana')branching to either call site → the static guard attests/unit/router/pm-ack-dispatch.test.ts(PM-ack dispatch surface: no literal pm-type branching) fails loudly with a file:line citation. - Project pinned to a
pm.typethat's no longer in the registry (configuration error) →dispatchPMAcklogs at ERROR + captures Sentry under tagpm_ack_unknown_pm_type(no longer a silent WARN).
CASCADE supports custom workflow statuses (e.g. prd, story, phased-plan) on top of the built-in BUILTIN_WORKFLOW_STATUSES catalog in src/workflow/statusDefinitions.ts. Trello, JIRA, and Linear all offer the same wizard + dispatch contract; new PM providers should follow it for parity.
| Concern | Storage | File |
|---|---|---|
Status definition (key, label, dispatch agentType, sortOrder) |
workflow_status_definitions table; managed via cascade workflow-statuses * or workflowStatuses.create/update/delete (superadmin tRPC) |
src/db/repositories/workflowStatusDefinitionsRepository.ts, src/api/routers/workflowStatuses.ts |
| Provider-native mapping for each custom key | project_integrations.config JSON, under the same key shape as built-in slots |
per-provider |
| Trello provider-native value | lists.<customKey> → Trello list ID |
src/pm/trello/integration.ts |
| JIRA provider-native value | statuses.<customKey> → JIRA status ID (locale-proof; name accepted as a legacy fallback — see below) |
src/pm/jira/integration.ts |
| Linear provider-native value | statuses.<customKey> → Linear workflow state UUID |
src/pm/linear/integration.ts |
The lifecycle config resolver on each PMIntegration (resolveLifecycleConfig) must spread the full lists / statuses record so custom keys survive normalization and are available to moveOnPrepare / moveOnSuccess lifecycle hooks for custom agents. Look at LinearIntegration.resolveLifecycleConfig for the canonical shape — statuses: { ...(linearConfig?.statuses ?? {}) } rather than handpicked built-in keys.
JIRA status names are rendered in the language of whichever account a request is scoped to: the dispatch webhook carries changelog.items[].toString / issue.fields.status.name in the site language, while the move side (moveWorkItem) matches getTransitions() names in the credential account's language. When those two languages differ for system statuses, the old name-on-both-ends matching silently no-op'd the move.
The fix matches on the locale-invariant JIRA status ID on both ends, with name matching kept as a fallback (zero forced migration):
- Dispatch —
JiraStatusChangedTriggerreadschangelog.items[].to(update path) /issue.fields.status.id(create path) and resolves viaresolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ statusId, statusName, configuredStatuses })insrc/triggers/shared/pm-status.ts(ID match first, case-insensitive name fallback). - Move —
JiraPMProvider.moveWorkItemmatchestransitions[].to.id === destinationfirst (distinct from the transitiont.id), then falls back to the name branches. A genuine no-transition-found miss now emitslogger.warnand a SentrycaptureExceptiontaggedjira_transition_not_foundso a localized/misconfigured account is caught on the first run. - Other
jira.statusesreaders — every consumer of the (now ID-valued)jira.statusesmap matches ID-first with a name fallback, so none silently no-op on ID-based configs.JiraReadyToProcessLabelTrigger(thecascade-readylabel flow) reads the issue'sstatus.id/status.nameand resolves viaresolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions;JiraCommentMentionTrigger'sisInPlanningStatusgate compares the configuredplanningvalue against the issue's status ID first, then its name. - Wizard — the status-mapping select now persists the status ID (
{ id: s.id, name: s.name }) while still displaying the name.normalizeJiraStatusMappingsToIdsauto-upgrades legacy name-valued mappings → IDs in theSET_JIRA_PROJECT_DETAILSreducer when project details load, so re-saving any project backfills IDs. Values already-ID or unrecognized (custom) are left untouched. - JQL —
listWorkItemsquotes the status value; JIRA resolves a quoted numeric value against status IDs, so ID-based config values remain valid with no behavior change.
Same "wizard writes X → runtime reads X" failure shape as the locale-fragile status bug above (MNG-1768). The JIRA wizard's IssueTypeMappingStep (web/src/components/projects/pm-providers/jira/issue-type-step.tsx) persists the operator's Task mapping under jira.issueTypes.task, but JiraPMProvider.createWorkItem used to read issueTypes.default — a key nothing ever wrote — so the optional chain always yielded undefined and every JIRA issue was hardcoded to type "Task", silently ignoring the operator's mapping.
- Runtime reads
issueTypes.task.createWorkItemreadsthis.config.issueTypes?.task ?? 'Task'. The'Task'fallback is retained for configs that never set a mapping (backward compatible). The legacyissueTypes.defaultkey is intentionally not read — honoring it would resurrect the bug — and a regression test intests/unit/pm/jira/adapter.test.tsprovesdefaultis no longer honored. - Actionable failure. When
jiraClient.createIssuefails (commonly a JIRA 400 when the mapped/fallback type does not exist on the project), the adapter best-effort callsjiraClient.getIssueTypesForProject(projectKey)and re-throws an error naming the attempted type and the project's discovered non-subtask issue types. The diagnostic fetch is guarded so a discovery failure re-throws the original creation error unchanged. subtaskis intentionally not consumed. There is no subtask-creation path, so the wizard's subtask row was removed rather than persist config nothing reads. A previously-savedissueTypes.subtaskvalue is harmless — it simply stays in config, unread.
The PM wizards consume the workflow status definition list through a single tRPC query (trpc.workflowStatuses.list) and render mapping rows for every key — built-in and custom alike. The provider's useProviderHooks resolves the list and forwards it as workflowStatuses on the hook return; the shared StatusMappingStep renders rows in the returned order. Reference implementations:
- Trello —
web/src/components/projects/pm-providers/trello/wizard.ts(TrelloStatusMappingAdapter,useProviderHooks→workflowStatuses) - JIRA —
web/src/components/projects/pm-providers/jira/wizard.ts(JiraStatusMappingAdapter,useProviderHooks→workflowStatuses) - Linear —
web/src/components/projects/pm-providers/linear/wizard.ts(LinearStatusMappingAdapter,useProviderHooks→workflowStatuses)
Save-time trigger-config materialization is shared too. Every ProviderWizardDefinition.buildSaveTriggerConfigs delegates to buildMissingStatusTriggerConfigs (web/src/components/projects/pm-providers/save-trigger-configs.ts), which:
- Walks the workflow status list returned by
trpc.workflowStatuses.list. - For every custom (or auto-enabled built-in) status the operator just mapped to a provider-native value, emits a
{ agentType, triggerEvent: 'pm:status-changed', enabled: true }entry — if not already present inagent_trigger_configs.
This means the operator never has to manually run cascade projects trigger-set --agent <custom> --event pm:status-changed --enable after mapping a custom dispatch-capable status in the wizard. The wizard does it. New PM providers inherit this for free by wiring buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => buildMissingStatusTriggerConfigs({ statusMappings: state.<provider>StatusMappings, workflowStatuses, existingConfigs }).
Custom-status dispatch reuses the same pm:status-changed trigger registry that built-in statuses use:
- Trello (
src/triggers/trello/status-changed.ts) —TrelloCustomStatusChangedTriggerclaimscreateCard/updateCardevents whose destination list ID maps to a custom (non-built-in) key intrello.lists. Built-in keys are still handled by the per-list triggers (TrelloStatusChangedTodoTrigger, etc.). - JIRA (
src/triggers/jira/status-changed.ts) —JiraStatusChangedTriggerresolves the new status againstjira.statusesviaresolvePMStatusAgentByIdOrNameFromWorkflowDefinitions(locale-invariant status ID first, case-insensitive name fallback — MNG-1768), picking up custom keys alongside built-ins in a single handler. - Linear (
src/triggers/linear/status-changed.ts) —LinearStatusChangedTriggerresolves the new state UUID againstlinear.statusesviaresolvePMStatusAgentByIdFromWorkflowDefinitions, also a single handler.
All three resolve through the shared resolvePMStatusAgentFromWorkflowDefinitions in src/triggers/shared/pm-status.ts and obey one dispatch precondition: a status only dispatches an agent when both of the following hold:
- The workflow status definition has a non-null
agentType. - The project has an enabled
agent_trigger_configsrow for(agentType, pm:status-changed).
A custom status with agentType: null (created without --agent-type or updated with --no-agent) renders in the wizard and persists in the provider config but does not dispatch any agent — the trigger handler returns null and the registry continues. Use this for board columns CASCADE should know about for wizard parity but never act on.
- Lifecycle resolver —
PMIntegration.resolveLifecycleConfigmust spread the full provider-native status/list record (custom keys included), not handpicked built-in keys. MirrorLinearIntegration.resolveLifecycleConfig. - Status-changed trigger — call the workflow-definition-aware resolver (
resolvePMStatusAgentByName*FromWorkflowDefinitionsorresolvePMStatusAgentById*FromWorkflowDefinitions) instead of the built-inSTATUS_TO_AGENTmap. Built-in providers above are the reference. Optionally add a dedicated custom-status trigger likeTrelloCustomStatusChangedTriggerif your provider's matching semantics need it. - Wizard hook — return
workflowStatusesfromuseProviderHooks(sourced viatrpc.workflowStatuses.list) and pass it ascascadeStatusesinto the sharedStatusMappingStep(falling back to your provider's built-in slot array when the query is still loading). - Save path — declare
buildSaveTriggerConfigson theProviderWizardDefinitiondelegating tobuildMissingStatusTriggerConfigs.
With those four pieces in place, custom workflow statuses get full wizard mapping + auto trigger-config + dispatch with zero additional code per provider.
Spec 009 AC #10: a new PM provider PR should not need to edit shared router / worker / CLI / dashboard / configMapper / central schema files. The orchestration and schema work lives in your provider folder + your wizard folder + one import in src/integrations/pm/index.ts + one import in web/src/components/projects/pm-providers/index.ts. The shared wizard orchestration files (pm-wizard.tsx, pm-wizard-hooks.ts, pm-wizard-common-steps.tsx) are guarded shared surface and should not change for a new provider. The one shared dashboard file that still requires a new-provider edit is pm-wizard-state.ts (step 4 below) — it composes the provider's state slice into WizardState, WizardAction, initial state, and reducer delegation. Edit-mode config hydration belongs on the provider's ProviderWizardDefinition.buildEditState; save-payload serialization belongs on ProviderWizardDefinition.buildIntegrationConfig; provider hook auth and mutation wiring belong in provider-owned metadata/hooks. The tests/unit/integrations/new-provider-surface.test.ts guard enforces the shared-file invariant for orchestration and schema files; pm-wizard-state.ts is the deliberate exception.
-
Backend folder at
src/integrations/pm/<provider>/:client.ts(or reuse a sibling undersrc/<provider>/) — your REST / GraphQL client. Must usewithXxxCredentials()+ AsyncLocalStorage credential scoping; never hand-assemble Bearer headers (see_shared/auth-headers.ts).adapter.ts— yourPMProviderimplementation. Narrow method parameters to brandedContainerId/LabelId/StateIdfromsrc/pm/ids.tsvia TypeScript method bivariance — direct adapter callers then get compile-time protection against state-name-vs-ID confusion (#1117/#1137/#1139).createWorkItemkeepsCreateWorkItemConfigdue to TS object-property invariance; parseconfig.containerIdat the boundary.config-schema.ts— Zod schema for the project-scoped config. This is the single source of truth — the centralsrc/config/schema.tsimports it.manifest.ts— thePMProviderManifest, wiring shared helpers (auth-headers,makeHmacSha256Verifier). DeclareconfigSchema,configFixture,discoveryCapabilities,wizardSpec,lifecycle.enabled,createDiscoveryProvider. The conformance harness runs round-trip + lifecycle + webhook-verify + trigger-self-hook checks against each declared contract.router-adapter.ts,triggers/*.ts,webhook.ts,platform-client.ts— same as before.index.ts— side-effect module callingregisterPMProvider(<provider>Manifest).
-
Wire the backend manifest via a single import in
src/integrations/pm/index.ts(import './<provider>/index.js';). No other backend file needs to change — thesingle-entrypointtest guards this. -
Frontend folder at
web/src/components/projects/pm-providers/<provider>/:wizard.ts(ProviderWizardDefinitionwithauth,credentialPersistence,formatVerificationDisplay,buildIntegrationConfig,buildEditState, anduseProviderHooksif the provider needs discovery / label creation / custom-field creation / webhook registration),state.tsfor the provider-owned wizard state slice/actions/reducer/defaults,hooks.tsfor provider-owned discovery/mutation/auth/webhook wrappers,auth.tsfor reusable auth metadata when useful, andindex.tsfor side-effect registration (registerProviderWizard(<provider>ProviderWizard)). For shared wizard steps declared onmanifest.wizardSpec, the generator inpm-providers/generator.tsxdispatches directly to the real shared step components atpm-providers/steps/*.tsx— there are seven kinds:credentials,container-pick,status-mapping,label-mapping,webhook-url-display,project-scope,custom-field-mapping. A provider with purely standard steps writes zero per-provider step components; Trello, JIRA, and Linear all use the shared components for every standard kind. ProvideproviderHooks(returned fromuseProviderHooks) to forward discovery data + mutation callbacks into the shared components; the generator spreadsctx.providerHooksas props. Unknown stepkindvalues still warn-and-render a placeholder. The shared steps accept optional widenings:container-pick/project-scopetakesearchable(cmdkCombobox),webhook-url-displaytakes an inline signing-secret input (secretFieldRole/secretValue/onSecretChange),label-mappingtakeslabelDefaults, andcustom-field-mappingtakesfieldDefaults; the generator dispatches throughrenderStandardStep+STANDARD_STEP_COMPONENTS. Provider-specific UI ships either as (a)kind: 'custom'steps declared on the manifest and resolved to provider-folder components (Trello OAuth popup, JIRA issue-type mapping), or (b) Fragment compositions around a shared step when the base UX is standard but needs augmentation (Trello/JIRA webhook steps composeWebhookUrlDisplayStep+ programmatic Create UX + active-webhook normalization; Linear composesWebhookUrlDisplayStep+ProjectSecretField+ setup instructions — seepm-providers/{trello,jira,linear}/webhook-step.tsxfor the reference composition pattern). Sharedpm-wizard-hooks.tsremains limited to metadata-driven verification/save shells and provider-agnostic mutation factories. -
Update shared dashboard state in
web/src/components/projects/pm-wizard-state.ts. This is the one shared dashboard file a new provider must edit whileWizardStateremains an aggregate type:- Import the provider's state-slice helpers from
pm-providers/<provider>/state.ts. - Compose the provider slice into
WizardState,WizardAction,createInitialState, andwizardReducerdelegation. - Keep provider-specific credential fields, action definitions, defaults, and reducer logic inside the provider
state.ts; reducer cases that mutate credentials should clearverificationResultandverifyError.
Edit-mode hydration is provider-owned: implement
buildEditState(initialConfig, configuredKeys)on the provider'sProviderWizardDefinition. It should sethasStoredCredentialsfrom the relevant persisted credential keys and restore saved config values (container IDs, status/label mappings) without returning raw credential values.Save config serialization is also provider-owned: implement
buildIntegrationConfig(state)on the provider'sProviderWizardDefinition. The credential-readiness check (areCredentialsReadyFromMetadatainpm-wizard-hooks.ts) and the mutation auth path (buildProviderAuthArgFromMetadata) are fully metadata-driven — they readmanifestDef.auth.rawCredentialsand require no changes here. - Import the provider's state-slice helpers from
-
Wire the frontend wizard via a single import in
web/src/components/projects/pm-providers/index.ts(import './<provider>/index.js';). This frontend barrel is the symmetric counterpart of the backend barrel atsrc/integrations/pm/index.ts—pm-wizard.tsximports the barrel once and never needs to be edited for a new provider. -
Lifecycle fixture at
tests/helpers/<provider>LifecycleFixture.ts. Add the fixture key toLIFECYCLE_FIXTURESintests/unit/integrations/pm-conformance.test.ts. Trivial providers can reusecreateFakePMProvider()(see Trello/JIRA/Linear fixtures). -
Run the conformance harness:
npx vitest run --project unit-core tests/unit/integrations/pm-conformance.test.ts. Behavioral contracts run against your provider automatically onceconfigSchema/discoveryCapabilities/lifecycleare declared. Failures name the contract. -
Provider-specific unit tests in
tests/unit/pm/<provider>/— adapter tests (vi.mock the client), config-schema round-trip, discovery shape, wizardSpec, adapter branded IDs. Linear'stests/unit/pm/linear/regression-2026-04.test.ts(one test set per past bug class) is the model for pinning fixed bugs.
The shared orchestration files (pm-wizard.tsx, pm-wizard-hooks.ts, pm-wizard-common-steps.tsx) require zero edits beyond the barrel import in step 5. The new-provider-surface snapshot test proves your PR does not modify shared router / worker / CLI / dashboard orchestration or central schema files. The one deliberate shared-dashboard exception is pm-wizard-state.ts for provider-specific state fields and reducer actions (step 4 above).
SCM (GitHub) and alerting (Sentry) integrations retain the legacy IntegrationModule pattern with self-registration in src/github/register.ts and src/sentry/register.ts. A future spec may extend the manifest pattern to those categories.
Friction reports are PM-backed work items filed by the ReportFriction gadget. Providers do not add a new adapter method for this feature. The shared materializer at src/friction/materialize.ts uses the existing PMProvider CRUD surface:
- Resolve placement from project config with
getFrictionContainerId(project). - Call
provider.createWorkItem({ containerId, title, description, labels: [] }). - Resolve an optional destination with
getFrictionStatusDestination(project). - Call
provider.moveWorkItem(workItem.id, destination)when a destination exists.
That means a new provider only needs correct implementations of the existing createWorkItem and moveWorkItem methods plus the normal config schema fields:
| Provider shape | Friction slot |
|---|---|
| Trello list-based config | lists.friction |
| JIRA status-based config | statuses.friction |
| Linear status-based config | statuses.friction |
If the slot is missing, the materializer returns a skipped result with reason friction_slot_missing instead of throwing. The sidecar/outbox layer can then keep the agent run non-blocking while operators fix configuration.
Different PM providers have different native concepts of "checklist". The PMProvider interface exposes a uniform API (getChecklists, createChecklist, addChecklistItem, updateChecklistItem, deleteChecklistItem) plus an optional bulk creation path (createChecklistWithItems), but adapters implement them differently:
| Provider | Implementation | Where items live |
|---|---|---|
| Trello | Native Trello checklist API | In-card checklists (lightweight items, not separate cards) |
| Linear | Inline markdown in description | ### {Checklist Name} heading + - [ ] / - [x] lines in the issue's description |
| JIRA | Inline markdown in description (via ADF round-trip) | ### {Checklist Name} heading + - [ ] / - [x] lines in the issue's description |
Why inline markdown for Linear and JIRA? Both providers support markdown checkboxes natively in their description editors but lack a dedicated lightweight checklist primitive — sub-issues and subtasks are full work items, which clutters boards when used for things like acceptance criteria or implementation steps. Inline markdown matches Trello's lightweight semantics without creating orphan issues. See spec 008 for full rationale.
The shared engine that parses, upserts, toggles, and removes inline checklist items lives at src/pm/_shared/inline-checklist.ts and is consumed by both the Linear and JIRA adapters. Inline checklist creation is idempotent by exact markdown heading and exact item text: repeated writes for the same ### {Checklist Name} section merge into the first matching section, duplicate sections with the same heading are collapsed while preserving non-checkbox prose, duplicate rows converge to one row, and checked state wins if any duplicate row is checked. This is intentional for CASCADE-generated checklists and keeps provider/tool retries from duplicating Implementation Steps or Acceptance Criteria blocks. Trello remains on native checklists and does not use this markdown merge behavior.
Because Linear and JIRA checklist mutations rewrite the whole description, their adapters serialize the full read/mutate/write operation with withDescriptionMutationLock(provider, workItemId, fn) from src/pm/_shared/description-mutation-lock.ts. Keep future inline-description mutations inside that guard; otherwise concurrent cascade-tools pm update-checklist-item processes can overwrite each other's description snapshots without a provider-side conflict error. Initial checklist creation for inline-description providers should implement createChecklistWithItems so AddChecklist can create the section and all starting rows in one locked description mutation instead of createChecklist plus one write per item. Linear can briefly serve stale descriptions after accepting linearClient.updateIssue(), so Linear records each successful description write in an in-process recent-description cache and uses that cached value as the next locked mutation's base while the provider catches up. Do not replay an accepted append solely because readback is stale; rely on idempotent upsert helpers plus the recent-description cache to keep retries and consecutive writes from duplicating or losing inline checklist content. Because withDescriptionMutationLock is a filesystem lock shared by separate cascade-tools processes, Linear also writes a durable cross-process sidecar (via writeLockedDescription / readLockedDescription in description-mutation-lock.ts) while holding the lock after each successful PUT. The next process that acquires the same lock reads the sidecar as its fresh base, preventing it from overwriting the previous process's accepted write with a stale Linear snapshot.
The default description-lock wait budget is intentionally lower than the checklist tool timeout: the lock waits up to 45s, while AddChecklist, PMUpdateChecklistItem, and PMDeleteChecklistItem allow 60s. Keep that relationship when adjusting either value so a queued, legitimate Linear/JIRA checklist mutation is not aborted by the outer tool runner before the lock wait can complete.
Spec 016 hardened the work-item-image pipeline so user-pasted screenshots (Linear especially, but the rules generalize) reliably reach the agent worker as files on disk. New PM providers should follow this contract; do nothing extra and image delivery just works.
- Extract URL refs from the work-item description and each comment via
extractMarkdownImages()(src/pm/media.ts). AMediaReferenceis produced for everymatch. The provider does NOT need its own extraction logic. - Pre-download MIME inference (a hint, not a verdict):
mimeTypeFromUrl()derives a MIME from the URL pathname's extension. For URLs whose hostname is inIMAGE_HOST_ALLOWLIST(currentlyuploads.linear.app) AND whose pathname has no recognised extension, the inference returns'image/*'— a wildcard sentinel that survives the image-only filter. Add a host to the allowlist only if itsContent-Typeheaders are reliable. - Filter via
filterImageMedia()— drops anything that isn't an image MIME or theimage/*wildcard. - Download via
downloadAndPrepareImages()(src/pm/download-and-prepare.ts) — the shared per-provider dispatch loop. The download response'sContent-Typeheader is the AUTHORITATIVE MIME — it resolves the wildcard and overrides any URL-extension-derived guess. - Write to disk at
.cascade/context/images/work-item-<id>-img-<index>.<ext>— extension is derived from the resolved MIME.
- Don't write your own MIME-detection logic. The shared resolution path covers all known PM provider URL shapes.
- Don't download images yourself in your adapter — let
downloadAndPrepareImagesdo it. - Don't surface
getAttachments()for inline-pasted images. That method is for formal Attachment records (Slack/GitHub link previews, integration cards) — distinct from inline pastes which live in description / comment markdown.
Every work-item fetch (boot path AND runtime read-work-item gadget) emits one INFO-level log line with the literal prefix [image-pipeline] work-item-fetch summary and the field schema:
{
provider: 'linear' | 'trello' | 'jira' | 'unknown',
workItemId: string,
urlsDetected: number, // pre-filter count
urlsAfterFilter: number, // post-filterImageMedia count
urlsDownloaded: number,
urlsFailed: number,
urlsByMimeType: Record<string, number>,
}
Operators triaging a "no image delivered" report grep for the literal prefix in cascade runs logs <runId> output. One line per fetch tells the whole story.
If your provider's upload host returns application/octet-stream (or wrong) on the actual GET response, the download-time resolution can't recover. Two options: (a) don't add the host to IMAGE_HOST_ALLOWLIST — let the URL-extension path do its job; (b) if URLs are also extension-less, file an issue describing the host's behavior so we can layer in a per-host content-type override. Don't hard-code MIMEs in your adapter — keep MIME resolution shared.
Spec 016/3 captured a fixture and pinned the rule for Linear specifically. The findings:
Issue.description(markdown) is the canonical surface for inline-pasted images. When a user pastes a screenshot into Linear's issue editor, Linear stores the upload athttps://uploads.linear.app/<uuid>(often extension-less) and inserts standard markdown image syntaxinto the description. The Linear adapter'sextractMarkdownImages(issue.description)is the right call — it's whatgetWorkItemalready does atsrc/pm/linear/adapter.ts:61.- Comment bodies follow the same convention. Each
Comment.bodyfield is markdown; pasted screenshots show up asexactly like the description.extractMarkdownImages(comment.body, 'comment')covers them. Issue.attachmentsis the WRONG surface for inline images. The Linear GraphQLIssue.attachmentsconnection holds formal Attachment records — link previews from Slack threads, GitHub PRs, Sentry alerts, and other integration cards. They haveurlfields but they are NOT user-pasted screenshots. The Linear adapter'sgetAttachments(issueId)(atsrc/linear/client.ts:542) correctly returns these asLinearAttachmentfor the dedicated attachment surface; do NOT extract images from this connection.- Regression net. The captured fixture lives at
tests/fixtures/linear-issue-with-screenshot.json. The unit test attests/unit/pm/linear/extraction-coverage.test.tsloads the fixture and asserts every inline image is extracted — fails LOUDLY with a clear message if Linear ever changes payload shape in a way that loses inline images. - No new GraphQL surface to query. As of spec 016/3 the Linear API exposes inline-pasted images only via the
descriptionandComment.bodymarkdown fields. There is nodescriptionDatarich-text JSON tree that would expose them differently, and noattachments(includeInline: true)filter. Future Linear API drift would surface as a fixture-test failure.
See spec 016 for the full rationale and the live incident this contract closed.
Spec 019 added a generic materializer that converts an external alert event into a real PM work item so the alerting agent runs against a native PM card/issue with full lifecycle support (budget tracking, status transitions, label writes). See the spec for full rationale; this section covers the contracts new providers must respect.
materializeAlertWorkItem(source, externalId, project, hints) → pmNativeWorkItemId
Located at src/integrations/alerting/_shared/materialize.ts. Callable from any alerting worker path; today Sentry materializes on the worker side after trigger resolution for event alerts, issue-lifecycle webhooks, and metric alerts.
source—'sentry' | 'sentry-issue' | 'sentry-metric' | 'pagerduty' | 'datadog' | 'github-alert'(union grows as new sources are added). The Sentry literals distinguish alert-rule event alerts, issue-lifecycle webhooks, and metric alerts so unique external mappings do not collide.externalId— the alert provider's stable issue/alert ID (e.g. Sentry issue ID117972276).project— the fullProjectConfigfor the target project. The materializer usesproject.pmto determine which PM provider to call and whichalertsslot to create the card in.hints—{ title: string; descriptionMarkdown: string }— content to put in the card. Built by the per-source format helper (see below).- Returns — the PM-native work item ID (Trello card ID, JIRA issue key, Linear issue ID). This ID goes directly into
TriggerResult.workItemId; no synthetic prefix.
The function throws AlertSlotMissingError (from src/integrations/alerting/_shared/types.ts) when the project's PM config doesn't have the alerts slot configured. Callers catch this and return null — no dispatch, operator must configure the slot.
Each materialization writes a row to the pr_work_items table (or updates the existing one) using the (projectId, externalSource, externalId) partial UNIQUE index:
CREATE UNIQUE INDEX uq_pr_work_items_project_external
ON pr_work_items (project_id, external_source, external_id)
WHERE external_source IS NOT NULL;A second Sentry alert for the same source/external ID on the same project hits the unique index and updates the existing row (lazy-heal: if the PM card was deleted, the UPDATE fetches the stored work_item_id, calls getWorkItem, and re-creates the card on 404). This makes the materializer fully idempotent — the same Sentry source event always produces the same workItemId.
After worker-side materialization, the Sentry webhook worker mirrors display metadata back into the resolved TriggerResult: workItemId, workItemTitle from the formatted alert hints, workItemUrl from the active PM provider, and the same fields on agentInput. Shared run persistence, dashboard headers, environment setup, and progress comments consume those values without alert-specific call-site branches.
The materializer calls getAlertsContainerId(project) / getAlertsStatusKey(project) (from src/pm/config.ts) to find the target list/status. These read:
| Provider | Config key | Meaning |
|---|---|---|
| Trello | lists.alerts |
Trello list ID where alert cards are created |
| JIRA | statuses.alerts |
JIRA status name/ID applied after issue creation |
| Linear | statuses.alerts |
Linear workflow state UUID applied after issue creation |
Configure this slot in the PM wizard's Status Mapping step (the "Alerts" row). The validation rule in src/triggers/shared/integration-validation.ts emits a pm-category error at agent pre-flight when an alerting trigger is enabled but this slot is unset.
A cascade-alert label (Trello: labels['cascade-alert']; JIRA: labels.cascadeAlert; Linear: labels.cascadeAlert) is applied to the work item after creation when configured. Optional — alert cards are created without it if the slot is unset.
Each alert source has a format helper that maps the raw webhook payload to AlertHints. Today Sentry has three helpers in src/integrations/alerting/_shared/format.ts: formatSentryCardBody (sentry), formatSentryIssueLifecycleCardBody (sentry-issue), and formatSentryMetricCardBody (sentry-metric). Adding PagerDuty, Datadog, or GitHub Alerts follows this pattern:
- Add the source literal to
AlertSourceinsrc/integrations/alerting/_shared/types.ts. - Add a
formatXxxCardBody(payload) → AlertHintsfunction informat.ts(or a new per-source file). - Create a trigger class that calls
materializeAlertWorkItem(source, externalId, project, hints). - Register the trigger in the alerting integration's
triggerHandlersarray.
{ "projectKey": "CLFX", "routing": { "discriminator": { "kind": "label", "value": "team-be" } } }