# IIOS — API & SDK Guide (for QA and Developers)

> Everything you need to run, test, and build against the IIOS service and SDKs. One NestJS API (`iios-service`) + a set of browser SDKs. This doc is generated from the current code — endpoints, request/response shapes, SDK methods, auth, and env are all as-built.

---

## 1. Quick facts

| Thing | Value |
|---|---|
| Base URL (dev) | `http://localhost:3200` (env `PORT`, default `3200`) |
| Auth | `Authorization: Bearer <JWT>` — HS256, per-app secret |
| Content type | `application/json` |
| Validation | Global — **unknown body fields are rejected (400)**; types are coerced |
| CORS | Enabled (`origin: true`) |
| Realtime | Socket.IO namespace `/message` |
| DB | PostgreSQL (dev: `localhost:5434`, db `iios`) |

**Everything is scoped to a tenant.** A caller can only see/act on data in its own scope (`orgId` + `appId` + optional `tenantId`, derived from the token). Cross-tenant access returns **403**.

---

## 2. Getting started (run it locally)

```bash
# 1. Postgres (docker-compose in the repo root brings up 'iios-db' on :5434)
docker compose up -d

# 2. Install + generate + migrate
pnpm install
pnpm -F @insignia/iios-service prisma:generate
pnpm -F @insignia/iios-service prisma:migrate

# 3. Run the API (dev tokens ON so you can mint test tokens)
cd packages/iios-service && IIOS_DEV_TOKENS=1 pnpm start
# → iios-service listening on :3200
```

Health check: `GET http://localhost:3200/health` → `{ "status": "ok", "db": true, "egressProviders": { ... } }`

---

## 3. Auth model (how to get a token)

Tokens are per-app JWTs signed with a secret from the `APP_SECRETS` env map (`{"portal-demo":"dev-secret"}` by default). The token carries `sub` (userId), `appId`, `orgId`, optional `tenantId`.

**Get a dev token** (only when `IIOS_DEV_TOKENS=1`):

```bash
curl -s -X POST http://localhost:3200/v1/dev/token \
  -H 'content-type: application/json' \
  -d '{"appId":"portal-demo","userId":"alice","orgId":"org_A"}'
# → { "token": "eyJhbGc..." }
```

Use it on every call: `-H "authorization: Bearer <token>"`.

- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
- Token TTL: 2h.

**Real IdP tokens (production path).** Beyond the dev HS256 tokens, `SessionVerifier` also verifies **real OIDC access tokens** (ES256) against a trusted issuer's public **JWKS** — set `SUPABASE_URL` (single issuer) or `AUTH_ISSUERS` (a registry mapping many issuers → app scopes). The verifier routes a token by its `iss` claim, so two projects/IdPs map to two isolated `appId` scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.

### Error responses (what QA will see)
| Status | When |
|---|---|
| `400` | Missing `Authorization` header, unknown `appId`, or invalid/unknown body fields |
| `401` | Invalid/expired token, or bad webhook signature |
| `403` | **Cross-tenant access** (resource not in your scope) |
| `404` | Resource id not found (within your scope) |
| `201` | Created (POST) · `200` others |

---

## 4. Conventions

- **Idempotency** — native message send accepts an `idempotency-key` header; adapter send takes `idempotencyKey` in the body; AI/route/meeting derive their own keys. Re-sending the same key returns the same record (no duplicate).
- **Scopes/tenancy** — inferred from the token; you never pass `scopeId` in requests (except the raw `/v1/interactions/ingest` and adapter ingest which carry a full scope object).
- **Fail-closed** — reads/writes/sends pass through policy (OPA) + consent (CMP) gates; a denial blocks the action rather than silently allowing it.
- **Preview / propose first** — routing simulates before sending; AI proposes (never auto-decides); meetings gate recording on consent.

---

## 5. REST API reference

Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token required** unless noted.

### 5.1 Health & Dev
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET | `/health` | none | — | `{status, db, egressProviders}` |
| POST | `/v1/dev/token` | none* | `{appId, userId, name?, orgId?}` | `{token}` |
| POST | `/v1/dev/webhook/:channelType` | none* | `{from?, text?}` | raw event (simulated signed inbound) |

