# Messaging UI SDK — Foundation (Plan 1 of 3) Implementation Plan

> **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.

**Goal:** Build the transport-free core of `@insignia/iios-messaging-ui` — types, the `MessagingAdapter` contract, a shared conformance suite, a mock adapter, and the provider + hooks — proving the seam works with zero network.

**Architecture:** The core knows nothing about any transport. Hosts inject a `MessagingAdapter`; the provider holds it in context; hooks (`useConversations`, `useMessages`) call it. `iios-kernel-client` is reachable only from the `./adapters/kernel` subpath (Plan 3), never from core — enforced by a test, not convention. A conformance suite defines "a correct adapter" once, so the mock, kernel, and CRM adapters are all verified against one definition.

**Tech Stack:** TypeScript, React 19 (peer), tsup (ESM), vitest 3 + jsdom + @testing-library/react, pnpm workspaces.

**Spec:** `lynkeduppro-crm/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md`

**Scope of this plan:** Package scaffold, test infra, types, adapter contract + conformance suite, mock adapter, provider, hooks, boundary enforcement. **Out of scope:** UI components and CSS (Plan 2); kernel adapter, CRM adapter, attachments, and the CRM migration (Plan 3).

---

## Context an engineer needs

**Read these first:**
- `packages/iios-message-web/` — the existing headless package. **This is the anti-pattern**, not the model: 109 lines, narrowed `send` API, no consumers. We are deliberately building the layer it refused to build.
- `lynkeduppro-crm/src/lib/messenger-api.ts` — the source of the adapter contract. Its `MessengerData`/`ThreadData` interfaces already survived two implementations (live + mock).
- `scripts/check-import-boundary.mjs` — the repo's dependency law.

**Repo facts that will bite you:**
- Root `vitest.config.ts` includes only `packages/**/src/**/*.{test,spec}.ts` — **`.tsx` is not matched**, and its `globalSetup` provisions a Postgres DB on port 5434 for every run. This package therefore gets its **own** `vitest.config.ts` with `environment: 'jsdom'` and **no** `globalSetup`. Never add a DB dependency to a pure-UI package.
- `tsconfig.base.json` sets `"module": "commonjs"` — every web package overrides this to `ESNext` + `moduleResolution: bundler` + `jsx: react-jsx`. Copy that override; don't fight the base.
- Packages are ESM-only (`"type": "module"`).

**The bug we are fixing (do not port it):** the CRM infers the current actor id by scanning for a message you sent (`messenger-api.ts:200-203`). Until you have sent in a thread, `myActorId` is `null`, so the REST poll fallback computes `mine: false` for **every** message. Root cause: the kernel's receipt event carries no `threadId`. Our fix is `adapter.currentActorId()` — identity is the adapter's job, stated explicitly. Task 8 has the regression test.

---

## File Structure

| File | Responsibility |
|---|---|
| `packages/iios-messaging-ui/package.json` | Package manifest, exports map, scripts |
| `packages/iios-messaging-ui/tsup.config.ts` | ESM build, 3 entries (core, adapters/mock, adapters/kernel) |
| `packages/iios-messaging-ui/tsconfig.json` | ESNext + jsx override over base |
| `packages/iios-messaging-ui/vitest.config.ts` | jsdom, includes `.tsx`, **no** globalSetup |
| `src/types.ts` | Domain types only. Zero imports. |
| `src/adapter.ts` | The `MessagingAdapter` interface |
| `src/conformance.ts` | Shared suite defining a correct adapter (shipped, not test-only) |
| `src/conformance.test.ts` | Runs the suite against the mock adapter |
| `src/adapters/mock.ts` | In-memory adapter for demos/tests |
| `src/provider.tsx` | `MessagingProvider` + `useAdapter` |
| `src/hooks/use-conversations.ts` | Conversation list state |
| `src/hooks/use-messages.ts` | Thread state, optimistic send, subscribe reconciliation |
| `src/index.ts` | Public barrel |
| `src/boundary.test.ts` | Asserts core never imports a transport |

Files split by responsibility, not layer. `conformance.ts` ships in `src/` (not a test file) because Plan 3's adapters import it to verify themselves.

---

### Task 1: Package scaffold + test infrastructure

**Files:**
- Create: `packages/iios-messaging-ui/package.json`
- Create: `packages/iios-messaging-ui/tsconfig.json`
- Create: `packages/iios-messaging-ui/tsup.config.ts`
- Create: `packages/iios-messaging-ui/vitest.config.ts`
- Create: `packages/iios-messaging-ui/src/index.ts`
- Test: `packages/iios-messaging-ui/src/smoke.test.tsx`

- [ ] **Step 1: Create the package manifest**

Create `packages/iios-messaging-ui/package.json`:

```json
{
  "name": "@insignia/iios-messaging-ui",
  "version": "0.1.0",
  "type": "module",
  "main": "dist/index.js",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./adapters/mock": {
      "types": "./dist/adapters/mock.d.ts",
      "import": "./dist/adapters/mock.js"
    },
    "./conformance": {
      "types": "./dist/conformance.d.ts",
      "import": "./dist/conformance.js"
    },
    "./styles.css": "./dist/styles.css"
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsup",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "peerDependencies": {
    "react": ">=18"
  },
  "devDependencies": {
    "@testing-library/dom": "^10.4.0",
    "@testing-library/react": "^16.1.0",
    "@types/react": "^19.0.0",
    "jsdom": "^26.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tsup": "^8.3.5",
    "typescript": "^5.7.3",
    "vitest": "^3.0.5"
  },
  "publishConfig": {
    "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
  }
}
```

Note: **no `dependencies`.** The core must stay transport-free. `./adapters/kernel` and its `@insignia/iios-kernel-client` dependency arrive in Plan 3. `./styles.css` is declared now but produced in Plan 2.

