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
webhookSecretnow (9895 owns that config field). - Do not rewrite
executeCreateTicketbeyond 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_linkonly (notworkflow_definition.steps). - Ticket create routes/services write
service_ticketas today and do not talk to Plane. - The workflow connector step writes the link row after Plane
createWorkItemsuccess (or adopt-on-unique-violation). GET /api/tickets/:ticketNumberreads the link table forexternalLinks.- Connector
test/createWorkItem/listStatesgo 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 +
Databasemap.
Step 2 — Plane connector¶
- [ ]
defineConnector+plane-supportclient wrappingconnectorHttpRequest. - [ ] Register in
BUILT_IN_DEFINITIONS.
Step 3 — Link repo + persist helper¶
- [x] Persist
ticket_external_linkafter a successfulplane/createWorkItemstep. - [x] Unique-violation adopt (idempotent retry).
Step 4 — No auto-create on ticket insert¶
- [x] Catalog / email /
POST /api/tickets/create_ticketdo not call Plane. - [x] Catalog and
create_ticketinject reserved{{ticket.*}}.
Step 5 — API + admin UI¶
- [ ]
externalLinkson GET-by-number forticket:manageonly. - [ ] 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-Keyheader, no secret in logs,test/createWorkItem/listStatesmapped responses,critical→urgent. - Persist helper: missing ids fail the step; unique violation succeeds only when stored Plane id matches; snapshot
web_urlfrom 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 scopewith ticket + §4b barrier
9. Acceptance gates¶
- Floh ticket create with a configured
planeconnector creates a Plane work item within 60s and persiststicket_external_link - Second create for the same Floh ticket does not duplicate the Plane item
testsucceeds against valid PAT + workspace/project- Invalid
baseUrlrejected before HTTP - Credentials never logged or returned in error bodies
- Admin ticket detail shows
external_keyand 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+ configuredbaseUrl(strip/api/v1for 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
planeinstance.
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