*Dev endpoints require `IIOS_DEV_TOKENS=1`; never enabled in prod.

### 5.2 Interactions (kernel ingest)
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/v1/interactions/ingest` | `IngestRequestDto` | `{interactionId, ...}` |

`IngestRequestDto`: `{ scope:{orgId,appId,buId?,tenantId?,workspaceId?,projectId?,userScopeId?}, channel:{type,externalChannelId?,capabilityContract?}, source:{handleKind,externalId,displayName?}, kind?, thread?:{externalThreadId?,subject?}, parts:[{kind,bodyText?,contentRef?,mimeType?}], occurredAt (ISO8601), providerEventId?, metadata? }`. Send `providerEventId` twice → deduped.

### 5.3 Threads & Messaging (native chat)
| Method | Path | Body / Headers | Returns |
|---|---|---|---|
| GET | `/v1/threads` | — | `ThreadSummary[]` — your threads (members, unread, last message, `membership`) |
| POST | `/v1/threads` | `{membership?, creatorRole?, subject?}` | `{threadId, status, history}` (201) — `membership`/`creatorRole`/`subject` are **opaque app attributes** the kernel stores but never interprets |
| POST | `/v1/threads/:id/participants` | `{userId, role?}` | `{threadId, participantCount}` (201) — **governed** (DM cap / group-admin via OPA) |
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
| POST | `/v1/threads/:id/messages` | `{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?}` + `idempotency-key?` header | `Message` (201) |
| GET | `/v1/threads/my-annotations` | `?type=save` | `{message, threadId, threadSubject}[]` — messages you annotated (e.g. saved) |

A **`Message`** carries `{id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}`. `senderId` is the sender's stable externalId (email/username) — the reliable "is this mine?" check. `attachment` = `{contentRef, mimeType, sizeBytes, kind}` (§5.10). `annotations` = `[{type, value, users[]}]` — the reaction/pin/save aggregate.

**Realtime (Socket.IO, namespace `/message`).** Token verified on connect (`auth.token`), then emit client→server:
- `open_thread` `{threadId?, membership?, creatorRole?, subject?}` — opens, or **creates** when no id; a **governed join** for membership threads. Returns `{threadId, status, history}` or `{error}` (acked, never a throw — clients don't hang).
- `send_message` `{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}` — `mentions` is an **opaque userId notify-list** (the app parses `@`; the kernel never does).
- `add_participant` `{threadId, userId, role?}` · `read` `{threadId, interactionId}` · `delivered` `{…}` · `typing` `{threadId}`.
- `annotate` `{threadId, interactionId, type, value}` — toggle a **generic annotation** (chat app uses `type: reaction|pin|save`; `value` = emoji, or empty).

Server → thread room: `message` (a Message), `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`, `typing` `{threadId, userId}`, `annotation` `{interactionId, type, value, op: add|remove, users[], userId}`.

**Governance & primitives (all fail-closed via OPA).** Membership (`iios.thread.participant.add`, `iios.thread.join`), annotating (`iios.interaction.annotate` — participant-only), and send all gate on policy. **Reactions/pins/saves are one generic primitive** — an *interaction annotation* the kernel stores + aggregates as opaque `(type, value)` but never interprets (the same primitive backs pins/saves/flags/tags). **Mentions → Inbox:** `mentions[]` flows into the message event; the inbox projector fans out a `MENTION` inbox item to each mentioned participant (never the sender); reading the thread resolves it.

### 5.4 Inbox (work queue)
| Method | Path | Query / Body | Returns |
|---|---|---|---|
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |

Item **kinds** include `NEEDS_REPLY` (unreplied thread activity, one per owner+thread) and `MENTION` (someone @-mentioned you; `priority: HIGH`, one per source message). Reading the thread resolves both to `DONE`.

### 5.5 Support (tickets, escalation, callbacks, queues, agents)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | `/v1/support/tickets` | `{subject, priority?, threadId?}` | `Ticket` |
| POST | `/v1/support/escalate` | `{threadId, subject?}` | `Ticket` (opens customer↔agent thread) |
| GET | `/v1/support/tickets` | `?scope=mine|assigned` | `Ticket[]` |
| PATCH | `/v1/support/tickets/:id` | `{state, reason?}` | `Ticket` |
| POST | `/v1/support/callbacks` | `{preferChannel, preferredTime?, notes?, ticketId?}` | `CallbackRequest` |
| POST | `/v1/support/queues` | `{name}` | `{id}` |
| POST | `/v1/support/queues/:id/members` | — | membership |
| POST | `/v1/support/agents/me/join` | — | joins default queue (go online) |
| PATCH | `/v1/support/members/me/availability` | `{state}` | membership |

`priority ∈ {LOW, NORMAL, HIGH, URGENT}`; ticket `state ∈ {NEW, OPEN, PENDING, RESOLVED, CLOSED}` (transitions validated).

### 5.6 Adapters (channels — inbound/outbound)
| Method | Path | Auth | Body / Headers | Returns |
|---|---|---|---|---|
| POST | `/v1/adapters/:channelType/webhook` | signature | raw provider body + `x-iios-signature` (HMAC) | `202` (async normalize) |
| POST | `/v1/adapters/:channelType/send` | Bearer | `{target, payload?, idempotencyKey?}` | `OutboundCommand` |
| GET | `/v1/adapters/inbound` | Bearer | — | raw inbound events (+ `interactionId` once normalized) |
| GET | `/v1/adapters/outbound` | Bearer | — | outbound commands (+ delivery attempts) |

`channelType ∈ {WEBHOOK, EMAIL, WHATSAPP, PORTAL}`. Outbound goes through the **Capability Broker** (policy + consent + provider); a blocked send → command status `BLOCKED`, a tenant over quota → `RATE_LIMITED`.

### 5.7 Routing / rules (community)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | `/v1/routes/bindings` | `CreateBindingDto` | `RouteBinding` |
| GET | `/v1/routes/bindings` | — | `RouteBinding[]` (your scope) |
| POST | `/v1/routes/simulate` | `{interactionId, originChannelType, originRef?}` | `{simulationId, decisions[]}` (no send) |
| GET | `/v1/routes/decisions` | `?state=ALLOW|DENY|REVIEW|SUPPRESS|SIMULATED` | `RouteDecision[]` (your scope) |
| POST | `/v1/routes/decisions/:id/approve` | — | `RouteDecision` (executes to sandbox) |
| POST | `/v1/routes/decisions/:id/deny` | — | `RouteDecision` |
| POST | `/v1/routes/bindings/:id/flush-digest` | — | `{forwarded}` |

`CreateBindingDto`: `{originChannelType, originRef?, destinationChannelType, destinationRef?, restrictionProfile?, mode?, outputFormat?, frequency?, includeAttachments?, requiresReview?, enabled?}`. `mode ∈ {MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY}`; `outputFormat ∈ {FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT}`. Restricted destinations are **deny-by-default** for flagged content.

### 5.8 AI (proposal layer)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | `/v1/ai/jobs` | `{interactionId, jobType}` | `{job, artifact}` |
| GET | `/v1/ai/artifacts` | `?interactionId=&status=` | `AiArtifact[]` (your scope) |
| GET | `/v1/ai/artifacts/:id` | — | `AiArtifact` (claims + evidence + modelRun) |
| POST | `/v1/ai/artifacts/:id/accept` | — | `AiArtifact` (ACCEPTED) |
| POST | `/v1/ai/artifacts/:id/reject` | — | `AiArtifact` (REJECTED) |

`jobType ∈ {CLASSIFY, SUMMARIZE, EXTRACT}`. Artifacts carry `confidence`, `evidence` (citations), and `abstentionReason` when the model declines. **AI proposes; a human accepts.**

### 5.9 Calendar & Meetings
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | `/v1/calendar/meetings` | `ScheduleMeetingDto` | `Meeting` (SCHEDULED) |
| POST | `/v1/calendar/requests` | `RequestMeetingDto` + `?fromCallback=&fromClaim=` | meeting request |
| GET | `/v1/calendar/meetings` | — | `Meeting[]` (your scope) |
| GET | `/v1/calendar/meetings/:id` | — | `Meeting` (participants, transcripts, summaries, action items) |
| POST | `/v1/calendar/meetings/:id/consent` | `{actorRefId, status}` | participant |
| POST | `/v1/calendar/meetings/:id/transcript` | — | transcript (`READY` or `BLOCKED`) |
| POST | `/v1/calendar/meetings/:id/summarize` | — | `Meeting` (summary + action items) |
| GET | `/v1/calendar/meetings/:id/action-items` | — | `MeetingActionItem[]` |
| POST | `/v1/calendar/providers` | — | provider account (simulated) |
| POST | `/v1/calendar/providers/:id/sync` | — | `{pulled, cursor}` |

`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**