- [ ] **Step 2: Create tsconfig**

Create `packages/iios-messaging-ui/tsconfig.json`:

```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],
    "jsx": "react-jsx",
    "types": ["react"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
```

- [ ] **Step 3: Create the build config**

Create `packages/iios-messaging-ui/tsup.config.ts`:

```ts
import { defineConfig } from 'tsup';

export default defineConfig({
  // `conformance` is its own entry, never reachable from `index`. It imports vitest,
  // and bundling that into the main barrel would drag a test runner into every
  // consumer's production build.
  entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/conformance.ts'],
  format: ['esm'],
  dts: true,
  clean: true,
  external: ['react', 'react-dom', 'vitest'],
});
```

- [ ] **Step 4: Create the package's own vitest config**

Create `packages/iios-messaging-ui/vitest.config.ts`:

```ts
import { defineConfig } from 'vitest/config';

// This package owns its vitest config on purpose. The ROOT config includes only
// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run —
// a pure-UI package must not drag a database along, and its tests are .tsx.
export default defineConfig({
  test: {
    environment: 'jsdom',
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    globals: false,
  },
});
```

- [ ] **Step 5: Create a placeholder barrel**

Create `packages/iios-messaging-ui/src/index.ts`:

```ts
export {};
```

- [ ] **Step 6: Write the failing smoke test**

Create `packages/iios-messaging-ui/src/smoke.test.tsx`:

```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';

describe('test infrastructure', () => {
  it('renders React components in jsdom', () => {
    render(<div>messaging-ui</div>);
    expect(screen.getByText('messaging-ui')).toBeDefined();
  });
});
```

- [ ] **Step 7: Install dependencies**

Run from the repo root:

```bash
pnpm install
```

Expected: pnpm links the new workspace package; `@testing-library/react`, `jsdom`, `react-dom` resolve.

- [ ] **Step 8: Run the test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS, 1 test. If it fails with "document is not defined", `environment: 'jsdom'` did not take effect — check you created `vitest.config.ts` in the package dir, not the root.

- [ ] **Step 9: Verify typecheck and build**

Run: `pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build`
Expected: no type errors; `dist/index.js` + `dist/index.d.ts` produced.

- [ ] **Step 10: Commit**

```bash
git add packages/iios-messaging-ui
git commit -m "chore: scaffold @insignia/iios-messaging-ui with jsdom test setup"
```

---

### Task 2: Domain types

**Files:**
- Create: `packages/iios-messaging-ui/src/types.ts`
- Test: `packages/iios-messaging-ui/src/types.test.ts`

These are the SDK's own types — deliberately **not** re-exported from `iios-kernel-client`, because core must not depend on it. Adapters map kernel/CRM DTOs onto these. Shape is taken from the CRM's `UiConversation`/`UiMessage`/`UiPerson`, which are already proven.

- [ ] **Step 1: Write the failing test**

Create `packages/iios-messaging-ui/src/types.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import { isOwnMessage } from './types';
import type { Message } from './types';

const base: Message = {
  id: 'm1',
  actorId: 'actor_a',
  text: 'hello',
  at: '2026-07-17T10:00:00.000Z',
};

describe('isOwnMessage', () => {
  it('is true when the message actor matches the current actor', () => {
    expect(isOwnMessage(base, 'actor_a')).toBe(true);
  });

  it('is false when the actors differ', () => {
    expect(isOwnMessage(base, 'actor_b')).toBe(false);
  });

  it('is false when the current actor is unknown', () => {
    expect(isOwnMessage(base, null)).toBe(false);
  });

  it('is false when the message has no actor', () => {
    expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: FAIL — "Failed to resolve import ./types" or "isOwnMessage is not a function".

- [ ] **Step 3: Write the implementation**

Create `packages/iios-messaging-ui/src/types.ts`:

```ts
// Domain types for the messaging UI. Zero imports on purpose: this file must never
// reach for a transport package. Adapters map their own DTOs onto these.

export type Membership = 'dm' | 'group';

export interface Person {
  id: string;
  name: string;
  kind: 'staff' | 'customer';
}

export interface Conversation {
  threadId: string;
  title: string;
  subject: string | null;
  membership: Membership | null;
  participants: string[];
  unread: number;
  lastMessage?: string;
  lastAt?: string;
}

export interface Reaction {
  emoji: string;
  count: number;
  mine: boolean;
}

export interface Attachment {
  url: string;
  mime: string;
  name: string;
}

export interface Message {
  id: string;
  actorId: string | null;
  text: string;
  at: string;
  parentInteractionId?: string | null;
  reactions?: Reaction[];
  attachment?: Attachment;
  /** True only when the transport is optimistic-local and not yet acknowledged. */
  pending?: boolean;
}

export interface SendOpts {
  parentInteractionId?: string;
  attachment?: Attachment;
}

export type MessageEvent =
  | { kind: 'message'; message: Message }
  | { kind: 'typing'; userId: string }
  | { kind: 'receipt'; messageId: string; actorId: string }
  | { kind: 'reaction'; messageId: string; reactions: Reaction[] };

export type Unsubscribe = () => void;

/** Ownership is a pure function of explicit identity — never inferred from history.
 *  See the spec: inferring it is the bug this SDK exists partly to kill. */
