Skip to content

LSA-9894 — Shared ITSM link table and Plane outbound create

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Story / ticket: LSA-9894 — Floh Feature: Ticketing — shared ITSM link table and Plane outbound create Estimated PR size: large (~1200–1500 LOC) Owner: @floh/server connectors + ticketing; @floh/web admin ticket detail Spec / design doc: docs/plans/2026-08-24-lsa-9791-plane-ticketing-integration.md Parent epic: LSA-9065. Blocks LSA-9895. Pathfinder for LSA-9069.


1. Goal

Product pivot (2026-09-01): v1 is opt-in and one-way. A catalog workflow creates a Floh ticket via create_ticket. A Plane work item is created only when that workflow also has a plane / createWorkItem connector step bound to {{ticket.id}} / {{ticket.ticketNumber}}. Email inbound and POST /api/tickets do not create Plane items. Bidirectional sync (LSA-9895 / 9896) is parked.

Operators configure one plane connector (PAT, workspace, project) and add the connector step on the catalogs that should open engineering work in Plane. Success persists ticket_external_link; admin ticket detail shows external_key plus a Plane URL. A second createWorkItem for the same Floh ticket is idempotent (adopt on 409 / unique link). Credentials never appear in logs or error bodies.


2. Scope contract

Hard rule: the implementation MUST NOT modify files outside this list.

In scope (will modify)