### 5.10 Media (attachments)

The DB stores a **reference**; the bytes live behind a **storage port** (dev = local disk `MEDIA_DIR`; prod = swap to S3/R2/Supabase). Bytes go **client ↔ storage directly** via short-lived signed URLs — they never pass through the kernel.

| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| POST | `/v1/media/presign-upload` | Bearer | `{mime, sizeBytes}` | `{objectKey, uploadUrl}` — **OPA-gated** (size/type) |
| PUT | `/v1/media/upload/:token` | token in URL | raw bytes | `{objectKey, sizeBytes, checksumSha256}` |
| POST | `/v1/media/presign-download` | Bearer | `{contentRef, mime?}` | `{url}` — **tenant-fenced**, signed 1h |
| GET | `/v1/media/blob/:token` | token in URL | — | streams the bytes with their `Content-Type` |

**Attaching to a message:** `send`/`send_message` accept `{contentRef, mimeType, sizeBytes, checksumSha256?}`. The stored `Message` then carries `attachment: { contentRef, mimeType, sizeBytes, kind }` where `kind ∈ {image, video, audio, file}` (a friendly view of the generic `MEDIA_REF`/`VOICE_REF`/`FILE_REF` part the kernel writes).

**Governance (fail-closed, built in):**
- **Upload policy** `iios.media.upload` — **≤ 25 MB** and an allowlist (`image/*`, `video/*`, `audio/*`, `application/pdf`, common Office/text/zip). A violation → **403** *before* any bytes are sent.
- **Signed tokens** (HS256, `MEDIA_SECRET`) — upload URL lives **5 min**, download URL **1 h**; a tampered/expired token → 403.
- **Tenant fence** — object keys are prefixed with the caller's `scopeId`; a download for an object outside your scope → 403.