export function isOwnMessage(message: Message, currentActorId: string | null): boolean {
  return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS, 5 tests (4 new + smoke).

- [ ] **Step 5: Commit**

```bash
git add packages/iios-messaging-ui/src/types.ts packages/iios-messaging-ui/src/types.test.ts
git commit -m "feat: add messaging-ui domain types"
```

---

### Task 3: The MessagingAdapter contract

**Files:**
- Create: `packages/iios-messaging-ui/src/adapter.ts`

An interface has no runtime behaviour to test directly; Task 4's conformance suite is its test. This task is type-only, so it is verified by `typecheck`.

- [ ] **Step 1: Write the interface**

Create `packages/iios-messaging-ui/src/adapter.ts`:

```ts
import type {
  Attachment,
  Conversation,
  Membership,
  Message,
  MessageEvent,
  SendOpts,
  Unsubscribe,
} from './types';

/**
 * The one seam of this SDK. Hosts implement this; the SDK renders it.
 *
 * Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
 * two implementations (live data-door + mock) — the minimum real evidence that a
 * seam is genuine rather than imagined.
 *
 * Optional methods degrade gracefully: the UI hides the reaction picker when
 * `react` is absent, and the attach button when `upload` is absent. That is how one
 * component set serves both a full CRM messenger and a stripped-down widget with no
 * `mode` prop.
 */
export interface MessagingAdapter {
  listConversations(): Promise<Conversation[]>;

  openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;

  history(threadId: string): Promise<Message[]>;

  send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;

  /** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
  subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;

  sendTyping(threadId: string): void;

  markRead(threadId: string, messageId: string): Promise<void>;

  /**
   * The current user's actor id, or null if not yet known.
   *
   * MUST NOT be inferred from message history. The CRM's bug was exactly that:
   * scanning for a sent message meant every message read as not-yours until you
   * had spoken. Adapters derive this from auth/session.
   */
  currentActorId(): string | null;

  /** Absent => the UI hides reactions entirely. */
  react?(threadId: string, messageId: string, emoji: string): Promise<void>;

  /** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
  upload?(file: File): Promise<Attachment>;

  /** Absent => treated as always connected (e.g. a pure-REST adapter). */
  isConnected?(): boolean;
}
```

- [ ] **Step 2: Verify it typechecks**

Run: `pnpm --filter @insignia/iios-messaging-ui typecheck`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add packages/iios-messaging-ui/src/adapter.ts
git commit -m "feat: define MessagingAdapter contract"
```

---

### Task 4: Adapter conformance suite

**Files:**
- Create: `packages/iios-messaging-ui/src/conformance.ts`

This ships in `src/` (not as a `.test.ts`) because Plan 3's kernel and CRM adapters import it to verify themselves. One definition of "correct adapter", three implementations checked against it.

- [ ] **Step 1: Write the suite**

Create `packages/iios-messaging-ui/src/conformance.ts`:

```ts
import { describe, it, expect } from 'vitest';
import type { MessagingAdapter } from './adapter';
import type { MessageEvent } from './types';

export interface ConformanceOptions {
  /** Build a fresh, isolated adapter per test. */
  makeAdapter: () => Promise<MessagingAdapter> | MessagingAdapter;
  /** A thread id that exists in the fixture. */
  seededThreadId: string;
  /** Participant ids openThread can legally be called with. */
  openWith: string[];
}

/**
 * The definition of a correct MessagingAdapter. Every adapter runs this.
 *
 * Imports vitest, so it ships from the './conformance' subpath ONLY and is never
 * reachable from the main barrel. Consumers supply their own vitest.
 *
 * Usage from another package:
 *   import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance';
 *   runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] });
 */
export function runAdapterConformance(opts: ConformanceOptions): void {
  const make = async () => await opts.makeAdapter();

  describe('MessagingAdapter conformance', () => {
    it('lists conversations', async () => {
      const a = await make();
      const list = await a.listConversations();
      expect(Array.isArray(list)).toBe(true);
    });

    it('returns history for a seeded thread', async () => {
      const a = await make();
      const msgs = await a.history(opts.seededThreadId);
      expect(Array.isArray(msgs)).toBe(true);
    });

    it('send resolves with a message carrying the sent text and a stable id', async () => {
      const a = await make();
      const m = await a.send(opts.seededThreadId, 'conformance hello');
      expect(m.text).toBe('conformance hello');
      expect(typeof m.id).toBe('string');
      expect(m.id.length).toBeGreaterThan(0);
    });

    it('a sent message is attributed to the current actor', async () => {
      const a = await make();
      const m = await a.send(opts.seededThreadId, 'whose is this');
      expect(m.actorId).toBe(a.currentActorId());
    });

    it('currentActorId is known BEFORE any message is sent', async () => {
      // The bug this SDK exists to kill: identity must come from auth, never be
      // inferred from history. A fresh adapter already knows who you are.
      const a = await make();
      expect(a.currentActorId()).not.toBeNull();
    });

    it('a sent message appears in history', async () => {
      const a = await make();
      await a.send(opts.seededThreadId, 'persist me');
      const msgs = await a.history(opts.seededThreadId);
      expect(msgs.some((m) => m.text === 'persist me')).toBe(true);
    });

    it('subscribe delivers a message event on send', async () => {
      const a = await make();
      const seen: MessageEvent[] = [];
      const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
      await a.send(opts.seededThreadId, 'live one');
      off();
      const msgs = seen.filter((e) => e.kind === 'message');
      expect(msgs.length).toBeGreaterThan(0);
    });

    it('unsubscribe stops delivery', async () => {
      const a = await make();
      const seen: MessageEvent[] = [];
      const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
      off();
      await a.send(opts.seededThreadId, 'should not be heard');
      expect(seen).toHaveLength(0);
    });

    it('unsubscribe is idempotent', async () => {
      const a = await make();
      const off = a.subscribe(opts.seededThreadId, () => {});
      off();
      expect(() => off()).not.toThrow();
    });

    it('openThread returns a thread id', async () => {
      const a = await make();
      const { threadId } = await a.openThread({ participantIds: opts.openWith });
      expect(typeof threadId).toBe('string');
      expect(threadId.length).toBeGreaterThan(0);
    });

    it('markRead resolves', async () => {
      const a = await make();
      const m = await a.send(opts.seededThreadId, 'read me');
      await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined();
    });

    it('sendTyping does not throw', async () => {
      const a = await make();
      expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow();
    });
  });
}
```

- [ ] **Step 2: Verify it typechecks**

Run: `pnpm --filter @insignia/iios-messaging-ui typecheck`
Expected: no errors. (The suite has no adapter to run against yet — that is Task 5.)

- [ ] **Step 3: Commit**

```bash
git add packages/iios-messaging-ui/src/conformance.ts
git commit -m "feat: add adapter conformance suite"
```

---

### Task 5: Mock adapter

**Files:**
- Create: `packages/iios-messaging-ui/src/adapters/mock.ts`
- Test: `packages/iios-messaging-ui/src/conformance.test.ts`

Ported from the CRM's `MOCK_STORE`, with one deliberate change: **the store is per-instance, not module-level.** The CRM's module-level `Map` leaks state between tests. Fixtures match the CRM's so demo mode looks identical after migration.

- [ ] **Step 1: Write the failing test**

Create `packages/iios-messaging-ui/src/conformance.test.ts`:

```ts
import { runAdapterConformance } from './conformance';
import { MockAdapter } from './adapters/mock';

runAdapterConformance({
  makeAdapter: () => new MockAdapter(),
  seededThreadId: 'th_mock_1',
  openWith: ['pp_sofia'],
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: FAIL — "Failed to resolve import ./adapters/mock".

- [ ] **Step 3: Write the implementation**

Create `packages/iios-messaging-ui/src/adapters/mock.ts`:

```ts
import type { MessagingAdapter } from '../adapter';
import type {
  Attachment,
  Conversation,
  Membership,
  Message,
  MessageEvent,
  Person,
  SendOpts,
  Unsubscribe,
} from '../types';

const ME = 'me';

export const MOCK_PEOPLE: Person[] = [
  { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
  { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
  { id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
  { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
  { id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
];

interface MockThread {
  threadId: string;
  membership: Membership;
  subject: string | null;
  participants: string[];
  messages: Message[];
}

const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));

/**
 * In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
 * used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
 * fully isolated.
 */
export class MockAdapter implements MessagingAdapter {
  private seq = 100;
  private threads = new Map<string, MockThread>();
  private listeners = new Map<string, Set<(e: MessageEvent) => void>>();

  constructor(private readonly now: () => string = () => new Date().toISOString()) {
    this.threads.set('th_mock_1', {
      threadId: 'th_mock_1',
      membership: 'dm',
      subject: null,
      participants: [ME, 'pp_sofia'],
      messages: [
        {
          id: 'm1',
          actorId: 'pp_sofia',
          text: 'Can you review the Henderson estimate?',
          at: this.now(),
          reactions: [],
        },
      ],
    });
    this.threads.set('th_mock_2', {
      threadId: 'th_mock_2',
      membership: 'group',
      subject: 'Storm response — East side',
      participants: [ME, 'pp_dan', 'pp_priya'],
      messages: [
        { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: this.now(), reactions: [] },
      ],
    });
  }

  currentActorId(): string {
    return ME;
  }

  async listConversations(): Promise<Conversation[]> {
    return [...this.threads.values()].map((t) => {
      const last = t.messages[t.messages.length - 1];
      const others = t.participants.filter((p) => p !== ME);
      return {
        threadId: t.threadId,
        title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation',
        subject: t.subject,
        membership: t.membership,
        participants: t.participants,
        unread: 0,
        ...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
      };
    });
  }

  async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
    const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
    const threadId = `th_mock_${this.seq++}`;
    this.threads.set(threadId, {
      threadId,
      membership,
      subject: p.subject ?? null,
      participants: [ME, ...p.participantIds],
      messages: [],
    });
    return { threadId };
  }

  async history(threadId: string): Promise<Message[]> {
    return this.threads.get(threadId)?.messages ?? [];
  }

  async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
    const t = this.threads.get(threadId);
    if (!t) throw new Error(`Unknown thread: ${threadId}`);
    const message: Message = {
      id: `m_${this.seq++}`,
      actorId: ME,
      text: content,
      at: this.now(),
      reactions: [],
      ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
      ...(opts?.attachment ? { attachment: opts.attachment } : {}),
    };
    t.messages = [...t.messages, message];
    this.emit(threadId, { kind: 'message', message });
    return message;
  }

  async react(threadId: string, messageId: string, emoji: string): Promise<void> {
    const t = this.threads.get(threadId);
    if (!t) return;
    let next: Message | undefined;
    t.messages = t.messages.map((m) => {
      if (m.id !== messageId) return m;
      const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
      const reactions = existing
        ? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
        : [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
      next = { ...m, reactions };
      return next;
    });
    if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
  }

  async upload(file: File): Promise<Attachment> {
    return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
  }

  subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
    if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
    this.listeners.get(threadId)!.add(cb);
    return () => {
      this.listeners.get(threadId)?.delete(cb);
    };
  }

  sendTyping(): void {
    // No-op: nobody is typing back in a mock.
  }

  async markRead(): Promise<void> {
    // No-op: the mock has no second party to report a read.
  }

  isConnected(): boolean {
    return true;
  }

  private emit(threadId: string, e: MessageEvent): void {
    this.listeners.get(threadId)?.forEach((cb) => cb(e));
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS — 12 conformance tests green.

- [ ] **Step 5: Commit**

```bash
git add packages/iios-messaging-ui/src/adapters/mock.ts packages/iios-messaging-ui/src/conformance.test.ts
git commit -m "feat: add MockAdapter passing the conformance suite"
```

---

### Task 6: MessagingProvider

**Files:**
- Create: `packages/iios-messaging-ui/src/provider.tsx`
- Test: `packages/iios-messaging-ui/src/provider.test.tsx`

- [ ] **Step 1: Write the failing test**

Create `packages/iios-messaging-ui/src/provider.test.tsx`:

```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MessagingProvider, useAdapter } from './provider';
import { MockAdapter } from './adapters/mock';

function ShowActor() {
  const adapter = useAdapter();
  return <span>{adapter.currentActorId()}</span>;
}

describe('MessagingProvider', () => {
  it('supplies the injected adapter to descendants', () => {
    render(
      <MessagingProvider adapter={new MockAdapter()}>
        <ShowActor />
      </MessagingProvider>,
    );
    expect(screen.getByText('me')).toBeDefined();
  });

  it('throws a helpful error when a hook is used outside the provider', () => {
    // React logs the error boundary trace; that noise is expected.
    expect(() => render(<ShowActor />)).toThrow(/useAdapter must be used within a <MessagingProvider>/);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: FAIL — "Failed to resolve import ./provider".

- [ ] **Step 3: Write the implementation**

Create `packages/iios-messaging-ui/src/provider.tsx`:

```tsx
import { createContext, useContext, type ReactNode } from 'react';
import type { MessagingAdapter } from './adapter';

const AdapterContext = createContext<MessagingAdapter | null>(null);

export function MessagingProvider({
  adapter,
  children,
}: {
  adapter: MessagingAdapter;
  children: ReactNode;
}) {
  return <AdapterContext.Provider value={adapter}>{children}</AdapterContext.Provider>;
}

/** Access the host-injected adapter. Throws outside a provider — a missing provider is
 *  a wiring bug, and failing loudly beats a confusing null-deref three layers down. */
export function useAdapter(): MessagingAdapter {
  const adapter = useContext(AdapterContext);
  if (!adapter) throw new Error('useAdapter must be used within a <MessagingProvider>');
  return adapter;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS, 2 new tests.

- [ ] **Step 5: Commit**

```bash
git add packages/iios-messaging-ui/src/provider.tsx packages/iios-messaging-ui/src/provider.test.tsx
git commit -m "feat: add MessagingProvider and useAdapter"
```

---

### Task 7: useConversations

**Files:**
- Create: `packages/iios-messaging-ui/src/hooks/use-conversations.ts`
- Test: `packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx`

- [ ] **Step 1: Write the failing test**

Create `packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx`:

```tsx
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useConversations } from './use-conversations';
import type { MessagingAdapter } from '../adapter';

const wrap = (adapter: MessagingAdapter) =>
  function Wrapper({ children }: { children: ReactNode }) {
    return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
  };

describe('useConversations', () => {
  it('starts loading, then resolves the adapter list', async () => {
    const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
    expect(result.current.loading).toBe(true);

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.conversations).toHaveLength(2);
    expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
    expect(result.current.error).toBeNull();
  });

  it('surfaces adapter failure as error state and never throws', async () => {
    const adapter = new MockAdapter();
    vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));

    const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.error).toBe('data door down');
    expect(result.current.conversations).toEqual([]);
  });

  it('refetch picks up newly opened threads', async () => {
    const adapter = new MockAdapter();
    const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.conversations).toHaveLength(2));

    await act(async () => {
      await adapter.openThread({ participantIds: ['pp_dan'] });
    });
    await act(async () => {
      result.current.refetch();
    });

    await waitFor(() => expect(result.current.conversations).toHaveLength(3));
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: FAIL — "Failed to resolve import ./use-conversations".

- [ ] **Step 3: Write the implementation**

Create `packages/iios-messaging-ui/src/hooks/use-conversations.ts`:

```ts
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Conversation } from '../types';

export interface ConversationsState {
  conversations: Conversation[];
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

export function useConversations(): ConversationsState {
  const adapter = useAdapter();
  const [conversations, setConversations] = useState<Conversation[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [nonce, setNonce] = useState(0);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    adapter
      .listConversations()
      .then((list) => {
        if (!alive) return;
        setConversations(list);
        setError(null);
      })
      .catch((e: unknown) => {
        if (!alive) return;
        setError(e instanceof Error ? e.message : String(e));
        setConversations([]);
      })
      .finally(() => {
        if (alive) setLoading(false);
      });
    return () => {
      alive = false;
    };
  }, [adapter, nonce]);

  const refetch = useCallback(() => setNonce((n) => n + 1), []);

  return { conversations, loading, error, refetch };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS, 3 new tests.

- [ ] **Step 5: Commit**

```bash
git add packages/iios-messaging-ui/src/hooks
git commit -m "feat: add useConversations hook"
```

---

### Task 8: useMessages

**Files:**
- Create: `packages/iios-messaging-ui/src/hooks/use-messages.ts`
- Test: `packages/iios-messaging-ui/src/hooks/use-messages.test.tsx`

The heart of the SDK. Three behaviours matter most: **ownership before you speak** (the CRM bug), **optimistic send with rollback**, and **event reconciliation by id** (no duplicates when the echo arrives).

- [ ] **Step 1: Write the failing test**

Create `packages/iios-messaging-ui/src/hooks/use-messages.test.tsx`:

```tsx
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useMessages } from './use-messages';
import type { MessagingAdapter } from '../adapter';

const wrap = (adapter: MessagingAdapter) =>
  function Wrapper({ children }: { children: ReactNode }) {
    return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
  };

describe('useMessages', () => {
  it('loads history for the thread', async () => {
    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.messages).toHaveLength(1);
    expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
  });

  // REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
  // before you had spoken in a thread EVERY message rendered as not-yours.
  it('marks ownership correctly before the user has sent anything', async () => {
    const adapter = new MockAdapter();
    await adapter.send('th_mock_1', 'an earlier message of mine');

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.messages).toHaveLength(2));

    // Never sent anything via the hook — ownership still resolves from currentActorId().
    expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
    expect(result.current.messages[1]!.mine).toBe(true); // from me
  });

  it('appends an optimistic message immediately on send', async () => {
    const adapter = new MockAdapter();
    let release!: () => void;
    vi.spyOn(adapter, 'send').mockImplementation(
      () => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
    );

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => { void result.current.send('hi'); });

    await waitFor(() => expect(result.current.messages).toHaveLength(2));
    expect(result.current.messages[1]!.pending).toBe(true);
    expect(result.current.messages[1]!.mine).toBe(true);

    await act(async () => { release(); });
    await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
  });

  it('rolls back the optimistic message and reports error when send fails', async () => {
    const adapter = new MockAdapter();
    vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    await act(async () => {
      await expect(result.current.send('doomed')).rejects.toThrow('offline');
    });

    expect(result.current.messages).toHaveLength(1);
    expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
    expect(result.current.error).toBe('offline');
  });

  it('does not duplicate a message when the transport echoes it back', async () => {
    const adapter = new MockAdapter();
    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    // MockAdapter.send emits a 'message' event AND resolves with the same message.
    await act(async () => { await result.current.send('echo once'); });

    expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
  });

  it('collects typing user ids from subscribe events', async () => {
    const adapter = new MockAdapter();
    let emit!: (userId: string) => void;
    vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
      emit = (userId) => cb({ kind: 'typing', userId });
      return () => {};
    });

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => emit('pp_sofia'));
    expect(result.current.typingUserIds).toEqual(['pp_sofia']);
  });

  it('unsubscribes on unmount', async () => {
    const adapter = new MockAdapter();
    const off = vi.fn();
    vi.spyOn(adapter, 'subscribe').mockReturnValue(off);

    const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));
    unmount();

    expect(off).toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: FAIL — "Failed to resolve import ./use-messages".

