feat(p9): Capability Broker — governed egress + fail-closed gate + obligations
Tasks 9.1+9.2: contracts (CapabilityRequest/Result/Provider, PolicyObligation) + service CapabilityBroker that fails closed via opa (PolicyDeniedError, nothing sent), enforces obligations before any provider call (DENY_OUTBOUND/REVIEW → BLOCKED; MASK/REDACT → mask payload field), then routes to a CapabilityProviderRegistry (SandboxProvider default; env-gated HttpProvider overrides per channel; unknown channel fails closed). 5 tests: allow/deny/block/redact/unknown-channel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
# IIOS — Insignia Interaction OS
|
||||||
|
### Phase-by-phase overview · who uses what · a real build (Yamuna Expressway support)
|
||||||
|
|
||||||
|
> A plain-language walkthrough of everything built so far (P0 → P8), the SDKs each phase produced, who consumes them, and a worked example of building an Intercom-style support experience for the Yamuna Expressway portal on top of it. Written to be read top-to-bottom in ~10 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What IIOS is, in one paragraph
|
||||||
|
|
||||||
|
IIOS is **one backend and a family of small SDKs** that treat *every* interaction — a chat message, a support ticket, a routed community post, an AI suggestion, a scheduled meeting — as the **same underlying object** (an "Interaction") sitting inside a **tenant scope**, behind the **same safety gates** (who's allowed, whose consent, what policy), and emitting the **same audit trail**. Instead of building a chat app, then a separate ticketing app, then a separate meetings app — each with its own identity, permissions, and audit — we built the shared core once and then "earned" each product capability as a thin specialization on top. The result: a WhatsApp-community manager, a customer-support console, and a meeting assistant are the *same system wearing different hats*.
|
||||||
|
|
||||||
|
**Why a CEO should care:** every new product surface (support, community, meetings, AI) reuses the same identity, permission, consent, and audit spine — so each new capability is faster to ship and *safe by construction*, not safe by remembering to add checks. That's the moat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The one big idea (and three rules that make it safe)
|
||||||
|
|
||||||
|
- **Everything is an Interaction.** A message is an artifact of an interaction; so is a ticket, a route decision, an AI summary, a meeting. One data spine, one audit log.
|
||||||
|
- **Kernel + specializations.** A tiny kernel (identity, scope, threads, events) is the foundation. Messaging, inbox, support, routing, AI, meetings are *specializations* that import the kernel — never the other way around. A build check (`pnpm boundary`) enforces this so the layers can't rot into spaghetti.
|
||||||
|
- **Three safety rules baked in everywhere:**
|
||||||
|
1. **Fail-closed** — if the policy/consent check is unavailable or says no, nothing is written. Never an accidental "allow."
|
||||||
|
2. **Replayable & idempotent** — the same event processed twice produces one result, so nothing double-charges, double-sends, or double-books.
|
||||||
|
3. **Preview / propose before act** — risky things (forwarding to a group, AI decisions, recording a meeting) are *previewed or proposed*, and a human or a deterministic rule is the gate. AI can suggest; it never decides.
|
||||||
|
|
||||||
|
Today the external world (real WhatsApp, real AI models, real calendars, the real policy engine) is **faked with deterministic stand-ins** so we could build and prove the whole system offline. Turning those fakes real is the final phase (P9). **This is the single most important framing for the CEO: the engine and all the product SDKs are built and demonstrable end-to-end; what remains is wiring them to real external providers and hardening for scale.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The phases at a glance
|
||||||
|
|
||||||
|
| Phase | Capability | SDK / package produced | Who uses it | Live demo |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **P0–P1** | **Kernel** — identity, tenant scope, threads, events, fail-closed gates | `iios-contracts`, `iios-testkit`, `iios-service` (kernel) | Foundation for everything (no direct end-user) | — |
|
||||||
|
| **P2** | **Native messaging** — threads, messages, read receipts, unread counts, realtime | `iios-message-web` | Any chat UI (customer↔agent, member↔member) | message-demo (5173) |
|
||||||
|
| **P3** | **Inbox** — "needs reply / needs review" work queue | `iios-inbox-web` | Agents & admins triaging work | message-demo sidebar |
|
||||||
|
| **P4** | **Support** — tickets, queues, agent availability, escalation, callback | `iios-support-web` | Support agents + customers asking for help | agent-demo (5174) |
|
||||||
|
| **P5** | **Adapters** — inbound webhooks (signed), outbound send (sandbox), delivery tracking | `iios-adapter-sdk` | Integration engineers connecting channels | adapter-inspector (5175) |
|
||||||
|
| **P6** | **Routing / rules** — forward messages between groups, preview-first, deny-by-default | `iios-community-web` | Community moderators / admins | route-admin (5176) |
|
||||||
|
| **P7** | **AI proposal layer** — classify, summarize, extract; budget-governed; propose-never-decide | `iios-ai-web` | Any surface wanting AI assist (human-gated) | ai-studio (5177) |
|
||||||
|
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
|
||||||
|
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
|
||||||
|
|
||||||
|
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (46 tables across 9 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase by phase — what it does, who uses it, a real example
|
||||||
|
|
||||||
|
### P0–P1 · The Kernel (the foundation)
|
||||||
|
**What it adds.** The vocabulary everything else speaks: a **Scope** (which org/tenant/app an interaction belongs to), a **SourceHandle → ActorRef** (turning "a WhatsApp number" or "a portal login" into a known actor), **Threads** and **Interactions**, a **transactional outbox** (every state change writes a business row *and* an event in the same transaction), and the **fail-closed policy gate**.
|
||||||
|
**SDKs.** `iios-contracts` (the shared types, event names, and the platform-port interfaces), `iios-testkit` (deterministic fakes of the platform: session, policy/OPA, consent/CMP, identity/MDM, context, tokenization, capability broker), and the kernel inside `iios-service`.
|
||||||
|
**Who uses it.** Nobody directly — it's the bedrock. Every other phase depends on it.
|
||||||
|
**Real example.** A message arrives from WhatsApp number `+9198…`. The kernel resolves it to an actor *within the Yamuna org's scope* (not globally — no cross-tenant leaks), checks policy (fail-closed), and writes both the interaction and a `interaction.normalized` event atomically. If the policy check had been down, **nothing would be written** — no silent half-writes.
|
||||||
|
|
||||||
|
### P2 · Native messaging — `iios-message-web`
|
||||||
|
**What it adds.** Real chat: open a thread, send a message, delivery/read receipts, unread counters, and **realtime** delivery over websockets.
|
||||||
|
**Who uses it.** Any user-facing chat surface — a customer chatting with support, two members chatting, an agent replying.
|
||||||
|
**Real example.** A Yamuna plot-buyer opens the portal's chat widget and types "When is my next payment due?" — that's a thread + message via `iios-message-web`; the agent sees it live, replies, and the buyer's unread count updates instantly. **Demo:** message-demo (5173).
|
||||||
|
|
||||||
|
### P3 · Inbox — `iios-inbox-web`
|
||||||
|
**What it adds.** Turns raw messages into a **work queue**. When a message needs a human, an inbox item ("needs reply") is created for the right owner, with a state machine (open → done/snoozed/archived). Built by a projector reacting to `message.sent` — so the queue is always consistent with what was actually said.
|
||||||
|
**Who uses it.** Agents and admins who triage: "what's waiting on me?"
|
||||||
|
**Real example.** The buyer's "payment due?" message auto-creates a **needs-reply** item in the assigned agent's inbox; once the agent replies, it auto-resolves. No message silently falls through the cracks. **Demo:** the inbox sidebar in message-demo.
|
||||||
|
|
||||||
|
### P4 · Support — `iios-support-web`
|
||||||
|
**What it adds.** The support product: **tickets**, **queues**, **agent availability** (go online/offline), **assignment** (least-loaded agent), **escalation** (customer ↔ support thread), and **callback requests**. This is the Intercom-shaped layer.
|
||||||
|
**Who uses it.** Support agents (a dashboard) and customers (a "get help / request callback" widget).
|
||||||
|
**Real example.** The buyer clicks "Talk to support." A ticket is created, auto-assigned to an available agent, and a dedicated customer↔agent thread opens. If it needs a phone follow-up, the buyer requests a **callback** — which later becomes a meeting (see P8). **Demo:** agent-demo (5174).
|
||||||
|
|
||||||
|
### P5 · Adapters — `iios-adapter-sdk`
|
||||||
|
**What it adds.** The bridge to the outside world: **inbound** provider webhooks are **signature-verified (HMAC)**, normalized into kernel interactions, and **outbound** replies go through a **sandbox sender** with delivery-attempt tracking and rate limiting. Channels modeled: WEBHOOK, EMAIL, WHATSAPP, PORTAL.
|
||||||
|
**Who uses it.** Integration engineers who connect a new channel; the service uses it to ingest/emit.
|
||||||
|
**Real example.** A WhatsApp message to the Yamuna business number hits the webhook adapter → verified → becomes the same kind of interaction as a portal chat, so support handles WhatsApp and web chat *identically*. Replies are recorded but **sandboxed** (no real send yet — that's P9). **Demo:** adapter-inspector (5175).
|
||||||
|
|
||||||
|
### P6 · Routing & rules — `iios-community-web`
|
||||||
|
**What it adds.** Community message routing: **route bindings** (from origin → destination), a **preview-first simulator** that shows exactly what *would* be forwarded and to whom (with reason codes) **without sending**, decisions of ALLOW/DENY/REVIEW, **deny-by-default for restricted audiences** (e.g. a children's group), and admin **approve/deny**. Automatic routing only for explicitly safe rules.
|
||||||
|
**Who uses it.** Community managers / moderators (WhatsApp-community use case).
|
||||||
|
**Real example.** In a residents' community, an "after-hours party at 9 PM" post is flagged and routed to **REVIEW** for adults and **DENY** for the kids' group — a moderator approves before anything is forwarded. Nothing leaks by accident. **Demo:** route-admin (5176).
|
||||||
|
|
||||||
|
### P7 · AI proposal layer — `iios-ai-web`
|
||||||
|
**What it adds.** AI that **only proposes**: classify (moderation flags), summarize, extract (candidate events/FAQ) — each output carries confidence, evidence citations, model/cost, and an abstention reason. A **budget governor** caps spend; the pipeline **fails closed**; and an AI flag can only push a routing decision toward REVIEW/DENY — **never** auto-approve. AI suggests; a human accepts.
|
||||||
|
**Who uses it.** Any surface wanting assistance — support-reply drafts, meeting summaries, community moderation hints.
|
||||||
|
**Real example.** The support agent gets an AI-drafted reply and a one-line summary of a long buyer thread; a community post is auto-classified so the moderator sees "flagged: after-hours event" with the evidence. The agent/moderator always makes the call. **Demo:** ai-studio (5177).
|
||||||
|
|
||||||
|
### P8 · Calendar & meetings — `iios-meeting-web`
|
||||||
|
**What it adds.** Turns a callback into a real **meeting**: schedule + attendees, **per-attendee recording consent**, transcript, AI summary (reuses P7), and **action items** that flow back into the inbox. The safety spine: **no transcript/summary/action-item exists until every attendee consents** (and attendees marked "no visibility" are excluded from follow-ups). Meetings can also be born from an accepted AI-extracted event.
|
||||||
|
**Who uses it.** Support/scheduling flows; anyone turning a conversation into a scheduled follow-up.
|
||||||
|
**Real example.** The buyer's callback becomes a scheduled call. Both sides grant recording consent → the call is transcribed and summarized → "send revised payment schedule" becomes an action item in the agent's inbox. If the buyer declines recording, **no transcript is ever created**. **Demo:** meeting-studio (5178).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The SDK catalog — who consumes what
|
||||||
|
|
||||||
|
Think of it as three tiers:
|
||||||
|
|
||||||
|
**Tier 1 — Foundation (engineers, not end-users)**
|
||||||
|
- `iios-contracts` — shared types, event catalog, platform-port contracts. Everything imports this.
|
||||||
|
- `iios-testkit` — deterministic fakes + fixtures. Used by tests/QA to prove behavior offline.
|
||||||
|
- `iios-service` — the single backend API (NestJS) that contains every module (kernel, messaging, inbox, support, adapters, routing, ai, calendar).
|
||||||
|
- `iios-kernel-client` — the low-level REST + websocket client. Every React SDK is built on top of it; advanced apps can use it directly.
|
||||||
|
- `iios-adapter-sdk` — for authoring channel adapters (normalize + HMAC verify + sandbox send).
|
||||||
|
|
||||||
|
**Tier 2 — Product React SDKs (front-end teams building app surfaces)**
|
||||||
|
- `iios-message-web` — chat.
|
||||||
|
- `iios-inbox-web` — work queue.
|
||||||
|
- `iios-support-web` — tickets/escalation/callback/agent-availability.
|
||||||
|
- `iios-community-web` — routing rules + approvals.
|
||||||
|
- `iios-ai-web` — AI proposals + accept/reject.
|
||||||
|
- `iios-meeting-web` — scheduling + consent + summaries.
|
||||||
|
|
||||||
|
**Tier 3 — Demos (proof surfaces, one per capability)**
|
||||||
|
- message-demo, agent-demo, adapter-inspector, route-admin, ai-studio, meeting-studio.
|
||||||
|
|
||||||
|
Each product SDK is a handful of React hooks — a front-end dev wires the UI, the SDK handles auth, data-fetching, realtime, and talking to the one backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The worked example: an Intercom-style support experience for Yamuna Expressway
|
||||||
|
|
||||||
|
**The goal:** a plot-buyer in the Yamuna portal can chat with support, get AI-assisted answers, escalate to a human, and book a callback — and agents work it all from one console. (Yamuna already has the shell: a `support-live` widget, a `support-token` endpoint, and an assistant panel — currently pointed at the older support-service. IIOS is the upgrade path.)
|
||||||
|
|
||||||
|
**How it maps onto what we've built:**
|
||||||
|
|
||||||
|
| Intercom-style feature | Built on | Status today |
|
||||||
|
|---|---|---|
|
||||||
|
| Customer chat widget in the portal | `iios-message-web` (P2) | ✅ works now (in-portal, realtime) |
|
||||||
|
| "Get help" → ticket + route to an agent | `iios-support-web` (P4) | ✅ works now |
|
||||||
|
| Agent console (queue of conversations) | `iios-inbox-web` + `iios-support-web` (P3/P4) | ✅ works now |
|
||||||
|
| AI-suggested replies + thread summary | `iios-ai-web` (P7) | ✅ works now (deterministic AI; real model = P9) |
|
||||||
|
| Escalate to human / assignment / availability | `iios-support-web` (P4) | ✅ works now |
|
||||||
|
| Request a callback → book a meeting → notes/action items | `iios-meeting-web` (P8) | ✅ works now (sandbox) |
|
||||||
|
| Also answer the buyer on **WhatsApp/email**, not just portal | `iios-adapter-sdk` (P5) | ◐ modeled + sandboxed; **real send = P9** |
|
||||||
|
| Real AI answers from a real model | `iios-ai-web` + Capability Broker | ◐ deterministic stand-in today; **real model = P9** |
|
||||||
|
|
||||||
|
**Do we have everything to build it now?**
|
||||||
|
- **For in-portal live support (web chat between buyer and agent, tickets, escalation, AI-assist, callback→meeting): yes — today.** Every piece exists as an SDK against one backend. This is a buildable, demoable product right now, entirely inside the Yamuna portal, with no external providers required (portal chat rides the kernel + websockets).
|
||||||
|
- **For true omnichannel (the same conversation over WhatsApp/email) and real AI answers: not yet — that's P9.** The adapters and AI layer are built and proven with sandbox/deterministic stand-ins; P9 swaps in the real WhatsApp Business Platform, a real LLM via the Capability Broker, and the real policy/consent engines.
|
||||||
|
|
||||||
|
**So the honest one-liner for the CEO:** *"We can build Yamuna's in-portal support product on IIOS today; making it answer on WhatsApp and with a real AI model is the next phase (P9). The whole thing already runs end-to-end locally with safety, consent, and audit built in."*
|
||||||
|
|
||||||
|
**Concrete build path for Yamuna (all with existing SDKs):**
|
||||||
|
1. Point the portal's `support-token` endpoint at `iios-service`'s dev/session token.
|
||||||
|
2. Buyer widget: `MeetingProvider`-style `MessageProvider` + `useThread`/`useSendMessage` (message-web) for chat; `useCreateTicket`/`useRequestCallback` (support-web) for "talk to a human."
|
||||||
|
3. Agent console: `useInboxItems` (inbox-web) + support-web queue/availability/assignment; `useRunJob`/`useAiProposals` (ai-web) for draft replies & summaries.
|
||||||
|
4. Callback → `useSchedule` + consent + `useSummarize` (meeting-web) to run and capture the call.
|
||||||
|
5. Later (P9): register a real WhatsApp adapter so the same conversations arrive from WhatsApp, and swap the deterministic AI for a real model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What's real vs. simulated today (say this plainly)
|
||||||
|
|
||||||
|
| Area | Today | Becomes real in |
|
||||||
|
|---|---|---|
|
||||||
|
| Core engine, identity, scope, audit, events | **Real** | — |
|
||||||
|
| All 8 product capabilities (chat→meetings) | **Real logic, proven by tests + demos** | — |
|
||||||
|
| Outbound to WhatsApp/email | Sandbox (recorded, not sent) | P9 |
|
||||||
|
| AI answers | Deterministic golden model (no real LLM) | P9 |
|
||||||
|
| Policy / consent / identity resolution | Faked deterministic ports | P9 |
|
||||||
|
| Calendar/Zoom providers | Simulated sync | P9 |
|
||||||
|
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
||||||
|
|
||||||
|
**Proof it works:** 95 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. What to actually show in the room
|
||||||
|
|
||||||
|
1. **message-demo (5173)** — two people chat in realtime; a needs-reply item appears in the inbox. *"This is the chat + work-queue spine."*
|
||||||
|
2. **agent-demo (5174)** — a customer escalates; a ticket auto-assigns to an agent. *"This is support."*
|
||||||
|
3. **ai-studio (5177)** — run classify/summarize on a message; see confidence + evidence; accept a proposal. *"This is AI that proposes, never decides."*
|
||||||
|
4. **meeting-studio (5178)** — schedule a meeting; try to transcribe without consent (blocked), grant consent (works), get a summary + action items. *"This is consent-safe meetings."*
|
||||||
|
5. **route-admin (5176)** — simulate forwarding a flagged post; watch it deny to the kids' group. *"This is safe community routing."*
|
||||||
|
|
||||||
|
Narrative arc for the CEO: **one engine → chat → inbox → support → channels → community routing → AI → meetings**, each a small SDK, each safe by construction, all demonstrable — with P9 (real providers + scale) as the clearly-scoped remaining work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 46 Postgres tables across 9 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar). Boundary-enforced dependency law; 95 passing tests; 7 end-to-end smoke scripts.*
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Capability Broker contract (P9) — the single governed egress boundary.
|
||||||
|
*
|
||||||
|
* IIOS prepares idempotent outbound commands; the broker gates them through
|
||||||
|
* policy, enforces the returned obligations, and routes to a provider. In dev the
|
||||||
|
* provider is a sandbox (no network); a real provider is a binding/config swap.
|
||||||
|
* This is the concrete realization of the `capability` platform port.
|
||||||
|
*/
|
||||||
|
import type { DataClass } from './events';
|
||||||
|
import type { PolicyDecision } from './ports';
|
||||||
|
|
||||||
|
/** One obligation the broker must enforce before/at egress. */
|
||||||
|
export type PolicyObligation = PolicyDecision['obligations'][number];
|
||||||
|
|
||||||
|
export type CapabilityOutcome = 'SENT' | 'BLOCKED' | 'FAILED' | 'RATE_LIMITED';
|
||||||
|
|
||||||
|
export interface CapabilityRequest {
|
||||||
|
/** Dotted capability name, e.g. "channel.send". */
|
||||||
|
capability: string;
|
||||||
|
scopeId?: string;
|
||||||
|
channelType: string;
|
||||||
|
target: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
idempotencyKey: string;
|
||||||
|
dataClass?: DataClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityResult {
|
||||||
|
providerRef: string;
|
||||||
|
outcome: CapabilityOutcome;
|
||||||
|
errorCode?: string;
|
||||||
|
latencyMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a provider reports; the broker normalizes `outcome` into CapabilityResult. */
|
||||||
|
export interface ProviderResult {
|
||||||
|
providerRef: string;
|
||||||
|
outcome: string;
|
||||||
|
errorCode?: string;
|
||||||
|
latencyMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pluggable egress provider. Sandbox (no network) by default; real providers
|
||||||
|
* (WhatsApp/SMTP/HTTP webhook) implement the same shape. Must be idempotent and
|
||||||
|
* must surface transport errors as FAILED rather than throwing.
|
||||||
|
*/
|
||||||
|
export interface CapabilityProvider {
|
||||||
|
readonly name: string;
|
||||||
|
readonly channelTypes: string[];
|
||||||
|
readonly capabilities: { canSend: boolean; maxPayloadBytes?: number };
|
||||||
|
send(req: CapabilityRequest): Promise<ProviderResult>;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export * from './enums';
|
|||||||
export * from './ingest';
|
export * from './ingest';
|
||||||
export * from './ports';
|
export * from './ports';
|
||||||
export * from './inference';
|
export * from './inference';
|
||||||
|
export * from './capability';
|
||||||
export * from './events';
|
export * from './events';
|
||||||
export * from './commands';
|
export * from './commands';
|
||||||
export * from './errors';
|
export * from './errors';
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { PolicyDeniedError, type CapabilityRequest } from '@insignia/iios-contracts';
|
||||||
|
import { CapabilityBroker } from './capability.broker';
|
||||||
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
|
import type { FakePorts } from '@insignia/iios-testkit';
|
||||||
|
|
||||||
|
// No DB — the broker is pure governance over an in-memory provider registry.
|
||||||
|
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
|
||||||
|
capability: 'channel.send',
|
||||||
|
scopeId: 'scope1',
|
||||||
|
channelType: 'WEBHOOK',
|
||||||
|
target: 'user@x',
|
||||||
|
payload: { text: 'hello' },
|
||||||
|
idempotencyKey: 'k1',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const broker = (ports: FakePorts) => new CapabilityBroker(ports, new CapabilityProviderRegistry());
|
||||||
|
|
||||||
|
describe('CapabilityBroker (P9 — governed egress)', () => {
|
||||||
|
it('allow + no obligations → provider SENT', async () => {
|
||||||
|
const r = await broker(makeFakePorts()).invoke(req());
|
||||||
|
expect(r.outcome).toBe('SENT');
|
||||||
|
expect(r.providerRef).toContain('sim-WEBHOOK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed: opa deny → PolicyDeniedError, provider never called', async () => {
|
||||||
|
const ports = makeFakePorts();
|
||||||
|
ports.opa.deny('no egress');
|
||||||
|
await expect(broker(ports).invoke(req())).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DENY_OUTBOUND obligation → BLOCKED, provider never called', async () => {
|
||||||
|
const ports = makeFakePorts();
|
||||||
|
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'user opted out' }] });
|
||||||
|
const r = await broker(ports).invoke(req());
|
||||||
|
expect(r.outcome).toBe('BLOCKED');
|
||||||
|
expect(r.errorCode).toBe('DENY_OUTBOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REDACT obligation → provider receives masked payload', async () => {
|
||||||
|
const ports = makeFakePorts();
|
||||||
|
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'REDACT', target: 'text', reason: 'PII' }] });
|
||||||
|
const registry = new CapabilityProviderRegistry();
|
||||||
|
let seen: unknown;
|
||||||
|
registry.register({ name: 'spy', channelTypes: ['WEBHOOK'], capabilities: { canSend: true }, async send(r) { seen = r.payload; return { providerRef: 'spy-1', outcome: 'SENT' }; } });
|
||||||
|
const r = await new CapabilityBroker(ports, registry).invoke(req());
|
||||||
|
expect(r.outcome).toBe('SENT');
|
||||||
|
expect((seen as { text: string }).text).toBe('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown channel → fails closed (throws, no silent egress)', async () => {
|
||||||
|
await expect(broker(makeFakePorts()).invoke(req({ channelType: 'CARRIER_PIGEON' }))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { CapabilityRequest, CapabilityResult, IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
|
import { decideOrThrow } from '../platform/fail-closed';
|
||||||
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single governed egress boundary (P9). Every outbound action flows through
|
||||||
|
* here: it fails closed on policy (nothing sent unless explicitly allowed),
|
||||||
|
* enforces the policy's obligations BEFORE a provider is ever called, then routes
|
||||||
|
* to the channel's provider. It is the concrete realization of the `capability`
|
||||||
|
* platform port — a real broker replacing the permissive P1 stub.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityBroker {
|
||||||
|
constructor(
|
||||||
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
|
private readonly registry: CapabilityProviderRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async invoke(req: CapabilityRequest): Promise<CapabilityResult> {
|
||||||
|
// 1. Fail-closed policy gate. Deny/timeout ⇒ PolicyDeniedError, nothing sent.
|
||||||
|
const decision = await decideOrThrow(this.ports, {
|
||||||
|
action: `iios.capability.${req.capability}`,
|
||||||
|
scopeId: req.scopeId,
|
||||||
|
channelType: req.channelType,
|
||||||
|
target: req.target,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Enforce obligations BEFORE touching a provider.
|
||||||
|
for (const ob of decision.obligations) {
|
||||||
|
if (ob.kind === 'DENY_OUTBOUND' || ob.kind === 'REVIEW') {
|
||||||
|
return { providerRef: 'blocked', outcome: 'BLOCKED', errorCode: ob.kind };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload = this.applyMasks(req.payload, decision.obligations);
|
||||||
|
|
||||||
|
// 3. Route to the channel's provider (unknown channel fails closed in the registry).
|
||||||
|
const provider = this.registry.forChannel(req.channelType);
|
||||||
|
const result = await provider.send({ ...req, payload });
|
||||||
|
|
||||||
|
// 4. Normalize the provider's outcome.
|
||||||
|
const outcome = result.outcome === 'SENT' ? 'SENT' : result.outcome === 'RATE_LIMITED' ? 'RATE_LIMITED' : 'FAILED';
|
||||||
|
return { providerRef: result.providerRef, outcome, errorCode: result.errorCode, latencyMs: result.latencyMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMasks(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
obligations: { kind: string; target?: string }[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const masks = obligations.filter((o) => (o.kind === 'MASK' || o.kind === 'REDACT') && o.target);
|
||||||
|
if (masks.length === 0) return payload;
|
||||||
|
const out = { ...payload };
|
||||||
|
for (const m of masks) out[m.target as string] = '[REDACTED]';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
|
import { CapabilityBroker } from './capability.broker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
|
||||||
|
* outbound layer routes all sends through policy + obligations + a pluggable
|
||||||
|
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
||||||
|
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
||||||
|
})
|
||||||
|
export class CapabilityModule {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||||
|
import { SandboxProvider } from './sandbox.provider';
|
||||||
|
import { HttpProvider } from './http.provider';
|
||||||
|
|
||||||
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
||||||
|
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
||||||
|
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
||||||
|
* channels fail closed — no silent egress path.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityProviderRegistry {
|
||||||
|
private readonly byChannel = new Map<string, CapabilityProvider>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
||||||
|
for (const ch of DEFAULT_CHANNELS) {
|
||||||
|
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||||
|
if (url) this.register(new HttpProvider(ch, url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(provider: CapabilityProvider): void {
|
||||||
|
for (const ch of provider.channelTypes) this.byChannel.set(ch, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
forChannel(channelType: string): CapabilityProvider {
|
||||||
|
const p = this.byChannel.get(channelType);
|
||||||
|
if (!p) throw new NotFoundException(`no egress provider registered for channel: ${channelType}`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops view: which provider backs each channel. */
|
||||||
|
bindings(): Record<string, string> {
|
||||||
|
return Object.fromEntries([...this.byChannel.entries()].map(([ch, p]) => [ch, p.name]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real egress provider: POSTs the outbound command to a configured URL. Activated
|
||||||
|
* only when `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set — proving "flip the binding →
|
||||||
|
* it really sends" without hardcoding any specific vendor. A transport failure is
|
||||||
|
* surfaced as FAILED (never thrown), per the adapter doctrine.
|
||||||
|
*/
|
||||||
|
export class HttpProvider implements CapabilityProvider {
|
||||||
|
readonly name = 'http';
|
||||||
|
readonly channelTypes: string[];
|
||||||
|
readonly capabilities = { canSend: true };
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
channelType: string,
|
||||||
|
private readonly url: string,
|
||||||
|
) {
|
||||||
|
this.channelTypes = [channelType];
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'idempotency-key': req.idempotencyKey },
|
||||||
|
body: JSON.stringify({ channelType: req.channelType, target: req.target, payload: req.payload }),
|
||||||
|
});
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
if (!res.ok) return { providerRef: `http-${res.status}`, outcome: 'FAILED', errorCode: `HTTP_${res.status}`, latencyMs };
|
||||||
|
return { providerRef: `http-${req.channelType}-${req.idempotencyKey}`, outcome: 'SENT', latencyMs };
|
||||||
|
} catch (err) {
|
||||||
|
return { providerRef: 'http-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default egress provider: a sandbox sink that records a deterministic providerRef
|
||||||
|
* and never touches the network (mirrors the P5 adapter sandbox). Idempotent by
|
||||||
|
* construction — the same request just yields another sim ref, never throws.
|
||||||
|
*/
|
||||||
|
export class SandboxProvider implements CapabilityProvider {
|
||||||
|
readonly name = 'sandbox';
|
||||||
|
readonly channelTypes: string[];
|
||||||
|
readonly capabilities = { canSend: true };
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
constructor(channelTypes: string[]) {
|
||||||
|
this.channelTypes = channelTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||||
|
return { providerRef: `sim-${req.channelType}-${++this.seq}`, outcome: 'SENT', latencyMs: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user