**The flow (with governance):**

```mermaid
sequenceDiagram
  autonumber
  participant C as Client (SDK uploadMedia)
  participant API as IIOS Media API
  participant OPA as OPA policy
  participant ST as StoragePort (disk → S3)
  participant K as Message kernel

  C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
  API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
  alt denied
    OPA-->>C: 403 (too large / type not allowed)
  else allowed
    API-->>C: { objectKey, uploadUrl }  (signed, 5-min)
    C->>ST: PUT bytes → uploadUrl  (direct, not via kernel)
    ST-->>C: { sizeBytes, checksumSha256 }
    C->>K: send_message { contentRef, mime, size }
    K-->>C: Message { attachment }
    C->>API: POST /v1/media/presign-download { contentRef }
    API->>API: tenant-fence (objectKey in my scope?)
    API-->>C: { url }  (signed, 1-hour)
    C->>ST: GET url → bytes (stream, inline render / download)
  end
```

---

### 5.11 Notifications (push, presence-gated)

The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable
**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`.

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe |
| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription |
| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` |
| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller |

**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message`
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar
joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`.

**The three gates (fail-closed, in the notification policy — not the kernel):**
1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you.
2. **Presence** — skip if you're currently focused on that thread (you saw it live).
3. **Mute** — skip if you muted the thread.
A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque
`membership` thread attribute here in the *notification policy*; the kernel never branches on it.