- [ ] **Step 3: Write the implementation**

Create `packages/iios-messaging-ui/src/hooks/use-messages.ts`:

```ts
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Message, SendOpts } from '../types';

const TYPING_TTL_MS = 3500;

export interface UiMessage extends Message {
  mine: boolean;
}

export interface MessagesState {
  messages: UiMessage[];
  loading: boolean;
  error: string | null;
  send: (content: string, opts?: SendOpts) => Promise<void>;
  react: (messageId: string, emoji: string) => Promise<void>;
  typingUserIds: string[];
  seenIds: Set<string>;
  sendTyping: () => void;
  canReact: boolean;
  canUpload: boolean;
}

let optimisticSeq = 0;

export function useMessages(threadId: string | null): MessagesState {
  const adapter = useAdapter();
  const [raw, setRaw] = useState<Message[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [typing, setTyping] = useState<Record<string, number>>({});
  const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
  const [, forceTick] = useState(0);

  const currentActorId = adapter.currentActorId();
  const actorRef = useRef(currentActorId);
  actorRef.current = currentActorId;

  // Load history, then subscribe. Reconciliation is by message id, so an echoed
  // send never duplicates the optimistic row.
  useEffect(() => {
    if (!threadId) {
      setRaw([]);
      setLoading(false);
      return;
    }
    let alive = true;
    setLoading(true);
    setRaw([]);
    setSeenIds(new Set());
    setTyping({});

    adapter
      .history(threadId)
      .then((h) => {
        if (alive) {
          setRaw(h);
          setError(null);
        }
      })
      .catch((e: unknown) => {
        if (alive) setError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (alive) setLoading(false);
      });

    const off = adapter.subscribe(threadId, (e) => {
      if (!alive) return;
      switch (e.kind) {
        case 'message':
          setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
          break;
        case 'typing':
          if (e.userId !== actorRef.current) {
            setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
          }
          break;
        case 'receipt':
          // Only the OTHER side reading my message counts as "seen".
          if (e.actorId !== actorRef.current) {
            setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
          }
          break;
        case 'reaction':
          setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
          break;
      }
    });

    return () => {
      alive = false;
      off();
    };
  }, [adapter, threadId]);

  const messages: UiMessage[] = useMemo(
    () => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
    [raw, currentActorId],
  );

  const send = useCallback(
    async (content: string, opts?: SendOpts) => {
      if (!threadId) return;
      const tempId = `optimistic_${optimisticSeq++}`;
      const optimistic: Message = {
        id: tempId,
        actorId: actorRef.current,
        text: content,
        at: new Date().toISOString(),
        pending: true,
        reactions: [],
        ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
        ...(opts?.attachment ? { attachment: opts.attachment } : {}),
      };
      setRaw((l) => [...l, optimistic]);

      try {
        const saved = await adapter.send(threadId, content, opts);
        setError(null);
        // Replace the optimistic row with the server's. If the subscribe echo already
        // added the real message, just drop the optimistic one.
        setRaw((l) => {
          const withoutTemp = l.filter((m) => m.id !== tempId);
          return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
        });
      } catch (e: unknown) {
        setRaw((l) => l.filter((m) => m.id !== tempId));
        setError(e instanceof Error ? e.message : String(e));
        throw e;
      }
    },
    [adapter, threadId],
  );

  const react = useCallback(
    async (messageId: string, emoji: string) => {
      if (!threadId || !adapter.react) return;
      await adapter.react(threadId, messageId, emoji);
    },
    [adapter, threadId],
  );

  const sendTyping = useCallback(() => {
    if (threadId) adapter.sendTyping(threadId);
  }, [adapter, threadId]);

  // Report my read of the newest message (drives the other side's "seen" tick).
  useEffect(() => {
    if (!threadId || raw.length === 0) return;
    const last = raw[raw.length - 1]!;
    if (last.pending) return;
    void adapter.markRead(threadId, last.id).catch(() => {});
  }, [adapter, threadId, raw]);

  const typingUserIds = useMemo(() => {
    const now = Date.now();
    return Object.entries(typing)
      .filter(([, exp]) => exp > now)
      .map(([u]) => u);
  }, [typing]);

  // Expire stale typing entries by re-evaluating after the TTL.
  useEffect(() => {
    if (typingUserIds.length === 0) return;
    const t = setTimeout(() => forceTick((n) => n + 1), TYPING_TTL_MS);
    return () => clearTimeout(t);
  }, [typingUserIds.length, typing]);

  // Only my messages that the other side has read.
  const seenMine = useMemo(() => {
    const out = new Set<string>();
    for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
    return out;
  }, [seenIds, messages]);

  return {
    messages,
    loading,
    error,
    send,
    react,
    typingUserIds,
    seenIds: seenMine,
    sendTyping,
    canReact: typeof adapter.react === 'function',
    canUpload: typeof adapter.upload === 'function',
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS, 7 new tests.

- [ ] **Step 5: Commit**

```bash
git add packages/iios-messaging-ui/src/hooks/use-messages.ts packages/iios-messaging-ui/src/hooks/use-messages.test.tsx
git commit -m "feat: add useMessages hook with explicit ownership and optimistic send"
```

---

### Task 9: Public barrel + transport-freedom enforcement

**Files:**
- Modify: `packages/iios-messaging-ui/src/index.ts`
- Create: `packages/iios-messaging-ui/src/boundary.test.ts`
- Modify: `scripts/check-import-boundary.mjs:22` (add the new package to `ALLOWED`)

The repo's boundary script is **package-level**, so it cannot express "core must not import kernel-client, but `adapters/kernel` may". That distinction is this SDK's core premise, so it gets its own test.

- [ ] **Step 1: Write the failing test**

Create `packages/iios-messaging-ui/src/boundary.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

// This package is ESM ("type": "module") — __dirname does not exist here.
const SRC = fileURLToPath(new URL('.', import.meta.url));
const ADAPTERS = join(SRC, 'adapters');

function tsFilesIn(dir: string): string[] {
  const out: string[] = [];
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry);
    if (statSync(full).isDirectory()) out.push(...tsFilesIn(full));
    else if (/\.tsx?$/.test(full)) out.push(full);
  }
  return out;
}

// The whole premise of this package: core renders, adapters transport. If core ever
// imports a transport, an app on a different backend pays for socket code it cannot
// use — which is exactly how iios-message-web welded itself to MessageSocket.
describe('transport boundary', () => {
  const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f));

  it('has core files to check', () => {
    expect(coreFiles.length).toBeGreaterThan(0);
  });

  it('core never imports a transport package', () => {
    const offenders: string[] = [];
    for (const file of coreFiles) {
      const content = readFileSync(file, 'utf8');
      if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) {
        offenders.push(file.replace(SRC, 'src'));
      }
    }
    expect(offenders).toEqual([]);
  });
});
```

- [ ] **Step 2: Run test to verify it passes**

Run: `pnpm --filter @insignia/iios-messaging-ui test`
Expected: PASS. (This test guards a property that already holds — it fails loudly in Plan 3 if someone imports the kernel from core.)

- [ ] **Step 3: Write the public barrel**

Replace `packages/iios-messaging-ui/src/index.ts`:

```ts
// Public API. Components land in Plan 2; adapters/kernel in Plan 3.
//
// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and
// ships from the './conformance' subpath so consumers never pull a test runner into
// their production bundle.
export { MessagingProvider, useAdapter } from './provider';
export { useConversations } from './hooks/use-conversations';
export { useMessages } from './hooks/use-messages';
export { isOwnMessage } from './types';

