From e64381cb527f2e36e972340a65eeb0a8daad393f Mon Sep 17 00:00:00 2001 From: Lennon Fiuza Date: Mon, 26 Oct 2026 18:57:50 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(api):=20ticket=20merge=20=E2=80=94=20a?= =?UTF-8?q?=20moved=20external=20ref=20records=20the=20ticket=20it=20came?= =?UTF-8?q?=20off=20(#10120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ticket_external_refs.origin_ticket_id: a plain FK to tickets ON DELETE SET NULL, the shape of ticket_comments.merged_from_ticket_id. Set only by the moved-ref UPDATE in ticketMerge/repoint.ts, COALESCEd so A→B→C keeps A; NULL for a ref written directly, no backfill. The Partner API upsert keeps it for the same external id and drops it when the external id changes. Export policy: included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F8mQQSv7pUuBL2gR5wsd9M --- ...000-ticket-external-refs-origin-ticket.sql | 26 +++++++++++++++++ .../ticketMerge.integration.test.ts | 28 +++++++++++++++++-- .../ticketMergeSchema.integration.test.ts | 22 +++++++++++++++ apps/api/src/db/schema/ticketExternalRefs.ts | 5 ++++ .../services/tenantExportPolicyRegistry.ts | 2 +- apps/api/src/services/ticketExternalRefs.ts | 6 +++- apps/api/src/services/ticketMerge/repoint.ts | 9 ++++-- 7 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sql diff --git a/apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sql b/apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sql new file mode 100644 index 00000000000..86309a6b965 --- /dev/null +++ b/apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sql @@ -0,0 +1,26 @@ +-- #10120 (follow-up to #8972 ticket merge, #8313 ticket import): record the +-- ticket a merge moved a ticket_external_refs row off. +-- +-- ticket_external_refs.origin_ticket_id plain FK → tickets ON DELETE SET NULL +-- +-- Set only by the moved-ref UPDATE in services/ticketMerge/repoint.ts, to the +-- tombstone the ref was written on (COALESCE: a ref moved A→B→C keeps A, as +-- ticket_comments.merged_from_ticket_id does). NULL for a ref written +-- directly; no backfill: refs moved before this column keep today's answer. +-- +-- Same shape as ticket_comments.merged_from_ticket_id +-- (2027-01-04-110000-ticket-merge.sql): not composite with org_id (the row's +-- own org_id RLS already fences the referenced ticket, and a tombstone shares +-- its survivor's org), so no DEFERRABLE requirement; ON DELETE SET NULL with a +-- nullable column is safe for the org-erasure cascade on its own. No index, +-- as for merged_from_ticket_id: tickets are hard-deleted only by the org +-- cascade, which deletes these refs in the same erasure. +-- +-- Export policy: included (one id). Writes no rows. + +ALTER TABLE public.ticket_external_refs ADD COLUMN IF NOT EXISTS origin_ticket_id uuid; + +ALTER TABLE public.ticket_external_refs + DROP CONSTRAINT IF EXISTS ticket_external_refs_origin_ticket_id_tickets_id_fk, + ADD CONSTRAINT ticket_external_refs_origin_ticket_id_tickets_id_fk + FOREIGN KEY (origin_ticket_id) REFERENCES public.tickets (id) ON DELETE SET NULL; diff --git a/apps/api/src/__tests__/integration/ticketMerge.integration.test.ts b/apps/api/src/__tests__/integration/ticketMerge.integration.test.ts index 1b8b2e6228e..850551069da 100644 --- a/apps/api/src/__tests__/integration/ticketMerge.integration.test.ts +++ b/apps/api/src/__tests__/integration/ticketMerge.integration.test.ts @@ -16,6 +16,7 @@ import { previewMerge } from '../../services/ticketMerge/preview'; import { db } from '../../db'; import { MergeRefusedError, TicketMergedError } from '../../services/ticketMerge/errors'; import { addTicketComment, type TicketActor } from '../../services/ticketService'; +import { upsertTicketExternalRef } from '../../services/ticketExternalRefs'; import { createDevice, createOrganization, createPartner, createSite, createUser } from './db-utils'; import { getTestDb } from './setup'; import { businessCalendarIntervals, businessCalendarRevisions, businessCalendars } from '../../db/schema'; @@ -235,9 +236,32 @@ describe('mergeTickets (#8972 W1, spec §3.3)', () => { INSERT INTO ticket_external_refs (ticket_id, org_id, partner_id, partner_service_principal_id, external_id) VALUES (${s.src}::uuid, ${s.org.id}::uuid, ${s.partner.id}::uuid, ${p2}::uuid, 'SRC-2') RETURNING id`); const res = await merge(s); - expect(await one(sql`SELECT ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)).toEqual({ ticket_id: s.dst }); - expect(await one(sql`SELECT ticket_id FROM ticket_external_refs WHERE id = ${collide.id}::uuid`)).toEqual({ ticket_id: s.src }); + // The moved ref records the ticket it came off (#10120); a ref that stays + // and one written directly on the survivor have no origin. + expect(await one(sql`SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)) + .toEqual({ ticket_id: s.dst, origin_ticket_id: s.src }); + expect(await one(sql`SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${collide.id}::uuid`)) + .toEqual({ ticket_id: s.src, origin_ticket_id: null }); + expect(await one(sql`SELECT origin_ticket_id FROM ticket_external_refs WHERE external_id = 'DST-1' AND partner_service_principal_id = ${p1}::uuid`)) + .toEqual({ origin_ticket_id: null }); expect(res.retainedExternalRefIds).toEqual([collide.id]); + + // Merged again (B→C): the ref keeps its earliest origin, as a comment does. + const c = await insertTicket(s.org.id, s.partner.id, { createdAt: new Date(Date.now() - 7_200_000) }); + await merge(s, s.dst, c); + expect(await one(sql`SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)) + .toEqual({ ticket_id: c, origin_ticket_id: s.src }); + + // The Partner API's upsert keeps the origin for the same external id, and + // drops it for a new one, which is written directly. + const upsert = (externalId: string) => withDbAccessContext(s.ctx, () => upsertTicketExternalRef({ + principalId: p2, partnerId: s.partner.id, ticketId: c, externalId, + })); + await upsert('SRC-2'); + expect(await one(sql`SELECT origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)).toEqual({ origin_ticket_id: s.src }); + await upsert('NEW-2'); + expect(await one(sql`SELECT external_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)) + .toEqual({ external_id: 'NEW-2', origin_ticket_id: null }); }, T); runDb('links: between-pair deleted, related re-canonicalised, collisions dropped and listed, duplicate_of follows the survivor', async () => { diff --git a/apps/api/src/__tests__/integration/ticketMergeSchema.integration.test.ts b/apps/api/src/__tests__/integration/ticketMergeSchema.integration.test.ts index 9b80be52451..a89852353c4 100644 --- a/apps/api/src/__tests__/integration/ticketMergeSchema.integration.test.ts +++ b/apps/api/src/__tests__/integration/ticketMergeSchema.integration.test.ts @@ -7,6 +7,7 @@ * tickets.merged_at CHECK (merged_into IS NULL) = (merged_at IS NULL) * tickets.merged_by_user_id → users ON DELETE SET NULL * ticket_comments.merged_from_ticket_id → tickets ON DELETE SET NULL + * ticket_external_refs.origin_ticket_id → tickets ON DELETE SET NULL (#10120) */ import './setup'; import { sql } from 'drizzle-orm'; @@ -140,6 +141,27 @@ describe('ticket merge schema (#8972 W1, spec §3.2)', () => { expect(after).toEqual([{ merged_from_ticket_id: null }]); }); + runDb('origin_ticket_id on external refs nulls when its ticket is hard-deleted (#10120)', async () => { + const { partner, org } = await seedOrg(); + const user = await createUser({ partnerId: partner.id }); + const src = await insertTicket(org.id, partner.id); + const dst = await insertTicket(org.id, partner.id); + const [principal] = (await getTestDb().execute(sql` + INSERT INTO partner_service_principals (partner_id, name, scopes, created_by, updated_by) + VALUES (${partner.id}::uuid, 'origin-fk', ARRAY['tickets:read'], ${user.id}::uuid, ${user.id}::uuid) RETURNING id + `)) as unknown as Array<{ id: string }>; + const rows = (await getTestDb().execute(sql` + INSERT INTO ticket_external_refs (ticket_id, org_id, partner_id, partner_service_principal_id, external_id, origin_ticket_id) + VALUES (${dst}::uuid, ${org.id}::uuid, ${partner.id}::uuid, ${principal!.id}::uuid, 'ORIGIN-1', ${src}::uuid) + RETURNING id + `)) as unknown as Array<{ id: string }>; + await getTestDb().execute(sql`DELETE FROM tickets WHERE id = ${src}::uuid`); + const after = (await getTestDb().execute(sql` + SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${rows[0]!.id}::uuid + `)) as unknown as Array<{ ticket_id: string; origin_ticket_id: string | null }>; + expect(after).toEqual([{ ticket_id: dst, origin_ticket_id: null }]); + }); + runDb('partial index exists and is valid', async () => { const rows = (await getTestDb().execute(sql` SELECT i.indisvalid, pg_get_expr(i.indpred, i.indrelid) AS pred diff --git a/apps/api/src/db/schema/ticketExternalRefs.ts b/apps/api/src/db/schema/ticketExternalRefs.ts index e0841d0abc5..b70672a6320 100644 --- a/apps/api/src/db/schema/ticketExternalRefs.ts +++ b/apps/api/src/db/schema/ticketExternalRefs.ts @@ -27,6 +27,11 @@ export const ticketExternalRefs = pgTable('ticket_external_refs', { // index idx_ticket_external_refs_org_external_number (org_id, // lower(external_number)); externalId stays the stable resume key. externalNumber: varchar('external_number', { length: 255 }), + // The ticket this ref was written on, when a merge moved it onto its + // survivor (#10120, 2027-01-08-120000): set only by ticketMerge/repoint.ts, + // kept across a second merge (A→B→C keeps A), NULL for a ref written + // directly. A plain FK like ticket_comments.merged_from_ticket_id. + originTicketId: uuid('origin_ticket_id').references(() => tickets.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [ diff --git a/apps/api/src/services/tenantExportPolicyRegistry.ts b/apps/api/src/services/tenantExportPolicyRegistry.ts index 2be6c9bab73..5850c531a00 100644 --- a/apps/api/src/services/tenantExportPolicyRegistry.ts +++ b/apps/api/src/services/tenantExportPolicyRegistry.ts @@ -977,7 +977,7 @@ export const CORE_TENANT_EXPORT_POLICY: TenantExportPolicyRegistry = { excludedSensitive: [], excludedOpen: [], }), - "ticket_external_refs": tablePolicy("org_id", {"included":["external_number","id","ticket_id","org_id","partner_id","partner_service_principal_id","external_id","external_url","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), + "ticket_external_refs": tablePolicy("org_id", {"included":["external_number","id","ticket_id","org_id","partner_id","partner_service_principal_id","external_id","external_url","created_at","updated_at","origin_ticket_id"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "ticket_form_org_links": tablePolicy("org_id", {"included":["id","form_id","org_id","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "ticket_forms": tablePolicy("org_id", {"included":["id","partner_id","org_id","name","description","category_id","title_template","description_intro","default_priority","default_tags","show_in_portal","is_active","sort_order","version","created_by","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["fields"]}), "ticket_links": tablePolicy("org_id", {"included":["id","org_id","source_ticket_id","target_ticket_id","link_type","created_by","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), diff --git a/apps/api/src/services/ticketExternalRefs.ts b/apps/api/src/services/ticketExternalRefs.ts index 5e833f15049..5380904e54e 100644 --- a/apps/api/src/services/ticketExternalRefs.ts +++ b/apps/api/src/services/ticketExternalRefs.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '../db'; import { ticketExternalRefs, tickets } from '../db/schema'; import { pgErrorConstraint } from '../utils/pgErrors'; @@ -102,6 +102,10 @@ export async function upsertTicketExternalRef(input: { externalId: input.externalId, externalUrl: input.externalUrl ?? null, ...(input.externalNumber !== undefined ? { externalNumber: input.externalNumber } : {}), + // A ref a merge moved (#10120) keeps its origin only while it + // names the same external id: a new one is written here directly. + originTicketId: sql`CASE WHEN ticket_external_refs.external_id = excluded.external_id + THEN ticket_external_refs.origin_ticket_id END`, updatedAt: new Date(), }, }) diff --git a/apps/api/src/services/ticketMerge/repoint.ts b/apps/api/src/services/ticketMerge/repoint.ts index a739e43b766..1bb188eec61 100644 --- a/apps/api/src/services/ticketMerge/repoint.ts +++ b/apps/api/src/services/ticketMerge/repoint.ts @@ -146,9 +146,14 @@ export async function repointTicketChildren( // UNIQUE (principal, ticket_id): a ref whose principal already has one on // the survivor STAYS on the tombstone, so a Partner API read of that - // external id returns the tombstone with mergedIntoTicketId (§3.7). + // external id returns the tombstone with mergedIntoTicketId (§3.7). A ref + // that moves records the ticket it was written on (#10120), kept across a + // later merge like a comment's merged_from_ticket_id (A→B→C keeps A), so + // its principal can tell the ref came off one of its own tickets. counts.ticket_external_refs = await affected(tx, sql` - UPDATE ticket_external_refs s SET ticket_id = ${dstId}::uuid + UPDATE ticket_external_refs s + SET ticket_id = ${dstId}::uuid, + origin_ticket_id = COALESCE(s.origin_ticket_id, ${srcId}::uuid) WHERE s.ticket_id = ${srcId}::uuid AND NOT EXISTS ( SELECT 1 FROM ticket_external_refs d From b81e7bda0b594072a78a0ae76b6b24335d38b964 Mon Sep 17 00:00:00 2001 From: Lennon Fiuza Date: Mon, 26 Oct 2026 18:57:55 -0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(api):=20ticket=20import=20=E2=80=94=20?= =?UTF-8?q?a=20ref=20a=20merge=20moved=20answers=20merged=5Fin=5Fbreeze=20?= =?UTF-8?q?from=20its=20origin=20(#10120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the principal's ref names an origin_ticket_id that is this principal's imported tombstone, the source id is skipped as merged_in_breeze with that tombstone and its survivor, as for a ref still on the tombstone. A moved ref with no origin (moved before the column) keeps the conflict answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F8mQQSv7pUuBL2gR5wsd9M --- .../ticketImport.integration.test.ts | 19 +++++++--- .../ticketImport/importWriter.flow.test.ts | 35 ++++++++++++++++++- .../src/services/ticketImport/importWriter.ts | 31 +++++++++++----- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/apps/api/src/__tests__/integration/ticketImport.integration.test.ts b/apps/api/src/__tests__/integration/ticketImport.integration.test.ts index 16859f51306..68446c9d34f 100644 --- a/apps/api/src/__tests__/integration/ticketImport.integration.test.ts +++ b/apps/api/src/__tests__/integration/ticketImport.integration.test.ts @@ -725,7 +725,7 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => { expect(await getTestDb().select().from(tickets).where(inArray(tickets.orgId, [other.orgA.id, fx.orgA.id]))).toEqual([]); }); - runDb('a ticket merged in Breeze between two passes is skipped as merged_in_breeze; a ref the merge moved is refused; nothing is written or charged', async () => { + runDb('a ticket merged in Breeze between two passes is skipped as merged_in_breeze, also when the merge moved its ref; a ref moved with no origin is refused; nothing is written or charged', async () => { const fx = await seed(); const first = await openWindow(fx); const open = { assigneeUserId: undefined, finalStatus: 'open' as const, resolvedAt: undefined, closedAt: undefined, resolutionNote: undefined }; @@ -770,6 +770,9 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => { expect(await refOf(a.sourceId)).toBe(ra.ticketId); expect(await refOf(c.sourceId)).toBe(live.id); expect(await refOf(e.sourceId)).toBe(rx.ticketId); + // E's ref stands for one moved before origin_ticket_id existed (#10120: no backfill). + await getTestDb().update(ticketExternalRefs).set({ originTicketId: null }) + .where(and(eq(ticketExternalRefs.partnerServicePrincipalId, fx.principal.id), eq(ticketExternalRefs.externalId, e.sourceId))); const touched = [ra.ticketId, rb.ticketId, rc.ticketId, rd.ticketId, re.ticketId, live.id, rx.ticketId]; const snapshot = async () => ({ @@ -788,10 +791,16 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => { skip: { reason: 'merged_in_breeze', mergedIntoTicketId: rb.ticketId }, commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, statusSkipped: false, ...NOTHING_CHANGED, }); - // A moved ref resolves to a ticket this principal did not import: refused, - // never resumed onto it (the known limit: on every pass). - await expect(importTicket(second.id, { ...c, comments: [...c.comments, article(6)] }, { partnerId: fx.partner.id })) - .rejects.toMatchObject({ code: 'MIGRATION_SOURCE_ID_IN_USE', status: 409, details: { reason: 'live_ticket', ticketId: live.id } }); + // A moved ref names its origin, this principal's tombstone C (#10120): + // the same answer as a ref still on the tombstone. + expect(await importTicket(second.id, { ...c, comments: [...c.comments, article(6)] }, { partnerId: fx.partner.id })).toEqual({ + ticketId: rc.ticketId, + outcome: 'skipped', + skip: { reason: 'merged_in_breeze', mergedIntoTicketId: live.id }, + commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, statusSkipped: false, ...NOTHING_CHANGED, + }); + // A moved ref with no origin resolves to a ticket this principal did not + // import: refused, never resumed onto it (on every pass). await expect(importTicket(second.id, { ...e, comments: [...e.comments, article(10)] }, { partnerId: fx.partner.id })) .rejects.toMatchObject({ code: 'MIGRATION_SOURCE_ID_IN_USE', status: 409, details: { reason: 'other_principal_ticket', ticketId: rx.ticketId } }); expect(await snapshot()).toEqual(before); diff --git a/apps/api/src/services/ticketImport/importWriter.flow.test.ts b/apps/api/src/services/ticketImport/importWriter.flow.test.ts index 9d3158e194b..257b1c79eb4 100644 --- a/apps/api/src/services/ticketImport/importWriter.flow.test.ts +++ b/apps/api/src/services/ticketImport/importWriter.flow.test.ts @@ -358,8 +358,41 @@ describe('importTicket — a ticket merged in Breeze (#8972) is skipped, final, expect(h.writes).toEqual([]); }); + it('answers a ref the merge moved onto the survivor from its origin, this principal\'s tombstone (#10120)', async () => { + const ORIGIN = '77777777-7777-4777-8777-777777777777'; + answer('ref', [{ ticketId: SURVIVOR, externalNumber: '#41023', externalUrl: null, originTicketId: ORIGIN }]); + // The survivor is a live ticket; the origin is this principal's import, merged into it. + answer('storedTicket', + [storedRow({ id: SURVIVOR, migrationWindowId: null, windowPrincipalId: null })], + [storedRow({ id: ORIGIN, mergedIntoTicketId: SURVIVOR, status: 'closed' })]); + + const result = await run(closedWithNotes); + + expect(result).toMatchObject({ + ticketId: ORIGIN, outcome: 'skipped', skip: { reason: 'merged_in_breeze', mergedIntoTicketId: SURVIVOR }, + commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, + }); + expect(h.log).toEqual(['ref', 'storedTicket', 'storedTicket']); + expect(h.locks).toBe(0); + expect(h.charges).toEqual([]); + expect(h.writes).toEqual([]); + }); + + it.each([ + ["another principal's tombstone", [storedRow({ mergedIntoTicketId: SURVIVOR, windowPrincipalId: '45454545-4545-4545-8545-454545454545' })]], + ['a live tombstone', [storedRow({ mergedIntoTicketId: SURVIVOR, migrationWindowId: null, windowPrincipalId: null })]], + ['a ticket the window cannot see', []], + ])('refuses a moved ref whose origin is %s', async (_label, originRows) => { + answer('ref', [{ ticketId: SURVIVOR, externalNumber: '#41023', externalUrl: null, originTicketId: TICKET }]); + answer('storedTicket', [storedRow({ id: SURVIVOR, migrationWindowId: null, windowPrincipalId: null })], originRows); + await expect(run(closedWithNotes)).rejects.toMatchObject({ + code: 'MIGRATION_SOURCE_ID_IN_USE', status: 409, details: { reason: 'live_ticket', ticketId: SURVIVOR }, + }); + expect(h.writes).toEqual([]); + }); + it.each([ - // The merge moved this import's ref onto a live survivor (the known limit: refused on every pass). + // The merge moved this import's ref onto a live survivor before origin_ticket_id existed (refused on every pass). ['a live ticket', { migrationWindowId: null, windowPrincipalId: null }, 'live_ticket'], // Checked before the tombstone: a never-imported live ticket that was later merged stays a conflict. ['a live tombstone', { migrationWindowId: null, windowPrincipalId: null, mergedIntoTicketId: SURVIVOR }, 'live_ticket'], diff --git a/apps/api/src/services/ticketImport/importWriter.ts b/apps/api/src/services/ticketImport/importWriter.ts index 52c98e07afe..f8a86c268d6 100644 --- a/apps/api/src/services/ticketImport/importWriter.ts +++ b/apps/api/src/services/ticketImport/importWriter.ts @@ -58,9 +58,11 @@ import { lockOpenWindowForWrite, readWindowDescriptor, windowWriteContext, type * Merged in Breeze (#8972): a ticket merged into another is a read-only * tombstone. Resuming one this principal imported writes nothing, charges * nothing and returns `outcome: 'skipped'` with `skip.reason: - * 'merged_in_breeze'` and the survivor (see `lockImportedTicket`). A ref that - * resolves to a ticket this principal did not import (a live ticket, merged - * or not, or another principal's import) is refused, never resumed. + * 'merged_in_breeze'` and the survivor (see `lockImportedTicket`), also when + * the merge moved the ref onto the survivor (`origin_ticket_id`, #10120). + * Any other ref that resolves to a ticket this principal did not import (a + * live ticket, merged or not, or another principal's import) is refused, + * never resumed. * * Must be called with no ambient DB context: it opens its own transactions, * and the throughput charge is a Redis round trip that must not hold one. @@ -112,11 +114,11 @@ export type ImportTicketResult = { * merge that committed mid-ticket), the transactions before it stay * committed, that transaction's charge is spent and it writes nothing. * - * Known limit: when the survivor had no ref from this principal, the merge - * moves the tombstone's ref onto the survivor (ticketMerge/repoint.ts). The - * source id then resolves to a ticket this principal did not import, and the - * resume is refused (`live_ticket` or `other_principal_ticket`) on every pass: - * nothing on the moved ref says where it came from. + * When the survivor had no ref from this principal, the merge moves the + * tombstone's ref onto the survivor (ticketMerge/repoint.ts) and records the + * tombstone in `origin_ticket_id` (#10120); the answer is the same. A ref + * moved before that column existed has no origin, and the resume is refused + * (`live_ticket` or `other_principal_ticket`) on every pass. */ export type ImportSkip = { reason: 'merged_in_breeze'; mergedIntoTicketId: string }; @@ -205,6 +207,8 @@ type StoredTicket = HeaderRow & { }; type StoredRef = { ticketId: string; externalNumber: string | null; externalUrl: string | null }; +/** The ref the plan resolves, with the ticket a merge moved it off (#10120). */ +type ResolvedRef = StoredRef & { originTicketId: string | null }; type HeaderChanges = { fields: UpdateTicketFieldsInput; @@ -260,12 +264,13 @@ function toCreateInput(t: ImportedTicket): CreateTicketInput { } as CreateTicketInput; } -async function readRef(principalId: string, sourceId: string): Promise { +async function readRef(principalId: string, sourceId: string): Promise { const [row] = await db .select({ ticketId: ticketExternalRefs.ticketId, externalNumber: ticketExternalRefs.externalNumber, externalUrl: ticketExternalRefs.externalUrl, + originTicketId: ticketExternalRefs.originTicketId, }) .from(ticketExternalRefs) .where(and(eq(ticketExternalRefs.partnerServicePrincipalId, principalId), eq(ticketExternalRefs.externalId, sourceId))) @@ -706,6 +711,14 @@ export async function importTicket( // A ref whose ticket the window's RLS hides: the same reason code the // locked re-check gives (lockImportedTicket). if (!stored) throw inUse(t.sourceId, { reason: 'org_outside_window', ticketId: ref.ticketId }); + // Merged in Breeze, and the merge moved the ref onto the survivor + // (#10120): the ticket it was written on is this principal's tombstone. + if (ref.originTicketId) { + const origin = await readStoredTicket(ref.originTicketId); + if (origin?.mergedIntoTicketId && origin.migrationWindowId && origin.windowPrincipalId === principalId) { + return { kind: 'merged' as const, ticketId: origin.id, mergedIntoTicketId: origin.mergedIntoTicketId }; + } + } // A ticket this principal did not import is never resumed, merged or not // (checked first, so a never-imported live ticket that was merged stays a // conflict, not a final skip): a live ticket, or another principal's From 60c7f27079ab941d8e9539cfa22129cc0d5c9e35 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Wed, 28 Oct 2026 12:08:46 -0600 Subject: [PATCH 3/5] chore(migrations): rename origin-ticket migration past main's newest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main gained 2027-01-08-140000 … 2027-01-10-110000 after this PR's push, so 2027-01-08-120000 would be ejected by the naming hook. Renamed to 2027-01-11-110000 (after #10064's 2027-01-11-1000xx slots). No SQL change. Co-Authored-By: Claude Fable 5.1 --- ...l => 2027-01-11-110000-ticket-external-refs-origin-ticket.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/api/migrations/{2027-01-08-120000-ticket-external-refs-origin-ticket.sql => 2027-01-11-110000-ticket-external-refs-origin-ticket.sql} (100%) diff --git a/apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sql b/apps/api/migrations/2027-01-11-110000-ticket-external-refs-origin-ticket.sql similarity index 100% rename from apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sql rename to apps/api/migrations/2027-01-11-110000-ticket-external-refs-origin-ticket.sql From b4bd073c963a7404407d2a463da2e7d4d2040f67 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Wed, 28 Oct 2026 21:16:02 -0600 Subject: [PATCH 4/5] test(tickets): expect the attachment counters on the moved-ref skip result Main added attachmentsWritten/attachmentsSkipped/attachmentBytes to the import result; the C-ticket expectation in the moved-ref test still used the old shape and failed under toEqual after main was merged in. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/integration/ticketImport.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/__tests__/integration/ticketImport.integration.test.ts b/apps/api/src/__tests__/integration/ticketImport.integration.test.ts index c2778c8321c..f7f5545b0db 100644 --- a/apps/api/src/__tests__/integration/ticketImport.integration.test.ts +++ b/apps/api/src/__tests__/integration/ticketImport.integration.test.ts @@ -800,7 +800,7 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => { ticketId: rc.ticketId, outcome: 'skipped', skip: { reason: 'merged_in_breeze', mergedIntoTicketId: live.id }, - commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, statusSkipped: false, ...NOTHING_CHANGED, + commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, statusSkipped: false, ...NO_FILES, ...NOTHING_CHANGED, }); // A moved ref with no origin resolves to a ticket this principal did not // import: refused, never resumed onto it (on every pass). From 820ba687d79df1ab60e70aeab0b02071cd2eccbd Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Wed, 28 Oct 2026 21:19:21 -0600 Subject: [PATCH 5/5] chore(migrations): sort the origin-ticket migration after main's 2027-01-12-100000 Co-Authored-By: Claude Opus 5.5 --- ...l => 2027-01-12-110000-ticket-external-refs-origin-ticket.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/api/migrations/{2027-01-11-110000-ticket-external-refs-origin-ticket.sql => 2027-01-12-110000-ticket-external-refs-origin-ticket.sql} (100%) diff --git a/apps/api/migrations/2027-01-11-110000-ticket-external-refs-origin-ticket.sql b/apps/api/migrations/2027-01-12-110000-ticket-external-refs-origin-ticket.sql similarity index 100% rename from apps/api/migrations/2027-01-11-110000-ticket-external-refs-origin-ticket.sql rename to apps/api/migrations/2027-01-12-110000-ticket-external-refs-origin-ticket.sql