```mermaid
flowchart TB
  SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
  PROJ --> LOOP{{"for each recipient (never the sender)"}}
  LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
  G1 -->|no| X1["skip"]
  G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
  G2 -->|yes| X2["skip — seen live"]
  G2 -->|no| G3{"MUTE: thread muted?"}
  G3 -->|yes| X3["skip"]
  G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
  PORT --> WP["Web Push (VAPID)"]
  WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
  WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
```

**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):**
`registerPush()` (permission → register service worker → `PushManager.subscribe` with the
VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`.
A service worker renders the OS notification on `push` and deep-links to the thread on click.

---

## 6. SDK reference

Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages.

### 6.1 Low-level client — `@insignia/iios-kernel-client`
```ts
import { RestClient } from '@insignia/iios-kernel-client';
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
```
Methods (all return typed promises):
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
- **AI:** `runAiJob({interactionId, jobType})`, `listArtifacts({interactionId?, status?})`, `getArtifact(id)`, `acceptArtifact(id)`, `rejectArtifact(id)`
- **Calendar:** `scheduleMeeting(input)`, `requestMeeting(input, {fromCallback?, fromClaim?})`, `listMeetings()`, `getMeeting(id)`, `setConsent(meetingId, actorRefId, status)`, `generateTranscript(meetingId)`, `summarizeMeeting(meetingId)`, `listActionItems(meetingId)`, `connectCalendarProvider()`, `syncCalendarProvider(id)`
- **Ingest:** `ingest(body, idempotencyKey)`
- Realtime: `MessageSocket` (Socket.IO facade for `/message`).

### 6.2 React hook packages (front-end teams)
Each package exports a `Provider` (wrap your app with `serviceUrl` + `token`) plus hooks:

| Package | Provider | Hooks |
|---|---|---|
| `@insignia/iios-message-web` | `MessageProvider` | `useThread`, `useMessages` |
| `@insignia/iios-inbox-web` | `InboxProvider` | `useInbox` |
| `@insignia/iios-support-web` | `SupportProvider` | `useEscalate`, `useTickets`, `useAssignedTickets`, `useCallbackRequest`, `useAvailability`, `useGoOnline` (+ re-exports `useThread`/`useMessages`) |
| `@insignia/iios-community-web` | `CommunityProvider` | `useBindings`, `useRoutePreview`, `useRouteDecisions` |
| `@insignia/iios-ai-web` | `AiProvider` | `useRunJob`, `useAiProposals`, `useArtifact` |
| `@insignia/iios-meeting-web` | `MeetingProvider` | `useMeetings`, `useMeeting`, `useSchedule`, `useConsent`, `useSummarize`, `useActionItems` |

```tsx
// Example: an AI-assist panel
import { AiProvider, useRunJob, useAiProposals } from '@insignia/iios-ai-web';

<AiProvider serviceUrl={SERVICE} token={token}>
  <Panel interactionId={id} />
</AiProvider>;

function Panel({ interactionId }: { interactionId: string }) {
  const run = useRunJob();
  const { artifacts, accept, reject } = useAiProposals({ interactionId, pollMs: 2500 });
  return <>
    <button onClick={() => run(interactionId, 'SUMMARIZE')}>Summarize</button>
    {artifacts.map(a => <ProposalCard key={a.id} a={a} onAccept={() => accept(a.id)} onReject={() => reject(a.id)} />)}
  </>;
}
```

---

## 7. QA quickstart (curl walkthrough)

```bash
BASE=http://localhost:3200
TOKEN=$(curl -s -X POST $BASE/v1/dev/token -H 'content-type: application/json' \
  -d '{"appId":"portal-demo","userId":"alice","orgId":"org_A"}' | jq -r .token)
AUTH="authorization: Bearer $TOKEN"

# schedule a meeting
MID=$(curl -s -X POST $BASE/v1/calendar/meetings -H "$AUTH" -H 'content-type: application/json' \
  -d '{"meetingType":"INTERNAL","title":"QA test","startAt":"2026-07-02T10:00:00.000Z"}' | jq -r .id)

# transcript is BLOCKED (no consent yet)
curl -s -X POST $BASE/v1/calendar/meetings/$MID/transcript -H "$AUTH" | jq .status   # "BLOCKED"

# tenant isolation: a second tenant cannot read it → 403
TOKEN_B=$(curl -s -X POST $BASE/v1/dev/token -H 'content-type: application/json' \
  -d '{"appId":"portal-demo","userId":"bob","orgId":"org_B"}' | jq -r .token)
curl -s -o /dev/null -w "%{http_code}\n" $BASE/v1/calendar/meetings/$MID \
  -H "authorization: Bearer $TOKEN_B"   # 403
```

### Ready-made end-to-end tests (smoke scripts)
Run against a live service (from `packages/iios-service`, `node scripts/<name>`):

| Script | Proves |
|---|---|
| `smoke-realtime.mjs` | native chat + realtime + unread (P2) |
| `smoke-inbox.mjs` | needs-reply queue + auto-resolve (P3) |
| `smoke-support.mjs` (+ `seed-support.mjs`) | ticket assign + escalate + live reply (P4) |
| `smoke-adapter.mjs` | signed webhook ingest + sandbox send (P5) |
| `smoke-route.mjs` | preview-first routing, deny-by-default, approve→send (P6) |
| `smoke-ai.mjs` | AI classify/summarize/extract, abstain, KG-05 (P7) |
| `smoke-calendar.mjs` | consent-gated transcript, summary, action items (P8) |
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |

Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.

---

## 8. Tenancy & security notes for QA

- **Cross-tenant** — any by-id read/mutate of another tenant's resource → **403**; lists only ever return your own scope's data. Test with two `orgId`s.
- **Consent** — a meeting transcript/summary is `BLOCKED` unless every attendee consented; an outbound send to an opted-out recipient is `BLOCKED` (consent gate).
- **Per-tenant quota** — one tenant flooding outbound gets `RATE_LIMITED` without affecting others.
- **AI safety** — AI outputs are proposals (`PROPOSED`) with confidence/evidence; they never auto-send or auto-approve; low-confidence → abstains.
- **Idempotency** — replaying a webhook/message/job with the same key yields one record.

---

## 9. Environment variables

| Var | Default | Purpose |
|---|---|---|
| `PORT` | `3200` | HTTP port |
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app HS256 JWT secrets (`{appId: secret}`) |
| `SUPABASE_URL` / `AUTH_ISSUERS` | — | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. `AUTH_ISSUERS` is a JSON array `[{url, appId, orgId?}]` mapping issuers → app scopes; `SUPABASE_URL` is the single-issuer shorthand |
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` |
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
| `IIOS_TENANT_OUTBOUND_LIMIT` / `_WINDOW_MS` | `10000` / `60000` | per-tenant egress quota |
| `IIOS_AI_BUDGET_UNITS` | `1000000` | per-tenant AI cost budget |
| `IIOS_CELL_ID` | `cell-default` | cell assignment for new scopes |
| `IIOS_PROVIDER_URL_<CHANNEL>` | — | set to route that channel's egress to a real HTTP provider |
| `IIOS_RELAY_INTERVAL_MS` | `500` | outbox → event relay poll interval |

---

## 10. Vocabularies (enums)

- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, **MENTION**, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
- **Message part kind:** TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · **Attachment kind (DTO):** image, video, audio, file
- **Interaction annotation (app-level, opaque to the kernel):** `type` = reaction | pin | save … ; `value` = emoji (reactions) or empty
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
- **Meeting type:** ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL · **status:** REQUESTED, SCHEDULED, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW · **consent:** UNKNOWN, GRANTED, DENIED, REVOKED · **participant visibility:** FULL, LIMITED, NONE

---

*Base URL, ports, and demos: the service is `http://localhost:3200`; runnable demo UIs live under `apps/` (message-demo 5173, agent-demo 5174, adapter-inspector 5175, route-admin 5176, ai-studio 5177, meeting-studio 5178). For architecture/what-each-SDK-is-for, see `docs/IIOS_OVERVIEW_FOR_CEO.md`.*