export type { MessagingAdapter } from './adapter';
export type { ConversationsState } from './hooks/use-conversations';
export type { MessagesState, UiMessage } from './hooks/use-messages';
export type {
  Attachment,
  Conversation,
  Membership,
  Message,
  MessageEvent,
  Person,
  Reaction,
  SendOpts,
  Unsubscribe,
} from './types';
```

- [ ] **Step 4: Register the package in the repo's import-boundary law**

In `scripts/check-import-boundary.mjs`, add this entry to the `ALLOWED` map (after the `'@insignia/iios-message-web'` line):

```js
  // Core is transport-free; only ./adapters/kernel may import the kernel client.
  // This map is package-level and cannot express that subpath rule — the finer
  // constraint is enforced by packages/iios-messaging-ui/src/boundary.test.ts.
  '@insignia/iios-messaging-ui': ['@insignia/iios-kernel-client'],
```

- [ ] **Step 5: Verify the boundary law still passes**

Run: `pnpm boundary`
Expected: `✓ import-boundary: OK`

- [ ] **Step 6: Verify the full package**

Run: `pnpm --filter @insignia/iios-messaging-ui test && pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build`
Expected: all tests PASS; no type errors; `dist/index.js`, `dist/index.d.ts`, `dist/adapters/mock.js`, `dist/conformance.js` produced.

- [ ] **Step 6b: Verify the main entry does not drag in vitest**

Run: `grep -c vitest packages/iios-messaging-ui/dist/index.js || echo "clean"`
Expected: `clean` (grep finds nothing and exits 1). If this prints a number, `conformance.ts` leaked into the main barrel — remove the re-export from `src/index.ts`.

Run: `grep -rE "iios-kernel-client|socket\.io" packages/iios-messaging-ui/dist/index.js || echo "transport-free"`
Expected: `transport-free`.

- [ ] **Step 7: Verify the root test suite is unaffected**

Run: `pnpm test`
Expected: the root suite passes exactly as before. Its `include` matches only `.ts`, so this package's `.tsx` tests are not picked up there — intentional, since root provisions Postgres. The package's tests run via its own `test` script.

- [ ] **Step 8: Commit**

```bash
git add packages/iios-messaging-ui/src/index.ts packages/iios-messaging-ui/src/boundary.test.ts scripts/check-import-boundary.mjs
git commit -m "feat: export messaging-ui public API and enforce transport boundary"
```

---

## Done criteria

- `pnpm --filter @insignia/iios-messaging-ui test` — all green (33 tests as shipped).
- `pnpm boundary` — OK.
- `pnpm test` at root — unchanged.
- The package builds to ESM with types and has **zero runtime dependencies**.
- A host can already drive messaging headlessly: `MessagingProvider` + `MockAdapter` + hooks.

## Post-implementation corrections (2026-07-18)

Executed via subagent-driven development on branch `feat/inbox-owner-interest`. Code review during Tasks 5 and 8 caught defects in the code blocks **above** — the shipped code diverges from the plan text in these ways, and Plan 3 should treat the committed source (not the Task 8 listing above) as authoritative:

- **`useMessages` typing expiry** — the plan used `forceTick((n) => n + 1)` to expire stale typing entries. That is a no-op: the `typingUserIds` memo is keyed on `[typing]`, so an unrelated state bump returns the cached array and the indicator sticks forever. Shipped code uses `setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS)` (a new `typing` reference forces the memo to recompute with a fresh `now`). `forceTick` state removed entirely.
- **`useMessages` history race** — the plan's `.then((h) => setRaw(h))` clobbers live messages that arrive via `subscribe` while `history()` is still in flight (silent data loss on any async transport; the sync MockAdapter can't show it). Shipped code merges by id: `setRaw((live) => { const histIds = new Set(h.map(m => m.id)); const extras = live.filter(m => !histIds.has(m.id)); return extras.length ? [...h, ...extras] : h; })`. Guarded by the test "keeps a live message that arrives before history resolves" (verified to fail if the merge is reverted).
- **`useMessages` markRead** — re-keyed from `[adapter, threadId, raw]` (fired on every array churn, incl. reactions) to a derived `lastReadableId` (newest non-pending id), so it fires only when the read frontier actually moves.
- **`useMessages` error reset** — `setError(null)` added to the thread-switch reset block (plan left a stale error banner across thread switches).
- **`MockAdapter`** — `history()` and `listConversations().participants` now return copies (plan leaked the live internal arrays); constructor calls `now()` once for both seed messages.
- **`boundary.test.ts`** — carries a `// @vitest-environment node` directive on line 1. jsdom overrides the global `URL` constructor, which breaks `fileURLToPath(new URL('.', import.meta.url))`; a filesystem test must run in node.

