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
@@ -143,6 +143,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.listThreads(bob))[0]?.unread).toBe(1);
});
it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => {
const s = svc();
// Two threads with different opaque app attributes — the kernel never interprets these values.
await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } });
await s.openThread(null, alice, { metadata: { source: 'other' } });
const all = await s.listThreads(alice);
expect(all).toHaveLength(2);
const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support');
expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' });
// Equality filter on the opaque bag returns only the matching thread.
const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } });
expect(filtered).toHaveLength(1);
expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' });
});
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });