Skip to content

LSA-9791 — Plane ticketing integration (spike)

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.

Before writing code, follow your local copy of local/prompts.md's Implement the Plan procedure. The file is gitignored at the repo root; new contributors should obtain the canonical copy from the team onboarding wiki or ask a maintainer (search the team chat for "prompts.md autonomous loop" or open an issue tagged onboarding). The autonomous loop expects every section below to be complete and a recorded human sign-off at the bottom.

Story / ticket: LSA-9791 — Spike: investigate integration to Plane
Estimated PR size: small (docs only)
Owner: @floh/server connectors + ticketing
Spec / design doc: Plane product docs · Plane API · Plane webhooks
Parent epic: LSA-9065 Phase 3
Sibling ITSM story: LSA-9069 (Jira SM + ServiceNow — still To Do; not a blocker)

This document is the spike output: research, architecture, and the story split. Connector / webhook / migration code lives in the follow-up stories in §11, each of which must write its own scoped implementation plan against this architecture.


1. Goal

Record how Floh should integrate with Plane as an external ITSM peer, so Phase 3 does not invent a one-off Jira/ServiceNow shape and then bolt Plane on later.

Verdict: Plane is the better first ITSM connector. Floh stays the customer-facing ticket of record (portal + email). Plane is the engineering/IT work tracker. Map service_ticket → a Plane work item in one configured project (not Plane Intake — Floh already is the intake surface). Sync is bidirectional and phased. LSA-9069 reuses the shared link table and sync skeleton; it does not have to finish first.

When this spike closes, operators and implementers have a template-complete architecture source, LSA-9791 carries the findings, and three Stories under LSA-9065 own the code.

flowchart LR
  requester[Requester]
  floh[Floh service_ticket]
  plane[Plane work item]
  requester --> floh
  floh -->|"outbound REST create/update/comment"| plane
  plane -->|"inbound HMAC webhook"| floh

2. Scope contract

Hard rule: this spike MUST NOT modify files outside this list. Connector, webhook, and migration code belong in the §11 follow-ups.

In scope (will modify)

File Reason
docs/plans/2026-08-24-lsa-9791-plane-ticketing-integration.md This spike plan (architecture source)
work/pr-logs/2026-08/pr1127-lsa-9791.md Team-visible PR review-loop log
Jira LSA-9791 Fill empty description + findings comment
Three new LSA Stories under LSA-9065 Outbound create, inbound webhook, comments

Out of scope (will NOT modify in this PR)