**Known limitation deferred to Plan 3 (issue I2):** `useMessages` reads `adapter.currentActorId()` per-render but nothing makes it reactive. If a real adapter's identity resolves from `null` to a value with no intervening React state change, ownership stays wrong until an incidental re-render — and an optimistic message stamped while identity is `null` renders as not-mine. The MockAdapter never has null identity, so this does not manifest in the foundation. Plan 3 must decide how the real adapter exposes identity reactively (e.g. an `onActorIdChange` subscription on the interface, or the host re-rendering the provider when auth resolves) rather than relying on incidental re-renders.

## What Plan 2 adds

Components (`ConversationList`, `ThreadView`, `Composer`, `MessageBubble`, `Messenger`), the SDK's own internal primitives (the CRM's `Avatar`/`Btn`/`Icon` cannot be imported — they live in `lynkeduppro-crm/src/components/dashboard/ui.tsx`), `styles.css` with the `--msg-*` token contract, and the `classNames` slot map.

## What Plan 3 adds

`./adapters/kernel` (wrapping `iios-kernel-client`, running the conformance suite), `CrmMessagingAdapter` in the CRM repo (data door + socket + 4s poll fallback, also running conformance), attachments end-to-end, and the CRM migration that deletes `messenger.tsx`.

Both adapters verify themselves with `import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'`. Plan 3 must add `@insignia/iios-kernel-client` to this package's `dependencies` and to the `tsup` `entry` + `external` lists — and `src/boundary.test.ts` will fail if that import reaches core rather than staying inside `src/adapters/`.

