feat(iios): generic opaque metadata + manual ticket assign (glue prereqs)

Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:

- thread: accept an opaque `metadata` bag on create (merged with membership),
  echo it in listThreads, and filter listThreads by `?metadata[key]=value`
  (equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
  inherited); add SupportService.assignTo — a policy-gated manual assignment
  of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
  listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
  assignTicket; ThreadSummary/Ticket expose the opaque bag.

Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:39:30 +05:30
parent 8c70b6d31f
commit b918a21083
10 changed files with 162 additions and 28 deletions
@@ -57,6 +57,19 @@ describe('SupportService (P4)', () => {
expect(links[0]?.threadId).toBe(threadId);
});
it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => {
const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } });
expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' });
expect(t.state).toBe('NEW');
const assigned = await support().assignTo(t.id, cust, 'agent_1');
expect(assigned?.state).toBe('OPEN');
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } });
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
expect(assigned?.assignedActorId).toBe(actor.id);
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1);
});
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
const t = await support().createTicket(cust, { subject: 's' });
await support().transition(t.id, cust, 'OPEN');