File / area Why deferred Tracking ticket
packages/server/src/modules/connectors/handlers/plane* Built-in connector LSA-9894
packages/server/src/db/migrations/*ticket_external* Shared ITSM link table LSA-9894
packages/server/src/modules/plane-ticketing/** Inbound HMAC webhook LSA-9895
packages/web ticket detail Plane URL Admin display of external_key LSA-9894
Assignee / attachment sync Needs member lookup + presigned upload Future (not in §11)
OAuth Plane-app install PAT is v1 auth Future
LSA-9069 Jira SM / ServiceNow handlers Reuse the shared rails; do not replace that story LSA-9069
ADR-014 Ships with story 1, not this docs-only spike LSA-9894

Adjacent code that would benefit from a sweep but is explicitly NOT included

  • Tightening LSA-9069’s planned external_id / external_system columns on service_ticket into the link table — LSA-9894 owns that decision in code; this plan only records it.
  • Adding itsm to TICKET_SOURCES before inbound create exists — LSA-9895.

3. Invariants matrix

These are the recommended design contracts. This spike does not add tests. Each covering test is owned by the follow-up story named in the last column; that story’s own plan must copy the row into its §3 and land the spec.

# Invariant (one sentence) Enforcement layer Covering test (follow-up)
1 Outbound Plane HTTP uses connectorHttpRequest only; never raw fetch() in modules/. Plane client + existing architecture test Story 1 — plane-client.test.ts + no-raw-fetch-in-modules.test.ts
2 apiKey and webhookSecret are t.secret; never logged; API key sent only as X-API-Key. defineConnector + client never logs headers Story 1 — handler secret-flag spec
3 baseUrl is an origin (scheme + host + optional port, no path). Default https://api.plane.so. It passes validateConnectorEndpoint (no userinfo, http/https only). allowPrivateNetworkEndpoint permits RFC1918/loopback only; link-local and cloud-metadata (169.254.0.0/16, including 169.254.169.254) are rejected unconditionally. The client always appends /api/v1. Persist/runtime normalize by stripping a trailing /api/v1 so an operator who pastes the docs API root does not produce /api/v1/api/v1. validateConnectorEndpoint + Plane URL normalizer Story 1 — metadata always rejected; RFC1918 only with private-net flag; /api/v1 suffix not doubled
4 Ticket create / status / comment persist a sync-intent (outbox) row in the same DB transaction, then a BullMQ job talks to Plane. No outbound I/O inside the transaction. Enqueue failure after commit is recovered by a registered recurring outbox reconciler (plus optional startup scan) — never by rolling back the ticket, and never by waiting for a later ticket mutation. Registry-only is not a schedule: Story 1 must call schedulerService.addRecurringJob from packages/server/src/app.ts. Outbox + post-commit addJob + app.ts addRecurringJob Story 1 — Redis down after commit; reconcile without a later mutation still creates the Plane item
5 Create is idempotent: persist the chosen connector_id (and a provisional Floh correlation) before outbound I/O; external_id = ticket.id + external_source = "floh"; adopt on lookup / 409. Retries stay on that connector_id even if it is later disabled — do not re-resolve earliest-enabled mid-retry. Plane create id must be a non-empty UUID (not merely present). Sync job + ticket_external_link unique keys Story 1 — create-twice / already-linked; lost-success then connector A disabled does not create on B
6 Inbound webhook authenticates solely via HMAC-SHA256 of raw body bytes captured before Fastify JSON parsing (X-Plane-Signature); do not reconstruct with JSON.stringify. Missing/invalid → 401; unknown or disabled connector → 404; no cookie/bearer. After HMAC, a payload guard rejects missing/non-string event_id, work-item id, or nested data with 2xx + no-op (never 5xx — Plane retries 5xx then auto-disables). Mount in route-registry.ts and add /api/plane-ticketing/inbound/ to csrf.ts (same as email-ticketing). Raw-body parser + HMAC; route-registry.ts + csrf.ts Story 2 — signature / 401 / 404; non-canonical JSON; malformed required fields
7 Webhook event_id insert and ticket mutation commit in one transaction (or a reclaimable processing state). Completed markers make retries no-ops; in-progress/failed markers may reclaim. Completed markers are retained past Plane’s max retry window then removed by a scheduled bounded cleanup (in-progress/reclaimable rows are not deleted). Story 2 owns the marker table + repository (not Story 1’s ticket_external_link migration) and app.ts addRecurringJob for cleanup. Marker table UNIQUE + same-txn mutate + app.ts cleanup job Story 2 — mutate fails then Plane retries and the event still applies; cleanup does not drop in-progress
8 Loop prevention: ignore Floh’s own echoes with a one-shot provider request/revision marker consumed when the matching webhook arrives — not clocks, and not payload-hash / state-UUID equality (a later user open → started → open would otherwise look like an echo). Comment echoes use Floh external_id. last_outbound_at is telemetry only. Sync service Story 2/3 — echo-of-our-write; later equal state still applies; both clock-skew directions
9 Inbound status: reverse-map the Plane state UUID through connector stateMap first (so on_hold / pending_customer survive); fall back to Plane group only when the UUID is not in the map. Reject a stateMap that maps two Floh statuses to one UUID. Reopen from Floh terminal (resolved/closed) maps to in_progress (Floh forbids terminal → open). Plane cancelled while Floh is non-terminal: Story 2 performs the legal current → resolved → closed pair in one apply. Remaining illegal transitions log + skip (never 5xx). stateMap reverse lookup + group fallback + isValidTransition Story 2 — UUID hit for hold/pending; reopen to in_progress; cancelled while open closes via resolved
10 Comment visibility: Floh is_internal ↔ Plane access INTERNAL / EXTERNAL. Inbound comment_html is converted to plain text and length-bounded (existing 10,000-char contract) before persist. Persist a sanitized external-author label on the comment row (do not invent a local user UUID; author_id stays null). Internal notes never email. Inbound EXTERNAL comments on email-sourced tickets use the same public-comment lifecycle as comment-routes.ts: sanitizeCommentBody, stampFirstRespondedAt (that helper is on the ticket repo, not inside sendAgentReply), then TicketEmailService.sendAgentReply. Skipping the stamp records a false response-SLA breach. Comment sync + shared lifecycle + comment schema Story 3 — both directions; HTML stripped; author label round-trips; EXTERNAL emails and stamps first response
11 Logs carry ids, event name, new status/group only — never API keys, webhook secrets, comment bodies, or ticket descriptions. Structured log sites Story 1/2/3 — redaction specs
12 HTTP 429 from Plane reschedules the job using X-RateLimit-Reset; it does not fail the Floh request. Job handler Story 1 — 429 reschedule
13 Unique webhook event_id only deduplicates retries. Apply inbound status only if the provider sequence / updated_at is newer than the link row’s last applied value in the same transaction, with the link row locked or a CAS on the stored version (two concurrent deliveries can both pass a non-locking compare). Reverse-order or concurrent started after completed must not reopen Floh. Persist provider version on ticket_external_link; SELECT … FOR UPDATE or version CAS Story 2 — concurrent completed + started stays resolved
14 Creates resolve the earliest-created enabled plane connector (findFirstByTypeRaw filters deleted only — wrap it). Updates and comments load ticket_external_link.connector_id and skip if that connector is disabled or deleted — they must not retarget the next enabled instance. Inbound :connectorId lookup also rejects disabled/deleted. Resolver wrapper + webhook preHandler Story 1 — create skips disabled; Story 2 — update against a disabled linked connector is a no-op
15 Remote identity unique key is (connector_id, external_id) (plus external_system on the row). (external_system, external_id) alone collides when two Jira/Plane installations both issue id 10001. One Floh ticket may link to at most one row per connector_id. UNIQUE on ticket_external_link Story 1 — two connectors, same remote id, both links persist
16 Outbound update jobs read the ticket’s current status/priority at execution (or serialize per ticket / reject a stale version). A job must not apply the status it captured at enqueue if a later mutation has already committed. Story 1 is create-only (createWorkItem); Story 2 ships updateWorkItem on plane.ts and the itsm-sync update processor — enqueue-only is not enough. plane.ts updateWorkItem + itsm-sync update processor Story 2 — out-of-order status jobs leave Plane on the latest Floh state
17 Every ticket insert that should sync uses the shared post-commit outbox (Story 1: POST /api/tickets in service-tickets/routes.ts, workflows/catalog-routes.ts, email-ticketing/ticket-email-service.ts, step-executor.ts create_ticket). Status/priority PATCH and comment-driven status enqueue are Story 2 — do not ship those hooks in the v1 create story. Story 1 still modifies GET on the same file for manager-gated Plane fields. Shared enqueueItsmSync after commit Story 1 — API/catalog/email/engine create enqueue; Story 2 — priority PATCH and comment status
18 Inbound status changes go through the same ticket lifecycle as the status route: status-change audit and TicketSlaService.onTicketTerminalStatus on resolved/closed. Bypassing those hooks leaves SLA timers firing after Plane resolves the ticket. Shared lifecycle service Story 2 — inbound completed cancels SLA; audit row exists
19 The Plane inbound route must not return 429 from the global 200/min limiter (packages/server/src/app.ts) for a valid delivery. Plane does not retry 4xx and then auto-disables on repeated 5xx; use a per-route override or authenticated throttle that cannot drop legitimate events. Fastify route config.rateLimit override Story 2 — >200 signed events/min from one IP still persist
20 Persisted Plane config is validated at runtime: stateMap (every TicketStatus key, every value a UUID), workspaceSlug (non-empty safe path segment — no /, .., or whitespace), and projectId (UUID string). t.raw / unknown connector config does not enforce this; a malformed row must fail the job with a structured error, not reach Plane or interpolate into a URL path. Runtime guard in connector context builder Story 1 — missing stateMap.open; non-UUID projectId; slug with / fails closed
21 Story 1 registers the create job and the recurring outbox-reconcile job in JOB_QUEUE_MAP (queue-config.ts) and handler factories in handler-registry.ts, and schedules the reconciler with schedulerService.addRecurringJob in app.ts. SchedulerService.addJobresolveQueue rejects unknown names; workers are built only from that registry. Queue+handler without app.ts never runs. assertQueueHandlerConsistency + app.ts addRecurringJob Story 1 — enqueue + consume + reconcile without a later ticket mutation
22 Story 2 either creates/updates/deletes the Plane workspace webhook (connector command or install hook) or documents a mandatory operator procedure: public URL /api/plane-ticketing/inbound/:connectorId, webhookSecret, event subscriptions, PQL project_id = "<uuid>". Receiving-route-only is not enough for the converge gate. Connector command or operator runbook in story 2 plan Story 2 — newly configured connector can receive workitem.updated
23 Outbound addComment is idempotent on retry: stamp external_id = Floh comment id + external_source = "floh", then lookup / adopt on 409 the same way create does for work items. A lost success response must not duplicate the Plane comment or leave the outbox stuck. Comment job + Plane lookup Story 3 — ambiguous-success retry creates exactly one Plane comment
24 Provider-originated comments skip outbound addComment. Inbound persist must mark origin (or adopt/link the Plane comment id) so the shared comment lifecycle does not enqueue a Floh→Plane copy. One inbound comment → zero Plane addComment calls. Origin flag on outbox / comment row Story 3 — inbound comment does not POST to Plane
25 The admin Plane URL is not derived from REST baseUrl (api.plane.so is not the browser app). Persist Plane’s canonical HTML URL from the create/update response, or a validated webBaseUrl (same URL validator as baseUrl) used only for display. Cover Cloud default and self-hosted split-origin. external_key / Plane URL are ticket:manage only — requester/owner GET /api/tickets/:ticketNumber must omit them (portal shares that route). Link row or connector webBaseUrl; schema omit unless manager Story 1 — manager GET returns app origin; requester body has no Plane URL
26 Each follow-up story’s §5 file map lists every path named in the enforcement layer of §3 rows assigned to that story (bootstrap, CSRF, route registry, migrations, handler, schema, repository). A covering-test file that is not on the map is a plan defect. foo/** or “scheduler/*” is not exhaustive. Child-plan §5 vs this matrix Child plan review — map contains every enforcement path for that story’s §3 rows

Every “validate X before Y” invariant (2, 3, 6, 8, 9, 13, 14, 19, 20, 25) has a row in §7.5 for the follow-up that implements it. Invariant 26 is a plan-completeness contract (no runtime trust boundary).

Stories 2 and 3 (LSA-9895, LSA-9896) are parked for product v1 (opt-in one-way create only). Invariants 6–10 and 13–26 stay here so an unparked implementation does not rediscover them.


4. Persistence- and runtime-boundary matrix

N/A for this spike (docs + Jira only). Follow-up stories must fill this table. The surfaces they will touch:

Boundary Reads / writes Validator / auth Owning story
ticket_external_link migration writes new table CHECK / UNIQUE 1
Built-in plane connector execute outbound REST SSRF URL validator + t.secret 1
Ticket create / PATCH status / PATCH priority / catalog / email inbound / engine create_ticket / comment status side-effect outbox + enqueue after commit existing ticket auth 1, 2
POST /api/plane-ticketing/inbound/:connectorId HMAC verify; write ticket status / comments signature; CSRF-exempt 2, 3
Ticket comment POST enqueue comment sync after commit existing per-ticket auth 3

4a. Platform contract checklist

N/A — this spike is docs + Jira only. No bootstrap, config/secrets, routing, persistence, CI, Docker, or operator-runtime change. Story 2’s inbound route is a platform-adjacent surface (new prefix + CSRF exemption); that story’s plan must fill §4a/§4b.


5. File map

docs/plans/2026-08-24-lsa-9791-plane-ticketing-integration.md  [create]
work/pr-logs/2026-08/pr1127-lsa-9791.md                        [create]

Jira mutations (LSA-9791 description/comment; three new Stories) are out-of-repo and listed in §2.

Recommended implementation file map for story 1 (not this spike) — create path only; status/priority/comment outbound hooks belong in Story 2. Exhaustive against §3 rows assigned to Story 1 (invariant 26):

packages/server/src/app.ts                                         [modify — addRecurringJob for outbox reconcile]
packages/server/src/modules/connectors/handlers/plane.ts           [create — test, createWorkItem, listStates]
packages/server/src/modules/connectors/handlers/plane-support/     [create — client, address, `/api/v1` origin normalizer, README]
packages/server/src/modules/connectors/handlers/index.ts           [modify — register]
packages/server/src/modules/itsm-sync/                             [create — outbox, create job, link repository, reconcile scanner]
packages/server/src/modules/scheduler/queue-config.ts              [modify — JOB_QUEUE_MAP: create + reconcile]
packages/server/src/modules/scheduler/handler-registry.ts          [modify — create-job + reconcile factories]
packages/server/src/modules/workflows/step-executor.ts             [modify — post-commit create dispatch]
packages/server/src/modules/workflows/catalog-routes.ts            [modify — post-commit create dispatch]
packages/server/src/modules/email-ticketing/ticket-email-service.ts [modify — post-commit create dispatch]
packages/server/src/modules/service-tickets/routes.ts              [modify — POST /api/tickets create enqueue + GET manager-gated fields; do not enqueue status/priority]
packages/server/src/shared/schemas/service-tickets.ts              [modify — TicketResponse link fields, manager-gated]
packages/server/src/db/schema/ticketing-tables.ts                  [modify — ticket_external_link]
packages/server/src/db/migrations/<ts>_ticket_external_link.ts     [create]
packages/web/.../ticket detail                                     [modify — external_key + URL for managers]

Recommended story 2 file map (parked) — exhaustive against §3 rows assigned to Story 2 (invariant 26):

packages/server/src/app.ts                                         [modify — addRecurringJob for webhook-marker cleanup]
packages/server/src/route-registry.ts                              [modify — mount POST /api/plane-ticketing]
packages/server/src/shared/csrf.ts                                 [modify — exempt /api/plane-ticketing/inbound/]
packages/server/src/modules/plane-ticketing/                       [create — raw-body parser, HMAC route, rateLimit override]
packages/server/src/modules/service-tickets/routes.ts              [modify — status + priority PATCH enqueue]
packages/server/src/modules/service-tickets/comment-routes.ts      [modify — enqueue status when comment transitions]
packages/server/src/modules/service-tickets/repository.ts          [modify — TICKET_SOURCES += itsm]
packages/server/src/modules/itsm-sync/                             [modify — update processor]
packages/server/src/modules/connectors/handlers/plane.ts           [modify — updateWorkItem + webhook create/update/delete]
packages/server/src/modules/scheduler/queue-config.ts              [modify — update job + marker-cleanup queue]
packages/server/src/modules/scheduler/handler-registry.ts          [modify — update + cleanup factories]
packages/server/src/db/schema/ticketing-tables.ts                  [modify — webhook event-id marker table]
packages/server/src/db/migrations/<ts>_plane_webhook_event.ts      [create]

Recommended story 3 file map (parked) — exhaustive against §3 rows assigned to Story 3 (invariant 26):

packages/server/src/modules/service-tickets/comment-routes.ts      [modify — outbound comment enqueue]
packages/server/src/modules/service-tickets/comment-repository.ts  [modify — persist null author_id + external-author label]
packages/server/src/modules/service-tickets/repository.ts          [modify — stampFirstRespondedAt used by inbound lifecycle]
packages/server/src/shared/schemas/ticket-comments.ts              [modify — author_name from stored label when no local user]
packages/server/src/db/schema/ticketing-tables.ts                  [modify — external author column on ticket_comment]
packages/server/src/db/migrations/<ts>_ticket_comment_external_author.ts [create]
packages/server/src/modules/connectors/handlers/plane.ts           [modify — addComment]
packages/server/src/modules/itsm-sync/                             [modify — comment job + origin flag]
packages/server/src/modules/plane-ticketing/                       [modify — inbound comment persist; skip outbound addComment; stamp + email]
packages/server/src/modules/scheduler/queue-config.ts              [modify — comment job]
packages/server/src/modules/scheduler/handler-registry.ts          [modify — comment-job factory]

6. Implementation steps

This spike’s work is close-out only.

Step 1 — Write this plan

  • Author the architecture (Plane API, mapping, connector + webhook, link table, story split) against the template.

Verification:

  • [x] Template sections 1–12 are present.
  • [x] §2 in-scope is this plan, the team PR log, and Jira close-out.
  • [x] §4a is N/A with rationale.

Step 2 — Update LSA-9791

  • Fill the empty description with Context / Plan / AC / Security / Dependencies.
  • Comment with the verdict and the three follow-up keys.

Verification:

  • [x] LSA-9791 description is non-empty and matches the issue-content contract.
  • [x] Comment names the three child Stories.

Step 3 — File three Stories under LSA-9065

  • Story 1: shared link + Plane outbound create.
  • Story 2: outbound updates + inbound webhook.
  • Story 3: bidirectional comments.
  • Epic Link LSA-9065; component floh; Blocks/Relates links as in §11.

Verification:

  • [x] Three Story keys exist and appear in §11.

7. Testing strategy

  • This spike: no automated tests (docs + Jira).
  • Follow-ups: unit tests for client, signature, mapping, and idempotency; architectural no-raw-fetch; webhook 401/404; integration against a mocked Plane HTTP (Testcontainers optional — Plane has no official mock; prefer undici MockAgent like Slack).

7.5 Malformed-input matrix (negative-shape coverage)

This spike has no trust boundary. The matrix below is the contract follow-up stories must copy into their own plans.

Trust boundaries (future): connector baseUrl / webBaseUrl / apiKey; Plane JSON responses; inbound webhook raw body + X-Plane-Signature; persisted stateMap / workspaceSlug / projectId.

# Input source Trust boundary / function Malformed shape Expected behavior Spec file / it(...) name §3 inv. Story
1 connector baseUrl address validator https://user:pass@host Config / call rejected; no request plane-handler.test.ts — embedded credentials 3 1
2 connector baseUrl address validator http://169.254.169.254/ Always rejected, even with allowPrivateNetworkEndpoint plane-handler.test.ts — metadata always blocked 3 1
2b connector baseUrl address validator http://10.0.0.8/ with private-net flag Allowed (RFC1918 opt-in); metadata still blocked plane-handler.test.ts — RFC1918 with private-net flag 3 1
2c connector baseUrl URL normalizer https://api.plane.so/api/v1 or self-hosted …/api/v1/ stored/used origin has no /api/v1; requests are /api/v1/… once plane-handler.test.ts — docs API root not doubled 3 1
3 connector apiKey buildPlaneContext missing / whitespace Structured connector error; secret not logged plane-handler.test.ts — missing apiKey 2 1
4 inbound header X-Plane-Signature webhook route missing / wrong hex 401; no ticket write plane-webhook.test.ts — invalid signature 6 2
5 inbound body webhook route valid HMAC, unknown connectorId 404; no write plane-webhook.test.ts — unknown connector 6 2
6 inbound body webhook apply event not in allowlist 2xx + no-op; log event name only plane-webhook.test.ts — ignored event 7 2
7 inbound data.state UUID status mapper UUID in stateMap for on_hold / pending_customer Floh status is the mapped value, not the Plane group plane-sync.test.ts — UUID preserves hold / pending 9 2
8 inbound data.state group status mapper UUID not in stateMap; unknown group log + skip; 2xx plane-sync.test.ts — unknown state group 9 2
9 inbound status isValidTransition cancelled while Floh open apply open → resolved → closed in Story 2; do not 500 plane-sync.test.ts — cancelled while open closes 9 2
10 inbound webhook pair echo filter Plane clock behind / ahead of Floh real user update applied; Floh echo ignored plane-sync.test.ts — both clock-skew directions 8 2
11 inbound events apply order started delivered after completed Floh stays resolved; no reopen plane-sync.test.ts — reverse-order delivery 13 2
11b inbound events apply order concurrent completed and started Floh stays resolved (CAS / row lock) plane-sync.test.ts — concurrent deliveries 13 2
12 inbound EXTERNAL comment comment lifecycle email-sourced ticket, first agent-side reply persist public comment, stamp first_responded_at, send threaded requester email plane-comment-sync.test.ts — EXTERNAL emails and stamps SLA 10 3
12b inbound Plane comment comment persist Plane actor has no Floh user UUID author_id null; GET comment author_name is the sanitized label plane-comment-sync.test.ts — external author round-trips 10 3
13 Plane create response response guard missing / whitespace / non-UUID id job fails structured; no unusable link row plane-client.test.ts — malformed create body 5 1
14 inbound body payload guard after HMAC missing / non-string event_id, work-item id, or data 2xx + no-op; no DB write; webhook stays enabled plane-webhook.test.ts — malformed required fields 6 2
15 persisted stateMap runtime guard missing key / non-UUID value job fails structured; no Plane request plane-handler.test.ts — malformed stateMap 20 1
16 outbound create resolver enabled filter earliest-created Plane connector enabled=false no create HTTP; next enabled instance used or skip itsm-sync.test.ts — disabled connector skipped on create 14 1
16b outbound update job link resolver linked connector enabled=false, another Plane enabled skip; do not retarget the other connector itsm-sync.test.ts — update uses link.connector_id 14 2
17 inbound status mapper Plane backlog while Floh resolved Floh becomes in_progress (not open) plane-sync.test.ts — reopen from terminal 9 2
18 persisted workspaceSlug runtime guard empty / contains / or .. job fails structured; slug not interpolated into URL plane-handler.test.ts — unsafe workspaceSlug 20 1
19 persisted projectId runtime guard non-UUID string job fails structured; no Plane request plane-handler.test.ts — non-UUID projectId 20 1
20 outbound comment retry comment job addComment succeeds then worker loses the response lookup by external_id adopts; no duplicate Plane comment plane-comment-sync.test.ts — ambiguous-success retry 23 3
21 PATCH /api/tickets/:id/priority outbox dispatcher priority change on a linked ticket outbox row + job; Plane priority converges itsm-sync.test.ts — priority PATCH enqueues 17 2
22 inbound comment origin suppression Plane user creates a comment persist in Floh; zero Plane addComment calls plane-comment-sync.test.ts — inbound does not re-POST 24 3
23 inbound webhook pair echo filter user open → started → open after Floh wrote open Floh applies the later open; not discarded as echo plane-sync.test.ts — later equal state still applies 8 2
24 connector webBaseUrl / link URL display URL Cloud baseUrl is api.plane.so manager GET returns app origin, not API host plane-handler.test.ts — Cloud display URL 25 1
25 GET ticket as requester response schema linked ticket, caller lacks ticket:manage body omits external_key and Plane URL ticket-routes.test.ts — requester cannot see Plane URL 25 1

8. Stopping criteria for the autonomous loop

This spike has no code review loop. Close-out is done when:

  • [x] This plan file exists and matches the template.
  • [x] LSA-9791 description + comment are updated.
  • [x] Three follow-up Stories are filed under LSA-9065 and listed in §11.
  • [ ] No connector/webhook/migration files are in the spike diff.

Follow-up stories run the standard two-clean-round loop against their plans. If two consecutive rounds raise NEW MAJORs that do not map to §3 here, extend this architecture doc (or the child plan) and halt for human sign-off. Requests for assignee/attachment/OAuth sync are out of scope (see §2 / §11).


9. Acceptance gates (user-visible)

Spike gates (LSA-9791):

  • [x] Architecture source exists at this path.
  • [x] LSA-9791 description records the verdict (Plane first; Floh is system of record; work items not Intake).
  • [x] Three implementation Stories are filed and linked.

User-visible product gates belong on the follow-ups (creating a Floh ticket creates a Plane work item within 60s except when Plane 429s or the outbox cannot enqueue because Redis is down — those cases retry via invariant 12 / 4 and are excluded from the 60s SLO; resolving either side converges; comments round-trip with INTERNAL/EXTERNAL).


10. Risks and unknowns

  • Risk: Plane webhooks refuse localhost / RFC1918, so local e2e needs a tunnel. Mitigation: unit tests with a mock signer; no ngrok in v1.
  • Risk: 60 req/min per API key. Mitigation: job-level 429 backoff; one connector instance per workspace in v1. The 60s create gate is best-effort while Plane and Redis are healthy — 429/outage paths must still converge, not meet 60s.
  • Risk: State names are per-project. Plane groups cannot represent Floh on_hold / pending_customer. Mitigation: outbound stateMap of UUIDs; inbound reverse-maps UUID first, group is fallback only (invariant 9).
  • Risk: LSA-9069 still describes two columns on service_ticket. Mitigation: story 1 lands ticket_external_link; comment on LSA-9069 pointing here.
  • Risk: Echo loops if outbound create triggers workitem.created. Mitigation: invariant 8 — one-shot provider revision marker, not clocks or payload-hash equality.
  • Risk: Out-of-order webhook delivery reopens a resolved ticket. Mitigation: invariant 13 — provider sequence / updated_at compare-before-write.
  • Open question: per-queue connector vs single earliest-created plane instance. Resolved by: creates use earliest-created enabled; updates/comments stay on link.connector_id (skip if disabled).
  • Open question: Plane Cloud vs self-hosted as the first supported target. Resolved by: both — operator baseUrl is an origin (default https://api.plane.so); client appends /api/v1; trailing /api/v1 on input is stripped (invariant 3).

Research (architecture source)

Official references: Plane docs, API introduction, create work item, comments, states, webhooks.

What Plane gives us

  • Cloud REST origin: https://api.plane.so (client appends /api/v1). Self-hosted origin: https://{custom-domain} (same append). Operator input that already ends in /api/v1 is normalized to the origin.
  • Auth v1: PAT (plane_api_…) in X-API-Key. OAuth Bearer exists; not required for v1 (same shape as Slack bot token).
  • Rate limit: 60 req/min per key; X-RateLimit-Remaining / X-RateLimit-Reset.
  • Pagination: cursor value:offset:is_prev, max 100/page.
  • Work items: POST/PATCH/GET …/workspaces/{slug}/projects/{id}/work-items/. Create accepts name, description_html, priority, state, assignees[], external_id, external_source. Lookup: ?external_id=&external_source=.
  • States: each has group backlog | unstarted | started | completed | cancelled.
  • Comments: comment_html, access INTERNAL | EXTERNAL, plus external_id / external_source.
  • Webhooks v2: workspace-level; public URL only; HMAC-SHA256 hex in X-Plane-Signature over raw body; dedup event_id; delivery_id per attempt. 2xx success; 5xx retries then auto-disable; 4xx not retried. Subscribe workitem.created, workitem.updated, workitem.comment.created; PQL filter project_id = "<uuid>".

Field mapping

Floh Plane Notes
title name Required on create
description description_html Escape; Floh stores plain text
priority critical urgent Only mismatch. high/medium/low are 1:1. Plane also has none
status project state UUID Outbound: connector stateMap. Inbound: reverse-map UUID through stateMap, then state’s group
ticket.id external_id Always external_source = "floh"
ticket_number description footer + optional work-item link Human-readable back-pointer
public comment access=EXTERNAL Stamp external_id = Floh comment id
internal note access=INTERNAL Same. Never emailed (existing rule)
assignee_id assignees[] Deferred — member UUID lookup
attachments 3-step presigned upload Deferred

Inbound status: if the Plane state UUID is a stateMap value, use that Floh status (on_hold / pending_customer require this). Otherwise map by group (honor TICKET_STATUS_TRANSITIONS in packages/server/src/modules/service-tickets/state-machine.ts):

  • backlog / unstartedopen when Floh is non-terminal; in_progress when Floh is resolved or closed (terminal → open is illegal)
  • startedin_progress
  • completedresolved (Floh forbids open → closed)
  • cancelled → Story 2 applies current → resolved → closed when Floh is non-terminal; resolved → closed when already resolved. Do not skip an active cancelled item.

Reuse two Floh patterns; do not invent a third.

  1. Built-in connector (defineConnector, like Slack/Vault) for outbound REST via connectorHttpRequest.
  2. Signed inbound webhook module (like packages/server/src/modules/email-ticketing/webhook-routes.ts) for Plane → Floh.

Connection config (plane type, category itsm):

  • baseUrlorigin only (no path); default https://api.plane.so; shared URL validator; allowPrivateNetworkEndpoint for self-hosted RFC1918. REST only — not the browser URL. Strip a trailing /api/v1 before persist/use; the client always joins {origin}/api/v1/….
  • webBaseUrl — optional; same URL validator; used only to build the admin ticket hyperlink when the create response has no canonical HTML URL. Cloud default https://app.plane.so.
  • apiKeyt.secret, X-API-Key only.
  • workspaceSlug, projectIdruntime-validated (safe path segment / UUID); never interpolate untrusted config into the URL path.
  • stateMapRecord<TicketStatus, stateUuid> for outbound; runtime-validated (every status key, every value a UUID) because t.raw does not enforce this.
  • webhookSecrett.secret, Plane’s plane_wh_… key (not the API key).

Commands: test, createWorkItem, listStates in Story 1. Story 2 adds updateWorkItem and webhook lifecycle (createWebhook / updateWebhook / deleteWebhook) or a mandatory operator procedure (public URL, secret, subscriptions, PQL project filter). Receiving-route-only is not enough. Story 3 adds addComment.

Shared link table (story 1; LSA-9069 reuses it instead of two columns on service_ticket):

ticket_external_link: ticket_id, connector_id, external_system (plane | jira_sm | servicenow), external_id, external_key (display, e.g. SUPPORT-42 from sequence_id), last_outbound_at (telemetry), last outbound state/payload id, last applied provider sequence / updated_at; unique (connector_id, external_id) (remote ids are installation-scoped — two Jira sites can both issue 10001) and unique (ticket_id, connector_id).

Add source value itsm to TICKET_SOURCES when inbound create exists (story 2).

Outbound: persist a sync-intent outbox row in the same ticket transaction, then enqueue BullMQ after commit (ADR-005). Story 1 dispatchers: POST /api/tickets, workflows/catalog-routes.ts, email-ticketing/ticket-email-service.ts, step-executor.ts create_ticket. Story 2 adds status/priority PATCH and comment-routes.ts, plus updateWorkItem and the itsm-sync update processor. Creates persist the chosen connector_id before HTTP and keep retries on that id (invariant 5). Recurring outbox reconcile is JOB_QUEUE_MAP and app.ts addRecurringJob (invariants 4, 21). Updates/comments use link.connector_id. Store the canonical browser URL; manager-only on GET (invariant 25).

Inbound: POST /api/plane-ticketing/inbound/:connectorId. Register in route-registry.ts. CSRF-exempt next to /api/email-ticketing/inbound/ in packages/server/src/shared/csrf.ts. Capture raw body before JSON parse. HMAC of those bytes; 401/404 with no side effect; disabled connector → 404. Payload guard after HMAC → 2xx no-op. Persist event_id markers in a Story 2 table (not ticket_external_link). event_id + mutate in one transaction; lock or CAS the link row’s provider version (invariant 13). Override the global 200/min limiter. Terminal inbound status uses audit + TicketSlaService.onTicketTerminalStatus. Completed webhook markers are cleaned up via app.ts addRecurringJob (invariant 7). Respond 2xx quickly. Log ids only.


11. Out-of-band cleanups deferred from this PR

Issue Tracking ticket
Shared ITSM link table + Plane outbound create (test, createWorkItem, listStates, job) LSA-9894
Outbound status/priority + inbound HMAC webhook LSA-9895
Bidirectional comments (INTERNAL/EXTERNAL) LSA-9896
Jira SM / ServiceNow on the same rails LSA-9069
Assignee mapping, attachments, deletes/archives, OAuth Plane app Do not file until product asks
ADR-014: Floh remains ticket of record; Plane is a bidirectional ITSM peer LSA-9894
Comment on LSA-9069 pointing at ticket_external_link instead of two ticket columns LSA-9894 implementer

LSA-9894 — Shared ITSM link + Plane outbound create

  • Migration + plane connector (test, createWorkItem, listStates) + outbox + recurring reconcile job in JOB_QUEUE_MAP, handler-registry.ts, and app.ts addRecurringJob. Shared post-commit create dispatcher: POST /api/tickets, step-executor.ts, catalog-routes.ts, ticket-email-service.ts. GET ticket: packages/server/src/shared/schemas/service-tickets.ts TicketResponse includes external_key + browser URL for ticket:manage only.
  • AC: creating a Floh ticket (catalog, API POST /api/tickets, or engine) with an enabled Plane connector creates a work item within 60s when Plane and Redis are healthy. Redis down still converges via the scheduled reconciler (app.ts, no later ticket mutation). Create retries stay on the originally selected connector_id. Requester GET omits Plane fields. Credentials never logged. Second create is idempotent. Plane create id must be a UUID. Operator baseUrl of https://api.plane.so/api/v1 does not double the /api/v1 prefix.

LSA-9895 — Outbound updates + inbound webhook (parked; not v1)

  • Status/priority PATCH → Plane via updateWorkItem + itsm-sync update processor (jobs read current Floh state at execution; priority PATCH uses the same outbox; updates use link.connector_id, skip if disabled). POST /api/plane-ticketing/inbound/:connectorId mounted in route-registry.ts, CSRF-exempt in csrf.ts. Marker table migration for event_id. Loop-safe via one-shot provider revision (invariants 8, 9, 13). Webhook provision (command or operator procedure). Raw-body HMAC, same-txn event_id, payload guard, limiter override, shared ticket lifecycle (audit + SLA). Marker cleanup via app.ts addRecurringJob.
  • AC: resolving in either system converges. Concurrent or reverse-order started after completed stays resolved (CAS/lock). stateMap UUID preserves on_hold / pending_customer. Reopen maps to in_progress. cancelled closes via resolved. Comment-driven pending_customer → open syncs status. Forged webhook → 401. Malformed signed payload → 2xx no-op. Retry after a failed mutate still applies once. Cleanup does not drop in-progress markers.
  • Blocked by LSA-9894. Parked until product asks for bidirectional sync.

LSA-9896 — Bidirectional comments (parked; not v1)

  • Public ↔ EXTERNAL, internal ↔ INTERNAL, both stamped with external_id. Lookup/adopt on retry (invariant 23). Inbound HTML → plain text + 10k bound. Sanitized external-author label persisted on ticket_comment (migration + repository + TicketCommentResponse; no invented local user). Inbound comments skip outbound addComment (invariant 24). Email-sourced EXTERNAL inbound uses stampFirstRespondedAt then sendAgentReply (invariant 10).
  • AC: agent note appears in Plane as INTERNAL; a lost addComment success then retry does not duplicate. One inbound Plane comment persists in Floh and does not POST back to Plane. Plane public comment appears on the Floh ticket with an author label (round-trips without a Floh user), is not treated as an agent-internal note, and on email-sourced tickets stamps first_responded_at and sends the existing threaded requester reply (invariant 10).
  • Blocked by LSA-9895. Parked until product asks for bidirectional sync.

LSA-9894 is the pathfinder for LSA-9069.


12. Sign-off

  • [x] Author — agent: plan complete and self-consistent.
  • [x] Architectural reviewer (human) — user: implementation requested against the attached spike plan on 2026-08-26.
  • [x] Platform reviewer (human) — N/A — non-cross-cutting change (docs + Jira only)
  • [x] Implementer — agent: started 2026-08-26.