**Strengthen the safety nets before writing the kernel adapter** (from the final foundation review — the current versions are adequate for the mock but too weak to catch a broken real adapter):

- **Expand `runAdapterConformance`.** Today it only exercises the happy `message` path. It does NOT test the optional methods (`react`/`upload`/`isConnected`), **subscribe thread-isolation** (an adapter that leaks every thread's events to every subscriber would pass yet break the UI), or the `receipt`/`reaction`/`typing` event kinds. Since conformance IS the safety net for the real kernel adapter, add these cases first.
- **Convert `boundary.test.ts` from a denylist to an allowlist.** It currently greps core for three transport strings (`iios-kernel-client|socket.io|@abe-kap/appshell-sdk`); a core file importing a *different* transport (`ws`, `pusher`, raw WebSocket) would slip through, and the root `pnpm boundary` allowlist only governs at package granularity. Once `adapters/kernel` exists, assert core imports only `react` + relative paths.
- **Also address the deferred I2 (reactive identity)** noted under Post-implementation corrections above, and resolve the two Plan-2 notes: emit `dist/styles.css` so the exports entry resolves, and decide whether `react()` failures should populate `error` (today only `send()` does).

## CI note for whoever wires it

Root `pnpm test` does not run this package's tests. CI must also run:

```bash
pnpm --filter @insignia/iios-messaging-ui test
```

The alternative — folding `.tsx` + jsdom into the root config — would make every UI test wait on Postgres provisioning. Not worth it.
