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 taggedonboarding). 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_systemcolumns onservice_ticketinto the link table — LSA-9894 owns that decision in code; this plan only records it. - Adding
itsmtoTICKET_SOURCESbefore 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.addJob → resolveQueue 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/Awith 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; componentfloh; 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; preferundiciMockAgent 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: outboundstateMapof 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 landsticket_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_atcompare-before-write. - Open question: per-queue connector vs single earliest-created
planeinstance. Resolved by: creates use earliest-created enabled; updates/comments stay onlink.connector_id(skip if disabled). - Open question: Plane Cloud vs self-hosted as the first supported target. Resolved by: both — operator
baseUrlis an origin (defaulthttps://api.plane.so); client appends/api/v1; trailing/api/v1on 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/v1is normalized to the origin. - Auth v1: PAT (
plane_api_…) inX-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 acceptsname,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,accessINTERNAL|EXTERNAL, plusexternal_id/external_source. - Webhooks v2: workspace-level; public URL only; HMAC-SHA256 hex in
X-Plane-Signatureover raw body; dedupevent_id;delivery_idper attempt. 2xx success; 5xx retries then auto-disable; 4xx not retried. Subscribeworkitem.created,workitem.updated,workitem.comment.created; PQL filterproject_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/unstarted→openwhen Floh is non-terminal;in_progresswhen Floh isresolvedorclosed(terminal →openis illegal)started→in_progresscompleted→resolved(Floh forbidsopen → closed)cancelled→ Story 2 appliescurrent → resolved → closedwhen Floh is non-terminal;resolved → closedwhen already resolved. Do not skip an active cancelled item.
Recommended architecture¶
Reuse two Floh patterns; do not invent a third.
- Built-in connector (
defineConnector, like Slack/Vault) for outbound REST viaconnectorHttpRequest. - Signed inbound webhook module (like
packages/server/src/modules/email-ticketing/webhook-routes.ts) for Plane → Floh.
Connection config (plane type, category itsm):
baseUrl— origin only (no path); defaulthttps://api.plane.so; shared URL validator;allowPrivateNetworkEndpointfor self-hosted RFC1918. REST only — not the browser URL. Strip a trailing/api/v1before 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 defaulthttps://app.plane.so.apiKey—t.secret,X-API-Keyonly.workspaceSlug,projectId— runtime-validated (safe path segment / UUID); never interpolate untrusted config into the URL path.stateMap—Record<TicketStatus, stateUuid>for outbound; runtime-validated (every status key, every value a UUID) becauset.rawdoes not enforce this.webhookSecret—t.secret, Plane’splane_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 +
planeconnector (test,createWorkItem,listStates) + outbox + recurring reconcile job inJOB_QUEUE_MAP,handler-registry.ts, andapp.tsaddRecurringJob. Shared post-commit create dispatcher:POST /api/tickets,step-executor.ts,catalog-routes.ts,ticket-email-service.ts.GETticket:packages/server/src/shared/schemas/service-tickets.tsTicketResponseincludesexternal_key+ browser URL forticket:manageonly. - 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 selectedconnector_id. Requester GET omits Plane fields. Credentials never logged. Second create is idempotent. Plane createidmust be a UUID. OperatorbaseUrlofhttps://api.plane.so/api/v1does not double the/api/v1prefix.
LSA-9895 — Outbound updates + inbound webhook (parked; not v1)
- Status/priority PATCH → Plane via
updateWorkItem+itsm-syncupdate processor (jobs read current Floh state at execution; priority PATCH uses the same outbox; updates uselink.connector_id, skip if disabled).POST /api/plane-ticketing/inbound/:connectorIdmounted inroute-registry.ts, CSRF-exempt incsrf.ts. Marker table migration forevent_id. Loop-safe via one-shot provider revision (invariants 8, 9, 13). Webhook provision (command or operator procedure). Raw-body HMAC, same-txnevent_id, payload guard, limiter override, shared ticket lifecycle (audit + SLA). Marker cleanup viaapp.tsaddRecurringJob. - AC: resolving in either system converges. Concurrent or reverse-order
startedaftercompletedstaysresolved(CAS/lock).stateMapUUID preserveson_hold/pending_customer. Reopen maps toin_progress.cancelledcloses viaresolved. Comment-drivenpending_customer → opensyncs 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 withexternal_id. Lookup/adopt on retry (invariant 23). Inbound HTML → plain text + 10k bound. Sanitized external-author label persisted onticket_comment(migration + repository +TicketCommentResponse; no invented local user). Inbound comments skip outboundaddComment(invariant 24). Email-sourcedEXTERNALinbound usesstampFirstRespondedAtthensendAgentReply(invariant 10). - AC: agent note appears in Plane as INTERNAL; a lost
addCommentsuccess 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 stampsfirst_responded_atand 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.