File Reason
docs/plans/2026-08-24-lsa-9791-plane-ticketing-integration.md Copy spike onto this branch
docs/plans/2026-08-28-lsa-9894-plane-outbound-create.md This implementation plan
docs/architecture/decisions.md ADR-014
docs/connectors/plane-connector.md Operator setup
docs/connectors/README.md Index row
packages/server/src/db/migrations/<ts>_ticket_external_link.ts New table
packages/server/src/db/schema/ticketing-tables.ts Kysely types
packages/server/src/db/schema/database.ts Database map
packages/server/src/modules/connectors/handlers/plane.ts defineConnector
packages/server/src/modules/connectors/handlers/plane-support/** Client, URL validator, config
packages/server/src/modules/connectors/handlers/index.ts Register built-in
packages/server/src/modules/itsm-sync/** Link repo, persist helper, URL builder, create-step gates
packages/server/src/db/migrations/<ts>_ticket_external_link_web_url.ts Nullable web_url snapshot column
packages/server/src/modules/scheduler/handler-registry.ts No Plane create job
packages/server/src/modules/scheduler/queue-config.ts No Plane create job
packages/server/src/modules/service-tickets/routes.ts GET externalLinks only
packages/server/src/modules/service-tickets/repository.ts Detail join / link read
packages/server/src/modules/workflows/catalog-routes.ts Inject reserved {{ticket.*}}
packages/server/src/modules/workflows/run-routes.ts PUT run variables preserves reserved snapshots
packages/server/src/modules/workflows/step-executor.ts Snapshot + persist after createWorkItem
packages/server/src/modules/workflows/engine.ts Engine wiring + reserved-key merge
packages/server/src/modules/workflows/validation.ts Reject reserved outputKey (ticket / submitter)
packages/server/src/modules/workflows/reserved-runtime-variables.ts SSOT reserved names + promote/outputKey barriers
packages/server/src/modules/workflows/ticket-run-variable.ts Re-export RESERVED_TICKET_VARIABLE
packages/server/src/modules/workflows/run-creator.ts Import shared reserved name constants
packages/server/src/modules/workflows/step-error-routing.ts Error envelope skips reserved slots
packages/server/src/modules/workflows/consent-resume.ts Consent envelope skips reserved slots
packages/server/src/modules/email-ticketing/ticket-email-service.ts No Plane create
packages/server/src/modules/email-ticketing/webhook-routes.ts No Plane create
packages/server/src/shared/schemas/service-tickets.ts Optional externalLinks
packages/web/src/app/features/tickets/ticket.types.ts DTO
packages/web/src/app/features/tickets/ticket-detail.component.ts Admin detail card
packages/web/src/app/features/tickets/ticket-detail.component.spec.ts UI spec
packages/server/test/unit/connectors/plane-*.test.ts Connector tests
packages/server/test/unit/itsm-sync/*.test.ts Link / persist / URL tests
Hook-site unit tests (catalog, tickets routes, email-ticketing, step-executor) No auto-create; reserved {{ticket.*}}
packages/server/test/unit/workflows/reserved-runtime-variables.test.ts Reserved promote / outputKey helper
packages/server/test/unit/workflows/validation.test.ts Reserved outputKey rejected at publish
packages/server/test/unit/workflows/create-ticket-validation.test.ts Default step id is not reserved ticket
packages/server/test/unit/workflows/engine-sub-workflow-guards.test.ts Reserved sub-workflow output mapping fails before child
packages/server/test/unit/run-routes.test.ts PUT preserves reserved runtime snapshots
packages/server/test/unit/workflows/step-error-routing.test.ts Error slot skips ticket
packages/server/test/unit/workflows/consent-resume.test.ts Consent slot skips ticket
.cursor/rules/server/gotchas/2026-09-03-pr1163-reserved-runtime-variable-write-barriers.mdc Write-barrier gotcha

Out of scope (will NOT modify in this PR)

File / area Why deferred Tracking ticket
Inbound webhook / CSRF exemption / webhookSecret Next story LSA-9895
Outbound status/priority PATCH, updateWorkItem Next story LSA-9895
Comments / addComment Later story LSA-9896
TICKET_SOURCES value itsm Inbound create LSA-9895
Portal requester ticket UI Admin detail only (defer)
Per-queue connector binding v1 = earliest plane instance (defer)
Assignee / attachment / OAuth Plane-app Product has not asked (defer)
Jira SM / ServiceNow handlers Shared table is the pathfinder LSA-9069

Adjacent, not included

  • Do not add webhookSecret now (9895 owns that config field).
  • Do not rewrite executeCreateTicket beyond the reserved {{ticket.*}} snapshot.
  • Do not add Plane URL to ticket list API.

3. Invariants matrix

# Invariant Enforcement layer Covering test
1 Outbound Plane HTTP uses connectorHttpRequest only. Plane client + no-raw-fetch-in-modules.test.ts plane-client.test.ts
2 apiKey is t.secret; sent only as X-API-Key; never logged or returned in API errors. Connector definition + errorMap handler secret-flag spec + log-redaction assertion
3 baseUrl passes shared URL validator (no userinfo; http/https only). Private nets require allowPrivateNetworkEndpoint. validateConnectorEndpoint SSRF / embedded-creds / private-net cases in §7.5
4 Plane HTTP happens only inside an explicit plane / createWorkItem workflow step. Catalog, email inbound, and POST /api/tickets never enqueue or call Plane. No hook-site enqueue; persist only after step catalog / routes / email / create_ticket specs
5 Create is idempotent: local plane link is adopted before outbound; Plane external_id = ticket.id; unique (ticket_id, external_system) and (external_system, external_id); unique-violation succeeds only when stored external_id equals the Plane id. Local adopt + persist match persist-plane-link + plane-create-work-item-step + step-executor-connector specs
6 HTTP 429 / retries are the connector-step contract, not a ticket-create hook. Existing connector execute plane-handler specs
7 A catalog without a Plane step creates a Floh ticket only. No auto-enqueue catalog + email + POST /api/tickets specs
8 Admin GET-by-number with ticket:manage includes externalLinks; callers without manage do not receive Plane URLs. Route + schema route unit test
9 Logs carry connector id, ticket id, Plane work-item id / external_key only — never title, description, or token. Client + job log-redaction assertion
10 Disabled connector instances fail the step before mock or outbound HTTP (same message as designer). Applies to every connector type, not only Plane. executeConnectorStep after resolveConnectorRefRaw isConnectorDisabled + step-executor-connector (plane + http)
11 Persist binds ticket_id from reserved variables.ticket.id (UUID + integer ticketNumber); interpolated externalId is overwritten and never used as the FK. preparePlaneCreateWorkItemStep plane-create-work-item-step + step-executor-connector
12 Plane work-item id must be a UUID; non-UUID responses fail the step. parseWorkItem + isUuid plane-client.test.ts
13 Manager GET prefers stored web_url; findByIdRaw throws omit URL only (still 200 + externalKey) with a structured warn. toExternalLinkDtos resolve-plane-connector.test.ts
14 Reserved runtime names ticket and submitter cannot be written via unauthorized outputKey, result.variables promotion, sub-workflow outputMapping, or PUT /api/runs/:id. Only create_ticket may promote ticket (legacy outputKey: "ticket" keeps the snapshot; payload is not written onto that key). validator + reserved-runtime-variables helpers validation.test.ts + reserved-runtime-variables.test.ts

4. Persistence- and runtime-boundary matrix

  • Migration writes ticket_external_link only (not workflow_definition.steps).
  • Ticket create routes/services write service_ticket as today and do not talk to Plane.
  • The workflow connector step writes the link row after Plane createWorkItem success (or adopt-on-unique-violation).
  • GET /api/tickets/:ticketNumber reads the link table for externalLinks.
  • Connector test / createWorkItem / listStates go through existing connector-execute routes (auth already on those routes).

No new public route prefix in this story (webhook is 9895).


4a. Platform contract checklist

Applies because this PR touches packages/server/src/db/** (migration + schema).

# Surface Status
1 env vars N/A — no new env; Plane config is connector row fields. N/A
2 secrets Yes — apiKey via existing t.secret / connector secret loader. Enforced in connector definition + tests
3 bootstrap order N/A — seed via existing seedConnectors() / loadBuiltInConnectors. N/A
4 shutdown drain N/A — reuses integrations worker. N/A
5 test-only hooks N/A — no new test-only exports on production modules. N/A
6 redaction Yes — structured logs ids-only (invariant 9); hideOutputInWorkflowRuns if create is callable as a workflow connector step. Yes
7 Docker readiness N/A — no image/script change. N/A
8 public-export surface Yes — built-in type seeds on boot; connector instances already flow through config-transfer. No new package export barrel required. Yes
9 operator runbook Yes — docs/connectors/plane-connector.md. Yes
10 CI two-step N/A — no new required GitHub check. N/A
11 rollback Yes — down() drops ticket_external_link; remove the Plane connector step (or disable the instance) to stop new creates. Documented in operator doc
12 telemetry / PII Yes — never log title/description/token; externalLinks omitted for non-manage callers. Yes

4b. Negative space

Capability Barrier Follow-up
Inbound HMAC webhook / CSRF-exempt /api/plane-ticketing/ Route not registered LSA-9895
webhookSecret on connection config Field not declared LSA-9895
updateWorkItem / addComment commands Not on the connector LSA-9895 / LSA-9896
Creating Floh tickets from Plane (source=itsm) Not a TICKET_SOURCES value LSA-9895
Portal My Tickets Plane URL portal-web not in scope (defer)
Per-queue Plane project Authors bind a specific connector instance on the step (defer)
Assignee / attachment / OAuth Not implemented; no stubs that pretend they work (defer)

5. File map (high level)

See §2 in-scope table. Table shape: ticket_external_link with unique (external_system, external_id) and (ticket_id, external_system). Connector commands: test, createWorkItem, listStates. Persist the link from the workflow createWorkItem step — there is no background create job.


6. Implementation steps

Step 0 — Plan docs on this branch

  • [x] Copy spike from LSA-9791.
  • [x] Write this plan from the attached Cursor plan (full template). Human architectural + platform sign-off recorded below (implementation requested 2026-08-28).

Step 1 — Migration + schema

  • [ ] New migration + Kysely types + Database map.

Step 2 — Plane connector

  • [ ] defineConnector + plane-support client wrapping connectorHttpRequest.
  • [ ] Register in BUILT_IN_DEFINITIONS.
  • [x] Persist ticket_external_link after a successful plane / createWorkItem step.
  • [x] Unique-violation adopt (idempotent retry).

Step 4 — No auto-create on ticket insert

  • [x] Catalog / email / POST /api/tickets / create_ticket do not call Plane.
  • [x] Catalog and create_ticket inject reserved {{ticket.*}}.

Step 5 — API + admin UI

  • [ ] externalLinks on GET-by-number for ticket:manage only.
  • [ ] Details card: external_key + rel="noopener noreferrer" link.

Step 6 — ADR-014, connector doc, LSA-9069 comment

  • [ ] ADR, operator doc, Jira comment.

7. Testing strategy

  • Connector: URL validation, X-API-Key header, no secret in logs, test / createWorkItem / listStates mapped responses, criticalurgent.
  • Persist helper: missing ids fail the step; unique violation succeeds only when stored Plane id matches; snapshot web_url from create-time config.
  • Create-step gates: disabled connector / missing {{ticket.*}} fail before HTTP; existing local link adopts stored ids.
  • Hooks: ticket insert sites do not call Plane; catalog injects {{ticket.*}}.
  • GET: manage sees links; requester/non-manage omits externalLinks.
  • Architecture: new handler has no raw fetch().
  • UI: detail renders key + URL; empty state when links absent.

7.5 Malformed-input matrix

Input source Malformed shape Expected behavior Spec file / case Invariant
Connector baseUrl userinfo (https://user:pass@host) reject before HTTP validate-plane-base-url.test.ts 3
Connector baseUrl ftp:// / missing scheme reject same 3
Connector baseUrl private IP without allowPrivateNetworkEndpoint reject same 3
Connector baseUrl private IP with flag validateConnectorEndpoint allow path same 3
Command params empty / whitespace workspaceSlug or projectId command fail, no HTTP plane-handler.test.ts 3
Plane HTTP 401 mapped error, no secret in message plane-handler.test.ts 2
Plane body missing id fail + structured warn, no link row plane-client.test.ts 9
Persist helper missing ticket id / Plane ids warn + fail the step persist-plane-link.test.ts 5
Persist helper unique link violation, same Plane id treat as success same 5
Persist helper unique link violation, different Plane id fail the step same 5
Connector step enabled === false fail, no HTTP step-executor-connector.test.ts 10
Connector step missing reserved ticket snapshot fail, no HTTP same + plane-create-work-item-step 11
Plane body non-UUID id PlaneApiError, no link row plane-client.test.ts 12
Manager GET findByIdRaw throws 200, key only, one warn resolve-plane-connector.test.ts 13
Step outputKey ticket / submitter reject publish validation.test.ts 14
Step result.variables { ticket: … } from non-create_ticket fail step, snapshot unchanged reserved-runtime-variables.test.ts 14
Sub-workflow mapping parent key ticket / submitter reject before child run engine-sub-workflow-guards.test.ts 14
PUT run variables forged ticket / submitter preserve existing runtime snapshots reserved-runtime-variables.test.ts 14
Floh description multiline \n / \r\n <br> after HTML escape plane-client.test.ts

8. Stopping criteria

  • Two consecutive review rounds with zero unresolved CRITICAL/MAJOR
  • Every §3 invariant has a passing test
  • CI green
  • Diff stays inside §2
  • PR description links this plan
  • Reassess if two consecutive rounds raise new MAJORs; 9895/9896/portal scope expansions reply Out of scope with ticket + §4b barrier

9. Acceptance gates

  • Floh ticket create with a configured plane connector creates a Plane work item within 60s and persists ticket_external_link
  • Second create for the same Floh ticket does not duplicate the Plane item
  • test succeeds against valid PAT + workspace/project
  • Invalid baseUrl rejected before HTTP
  • Credentials never logged or returned in error bodies
  • Admin ticket detail shows external_key and a Plane URL (noopener/noreferrer)

10. Risks and unknowns

  • Risk: Plane Cloud UI URL shape vs self-hosted. Mitigation: URL helper unit-tested; prefer API id + configured baseUrl (strip /api/v1 for app origin if needed).
  • Risk: 60 req/min. Mitigation: 429 + Reset header; one connector instance in v1.
  • Risk: Catalog + engine double-enqueue. Mitigation: unique link keys + skip enqueue when alreadyExisted.
  • Open question (resolved): no connector → no Plane create. Gated on earliest plane instance.

11. Out-of-band cleanups

  • Outbound status + inbound webhook — LSA-9895
  • Bidirectional comments — LSA-9896
  • Jira SM / ServiceNow on the same table — LSA-9069
  • Assignee / attachments / OAuth — do not file until product asks

12. Sign-off (required before code)

  • [x] Author — agent: plan complete (this document)
  • [x] Architectural reviewer (human) — user requested implementation of the attached Cursor plan on 2026-08-28
  • [x] Platform reviewer (human) — same request; §4a/§4b apply (migration) and were reviewed as part of the attached plan
  • [x] Implementer — agent: started 2026-08-28