# Employee Communication Fabric Implementation Plan

> **Status (2026-05-16):** Slice 0 + Slice 1 shipped via [PR #619 "[codex] Employee communication fabric"](https://github.com/anthropics/dpf/pull/619) and [PR #628 "feat(communications): surface channel binding management"](https://github.com/anthropics/dpf/pull/628). Tasks 1–9 are implemented on `main`. Task 10 (backlog hygiene) was not separately tracked; if Slice 2+ work is planned, re-run Task 10 to confirm no overlap and to file an `EP-COMMS-FABRIC` epic. Slice 2 (Teams + Slack outbound), Slice 3 (WhatsApp Secretary as channel slice), Slice 4 (Telegram/webhook), and Slice 5 (rich mobile work client) are not yet planned.

> **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 first implementation slice of DPF's employee communication fabric: shared communication adapter contracts, DPF-owned in-app/push/email baseline, delivery evidence, and the operator/employee surfaces needed before Teams, Slack, WhatsApp, or Telegram adapters are added.

**Architecture:** Keep DPF as the system of record. External channels are adapter implementations behind `apps/web/lib/communications/`; work queue, coworker, notification, identity, and audit models remain canonical. The first slice refactors the existing queue notification adapter into a reusable dispatcher, adds delivery evidence models, and exposes reachability/readiness surfaces without adding provider-specific outbound behavior yet.

**Tech Stack:** Next.js 16 app router, TypeScript, Prisma 7, Vitest, pnpm workspaces, existing DPF theme variables.

---

## Scope

This plan implements the foundation and DPF-owned baseline only.

Included:

- Shared channel constants and adapter interfaces.
- Dispatch policy helper for urgency, quiet hours, and fallback order.
- Existing in-app notification adapter moved behind the new interface.
- Delivery evidence model and persistence helper.
- Employee channel binding model and server-side validation helpers.
- Admin communications hub shell and employee reachability shell.
- Tests for every pure helper and DB-facing helper.

Deferred:

- Teams outbound send.
- Slack outbound send.
- WhatsApp inbound runtime.
- Telegram adapter.
- SMS adapter.
- Raw provider webhook replay storage.

## File Structure

- `apps/web/lib/communications/channel-types.ts`
  Canonical channel enums, urgency types, delivery statuses, and adapter input/output types.

- `apps/web/lib/communications/channel-types.test.ts`
  Contract tests for channel constants and guards.

- `apps/web/lib/communications/dispatch-policy.ts`
  Pure selection logic that turns urgency, configured bindings, quiet hours, and available channels into an ordered delivery plan.

- `apps/web/lib/communications/dispatch-policy.test.ts`
  Tests for routine, urgent, emergency, quiet-hours, and fallback behavior.

- `apps/web/lib/communications/in-app-adapter.ts`
  Built-in communication adapter that writes to the existing `Notification` model.

- `apps/web/lib/communications/dispatcher.ts`
  Registry and dispatch function that calls adapters, records results, and returns a stable result shape.

- `apps/web/lib/communications/dispatcher.test.ts`
  Tests for adapter fanout, settled errors, and delivery result shape.

- `apps/web/lib/communications/delivery-evidence.ts`
  DB helper for `CommunicationDeliveryAttempt` rows.

- `apps/web/lib/communications/delivery-evidence.test.ts`
  Tests for create/update delivery evidence.

- `apps/web/lib/communications/channel-bindings.ts`
  Validation and normalization helpers for employee channel bindings.

- `apps/web/lib/communications/channel-bindings.test.ts`
  Tests for channel binding validation and identity-safe normalization.

- `apps/web/lib/queue/notification-adapter.ts`
  Compatibility wrapper that delegates queue notifications to the communication dispatcher.

- `apps/web/lib/queue/queue-types.ts`
  Reuse or re-export communication channel constants so queue code does not maintain a separate list.

- `packages/db/prisma/schema.prisma`
  Add `CommunicationChannelBinding`, `CommunicationChannelSession`, and `CommunicationDeliveryAttempt`.

- `packages/db/prisma/migrations/<timestamp>_communication_fabric/migration.sql`
  Migration generated by Prisma, with indexes added in the same migration.

- `apps/web/app/(shell)/platform/tools/integrations/communications/page.tsx`
  Admin communications hub shell.

- `apps/web/app/(shell)/platform/tools/integrations/communications/page.test.tsx`
  Static render test for the hub.

- `apps/web/components/employee/EmployeeReachabilityPanel.tsx`
  Compact employee reachability panel using DPF theme variables.

- `apps/web/components/employee/EmployeeReachabilityPanel.test.tsx`
  Static render tests for channel status and empty state.

---

### Task 1: Add Canonical Communication Types

**Files:**
- Create: `apps/web/lib/communications/channel-types.ts`
- Create: `apps/web/lib/communications/channel-types.test.ts`
- Modify: `apps/web/lib/queue/queue-types.ts`

- [x] **Step 1: Write the failing type contract test**

Create `apps/web/lib/communications/channel-types.test.ts`:

```ts
import { describe, expect, it } from "vitest";

import {
  COMMUNICATION_CHANNELS,
  COMMUNICATION_DELIVERY_STATUSES,
  isCommunicationChannel,
  normalizeCommunicationChannel,
} from "./channel-types";

describe("communication channel types", () => {
  it("includes DPF-owned and external employee communication channels", () => {
    expect(COMMUNICATION_CHANNELS).toEqual([
      "in-app",
      "push",
      "email",
      "teams",
      "slack",
      "whatsapp",
      "telegram",
      "webhook",
    ]);
  });

  it("normalizes legacy and display channel labels", () => {
    expect(normalizeCommunicationChannel("in_app")).toBe("in-app");
    expect(normalizeCommunicationChannel("Microsoft Teams")).toBe("teams");
    expect(normalizeCommunicationChannel("WhatsApp Business")).toBe("whatsapp");
  });

  it("rejects unknown channel labels", () => {
    expect(isCommunicationChannel("fax")).toBe(false);
    expect(normalizeCommunicationChannel("fax")).toBeNull();
  });

  it("declares delivery states used by adapter results", () => {
    expect(COMMUNICATION_DELIVERY_STATUSES).toContain("queued");
    expect(COMMUNICATION_DELIVERY_STATUSES).toContain("sent");
    expect(COMMUNICATION_DELIVERY_STATUSES).toContain("failed");
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/channel-types.test.ts
```

Expected: FAIL because `apps/web/lib/communications/channel-types.ts` does not exist.

- [x] **Step 3: Add the shared communication types**

Create `apps/web/lib/communications/channel-types.ts`:

```ts
export const COMMUNICATION_CHANNELS = [
  "in-app",
  "push",
  "email",
  "teams",
  "slack",
  "whatsapp",
  "telegram",
  "webhook",
] as const;

export type CommunicationChannel = (typeof COMMUNICATION_CHANNELS)[number];

export const COMMUNICATION_URGENCIES = [
  "routine",
  "priority",
  "urgent",
  "emergency",
] as const;

export type CommunicationUrgency = (typeof COMMUNICATION_URGENCIES)[number];

export const COMMUNICATION_DELIVERY_STATUSES = [
  "queued",
  "sent",
  "delivered",
  "read",
  "failed",
  "expired",
] as const;

export type CommunicationDeliveryStatus = (typeof COMMUNICATION_DELIVERY_STATUSES)[number];

export type CommunicationDirection = "outbound" | "inbound" | "interactive";

export interface CommunicationTarget {
  targetType: "notification" | "work-item" | "agent-thread" | "direct-test";
  targetId: string;
  recipientUserId?: string;
  recipientPrincipalId?: string;
}

export interface SendCommunicationInput {
  channel: CommunicationChannel;
  target: CommunicationTarget;
  title: string;
  body: string;
  urgency: CommunicationUrgency;
  deepLink?: string;
  metadata?: Record<string, unknown>;
}

export interface CommunicationDeliveryResult {
  channel: CommunicationChannel;
  status: CommunicationDeliveryStatus;
  providerMessageId?: string;
  errorCode?: string;
  errorMessage?: string;
}

export interface CommunicationAdapter {
  key: string;
  channel: CommunicationChannel;
  capabilities: {
    outbound: boolean;
    inbound: boolean;
    interactive: boolean;
    deliveryReceipts: boolean;
    readReceipts: boolean;
    templatesRequired: boolean;
  };
  send(input: SendCommunicationInput): Promise<CommunicationDeliveryResult>;
}

const CHANNEL_ALIASES: Record<string, CommunicationChannel> = {
  in_app: "in-app",
  inapp: "in-app",
  "in app": "in-app",
  mobile: "push",
  "mobile push": "push",
  "microsoft teams": "teams",
  ms_teams: "teams",
  whatsapp_business: "whatsapp",
  "whatsapp business": "whatsapp",
};

export function isCommunicationChannel(value: string): value is CommunicationChannel {
  return (COMMUNICATION_CHANNELS as readonly string[]).includes(value);
}

export function normalizeCommunicationChannel(value: string): CommunicationChannel | null {
  const normalized = value.trim().toLowerCase().replace(/\s+/g, " ");
  const aliased = CHANNEL_ALIASES[normalized] ?? CHANNEL_ALIASES[normalized.replace(/-/g, "_")];
  if (aliased) return aliased;
  return isCommunicationChannel(normalized) ? normalized : null;
}
```

- [x] **Step 4: Re-export queue notification channels from the communication types**

Modify `apps/web/lib/queue/queue-types.ts` by replacing the current `NOTIFICATION_CHANNELS` block with:

```ts
export {
  COMMUNICATION_CHANNELS as NOTIFICATION_CHANNELS,
  type CommunicationChannel as NotificationChannel,
} from "@/lib/communications/channel-types";
```

- [x] **Step 5: Run the focused test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/channel-types.test.ts
```

Expected: PASS.

- [x] **Step 6: Commit**

Run:

```powershell
git branch --show-current
git add apps/web/lib/communications/channel-types.ts apps/web/lib/communications/channel-types.test.ts apps/web/lib/queue/queue-types.ts
git commit -s -m "feat(communications): add canonical channel types"
```

Expected branch: not `main`. Commit succeeds with a `Signed-off-by:` trailer.

---

### Task 2: Add Dispatch Policy Helper

**Files:**
- Create: `apps/web/lib/communications/dispatch-policy.ts`
- Create: `apps/web/lib/communications/dispatch-policy.test.ts`

- [x] **Step 1: Write the failing dispatch policy tests**

Create `apps/web/lib/communications/dispatch-policy.test.ts`:

```ts
import { describe, expect, it } from "vitest";

import { selectCommunicationPlan } from "./dispatch-policy";

const bindings = [
  { channel: "in-app", verified: true, active: true, allowedUrgencies: ["routine", "priority", "urgent", "emergency"] },
  { channel: "push", verified: true, active: true, allowedUrgencies: ["routine", "priority", "urgent", "emergency"] },
  { channel: "teams", verified: true, active: true, allowedUrgencies: ["priority", "urgent", "emergency"] },
  { channel: "email", verified: true, active: true, allowedUrgencies: ["routine", "priority", "urgent", "emergency"] },
] as const;

describe("selectCommunicationPlan", () => {
  it("uses DPF-owned channels for routine work", () => {
    const plan = selectCommunicationPlan({ urgency: "routine", bindings, now: new Date("2026-05-15T16:00:00Z") });
    expect(plan.channels).toEqual(["in-app", "push", "email"]);
    expect(plan.fanOut).toBe(false);
  });

  it("adds real-time channels for urgent work", () => {
    const plan = selectCommunicationPlan({ urgency: "urgent", bindings, now: new Date("2026-05-15T16:00:00Z") });
    expect(plan.channels).toEqual(["in-app", "push", "teams", "email"]);
  });

  it("fans out emergency notifications", () => {
    const plan = selectCommunicationPlan({ urgency: "emergency", bindings, now: new Date("2026-05-15T16:00:00Z") });
    expect(plan.fanOut).toBe(true);
    expect(plan.channels).toEqual(["in-app", "push", "teams", "email"]);
  });

  it("skips inactive and unverified bindings", () => {
    const plan = selectCommunicationPlan({
      urgency: "priority",
      now: new Date("2026-05-15T16:00:00Z"),
      bindings: [
        { channel: "slack", verified: false, active: true, allowedUrgencies: ["priority"] },
        { channel: "email", verified: true, active: false, allowedUrgencies: ["priority"] },
        { channel: "in-app", verified: true, active: true, allowedUrgencies: ["priority"] },
      ],
    });
    expect(plan.channels).toEqual(["in-app"]);
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/dispatch-policy.test.ts
```

Expected: FAIL because `dispatch-policy.ts` does not exist.

- [x] **Step 3: Implement the policy helper**

Create `apps/web/lib/communications/dispatch-policy.ts`:

```ts
import type { CommunicationChannel, CommunicationUrgency } from "./channel-types";

export interface CommunicationBindingSummary {
  channel: CommunicationChannel;
  verified: boolean;
  active: boolean;
  allowedUrgencies: readonly CommunicationUrgency[];
}

export interface SelectCommunicationPlanInput {
  urgency: CommunicationUrgency;
  bindings: readonly CommunicationBindingSummary[];
  now: Date;
}

export interface CommunicationPlan {
  channels: CommunicationChannel[];
  fanOut: boolean;
}

const ROUTINE_ORDER: CommunicationChannel[] = ["in-app", "push", "email"];
const PRIORITY_ORDER: CommunicationChannel[] = ["in-app", "push", "teams", "slack", "whatsapp", "email"];
const URGENT_ORDER: CommunicationChannel[] = ["in-app", "push", "teams", "slack", "whatsapp", "email"];
const EMERGENCY_ORDER: CommunicationChannel[] = ["in-app", "push", "teams", "slack", "whatsapp", "email", "telegram", "webhook"];

export function selectCommunicationPlan(input: SelectCommunicationPlanInput): CommunicationPlan {
  const order = input.urgency === "routine"
    ? ROUTINE_ORDER
    : input.urgency === "priority"
      ? PRIORITY_ORDER
      : input.urgency === "urgent"
        ? URGENT_ORDER
        : EMERGENCY_ORDER;

  const eligible = input.bindings.filter(
    (binding) =>
      binding.active &&
      binding.verified &&
      binding.allowedUrgencies.includes(input.urgency),
  );

  const channels = order.filter((channel) =>
    eligible.some((binding) => binding.channel === channel),
  );

  return {
    channels,
    fanOut: input.urgency === "emergency",
  };
}
```

- [x] **Step 4: Run the focused test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/dispatch-policy.test.ts
```

Expected: PASS.

- [x] **Step 5: Commit**

Run:

```powershell
git add apps/web/lib/communications/dispatch-policy.ts apps/web/lib/communications/dispatch-policy.test.ts
git commit -s -m "feat(communications): add dispatch policy helper"
```

---

### Task 3: Refactor In-App Queue Notifications Behind The Communication Dispatcher

**Files:**
- Create: `apps/web/lib/communications/in-app-adapter.ts`
- Create: `apps/web/lib/communications/dispatcher.ts`
- Create: `apps/web/lib/communications/dispatcher.test.ts`
- Modify: `apps/web/lib/queue/notification-adapter.ts`

- [x] **Step 1: Write dispatcher tests**

Create `apps/web/lib/communications/dispatcher.test.ts`:

```ts
import { beforeEach, describe, expect, it, vi } from "vitest";

const notificationCreate = vi.fn();

vi.mock("@dpf/db", () => ({
  prisma: {
    notification: { create: notificationCreate },
  },
}));

import { createInAppAdapter } from "./in-app-adapter";
import { createCommunicationDispatcher } from "./dispatcher";

beforeEach(() => {
  vi.clearAllMocks();
  notificationCreate.mockResolvedValue({ id: "notif-1" });
});

describe("communication dispatcher", () => {
  it("dispatches through the in-app adapter and returns a sent result", async () => {
    const dispatcher = createCommunicationDispatcher([createInAppAdapter()]);

    const result = await dispatcher.send({
      channel: "in-app",
      target: { targetType: "work-item", targetId: "WI-1", recipientUserId: "user-1" },
      title: "Approval needed",
      body: "Please review the request.",
      urgency: "priority",
      deepLink: "/workspace/my-queue",
    });

    expect(result).toEqual({
      channel: "in-app",
      status: "sent",
      providerMessageId: "notif-1",
    });
    expect(notificationCreate).toHaveBeenCalledWith({
      data: {
        userId: "user-1",
        type: "work-queue",
        title: "Approval needed",
        body: "Please review the request.",
        deepLink: "/workspace/my-queue",
        read: false,
      },
    });
  });

  it("returns failed when no adapter is registered for the channel", async () => {
    const dispatcher = createCommunicationDispatcher([]);
    const result = await dispatcher.send({
      channel: "teams",
      target: { targetType: "work-item", targetId: "WI-1", recipientUserId: "user-1" },
      title: "Approval needed",
      body: "Please review the request.",
      urgency: "priority",
    });

    expect(result.status).toBe("failed");
    expect(result.errorCode).toBe("adapter_not_registered");
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/dispatcher.test.ts
```

Expected: FAIL because dispatcher and adapter files do not exist.

- [x] **Step 3: Add the in-app adapter**

Create `apps/web/lib/communications/in-app-adapter.ts`:

```ts
import { prisma } from "@dpf/db";

import type { CommunicationAdapter } from "./channel-types";

export function createInAppAdapter(): CommunicationAdapter {
  return {
    key: "in-app",
    channel: "in-app",
    capabilities: {
      outbound: true,
      inbound: false,
      interactive: false,
      deliveryReceipts: false,
      readReceipts: false,
      templatesRequired: false,
    },
    async send(input) {
      if (!input.target.recipientUserId) {
        return {
          channel: "in-app",
          status: "failed",
          errorCode: "missing_recipient_user",
          errorMessage: "In-app notifications require recipientUserId.",
        };
      }

      const notification = await prisma.notification.create({
        data: {
          userId: input.target.recipientUserId,
          type: input.target.targetType === "work-item" ? "work-queue" : "communication",
          title: input.title,
          body: input.body,
          deepLink: input.deepLink ?? "/workspace/my-queue",
          read: false,
        },
      });

      return {
        channel: "in-app",
        status: "sent",
        providerMessageId: notification.id,
      };
    },
  };
}
```

- [x] **Step 4: Add the dispatcher**

Create `apps/web/lib/communications/dispatcher.ts`:

```ts
import type {
  CommunicationAdapter,
  CommunicationDeliveryResult,
  SendCommunicationInput,
} from "./channel-types";

export interface CommunicationDispatcher {
  send(input: SendCommunicationInput): Promise<CommunicationDeliveryResult>;
}

export function createCommunicationDispatcher(
  adapters: readonly CommunicationAdapter[],
): CommunicationDispatcher {
  const byChannel = new Map(adapters.map((adapter) => [adapter.channel, adapter]));

  return {
    async send(input) {
      const adapter = byChannel.get(input.channel);
      if (!adapter) {
        return {
          channel: input.channel,
          status: "failed",
          errorCode: "adapter_not_registered",
          errorMessage: `No communication adapter is registered for ${input.channel}.`,
        };
      }

      try {
        return await adapter.send(input);
      } catch (error) {
        return {
          channel: input.channel,
          status: "failed",
          errorCode: "adapter_error",
          errorMessage: error instanceof Error ? error.message : "Unknown adapter error.",
        };
      }
    },
  };
}
```

- [x] **Step 5: Update queue notification compatibility wrapper**

Modify `apps/web/lib/queue/notification-adapter.ts`:

```ts
import { createCommunicationDispatcher } from "@/lib/communications/dispatcher";
import { createInAppAdapter } from "@/lib/communications/in-app-adapter";
import type { CommunicationAdapter } from "@/lib/communications/channel-types";

export interface QueueNotification {
  recipientUserId: string;
  workItemId: string;
  title: string;
  body: string;
  urgency: string;
  deepLink?: string;
}

const adapters: CommunicationAdapter[] = [createInAppAdapter()];

export function registerAdapter(adapter: CommunicationAdapter): void {
  adapters.push(adapter);
}

export async function sendQueueNotification(notification: QueueNotification): Promise<void> {
  const dispatcher = createCommunicationDispatcher(adapters);
  await dispatcher.send({
    channel: "in-app",
    target: {
      targetType: "work-item",
      targetId: notification.workItemId,
      recipientUserId: notification.recipientUserId,
    },
    title: notification.title,
    body: notification.body,
    urgency: notification.urgency === "emergency"
      ? "emergency"
      : notification.urgency === "urgent"
        ? "urgent"
        : notification.urgency === "priority"
          ? "priority"
          : "routine",
    deepLink: notification.deepLink,
  });
}
```

- [x] **Step 6: Run focused tests**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/dispatcher.test.ts
```

Expected: PASS.

- [x] **Step 7: Commit**

Run:

```powershell
git add apps/web/lib/communications/in-app-adapter.ts apps/web/lib/communications/dispatcher.ts apps/web/lib/communications/dispatcher.test.ts apps/web/lib/queue/notification-adapter.ts
git commit -s -m "refactor(communications): route queue notifications through dispatcher"
```

---

### Task 4: Add Communication Delivery And Binding Models

**Files:**
- Modify: `packages/db/prisma/schema.prisma`
- Create: `packages/db/prisma/migrations/<timestamp>_communication_fabric/migration.sql`

- [x] **Step 1: Add Prisma models**

Add these models near `Notification` and `PushDeviceRegistration` in `packages/db/prisma/schema.prisma`:

```prisma
model CommunicationChannelBinding {
  id                 String   @id @default(cuid())
  bindingId          String   @unique @default(cuid())
  principalId        String
  employeeProfileId  String?
  channelType        String
  providerKey        String
  providerAccountId  String?
  externalSubject    String
  displayLabel       String?
  verificationStatus String   @default("pending")
  allowedDirections  String[] @default([])
  allowedUrgencies   String[] @default([])
  quietHours         Json?
  preferences        Json?
  lastVerifiedAt     DateTime?
  lastTestedAt       DateTime?
  lastError          String?
  isActive           Boolean  @default(true)
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  @@unique([channelType, providerKey, providerAccountId, externalSubject])
  @@index([principalId])
  @@index([employeeProfileId])
  @@index([channelType, isActive])
}

model CommunicationChannelSession {
  id                String   @id @default(cuid())
  sessionId         String   @unique @default(cuid())
  channelType       String
  providerKey       String
  providerAccountId String?
  externalPeerId    String
  trustLevel        String   @default("unknown")
  principalId       String?
  agentThreadId     String?
  workItemId        String?
  lastInboundAt     DateTime?
  lastOutboundAt    DateTime?
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt

  @@unique([channelType, providerKey, providerAccountId, externalPeerId])
  @@index([principalId])
  @@index([agentThreadId])
  @@index([workItemId])
}

model CommunicationDeliveryAttempt {
  id               String   @id @default(cuid())
  attemptId        String   @unique @default(cuid())
  channelBindingId String?
  channelType      String
  targetType       String
  targetId         String
  providerMessageId String?
  status           String   @default("queued")
  urgency          String   @default("routine")
  errorCode        String?
  errorMessage     String?
  sentAt           DateTime?
  deliveredAt      DateTime?
  readAt           DateTime?
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt

  @@index([targetType, targetId])
  @@index([channelBindingId])
  @@index([channelType, status])
  @@index([createdAt])
}
```

- [x] **Step 2: Generate the migration**

Run:

```powershell
pnpm --filter @dpf/db exec prisma migrate dev --name communication_fabric
```

Expected: Prisma creates `packages/db/prisma/migrations/<timestamp>_communication_fabric/migration.sql` and applies it to the local development database.

- [x] **Step 3: Generate the Prisma client**

Run:

```powershell
pnpm --filter @dpf/db exec prisma generate
```

Expected: Prisma client generation succeeds.

- [x] **Step 4: Commit**

Run:

```powershell
git add packages/db/prisma/schema.prisma packages/db/prisma/migrations
git commit -s -m "feat(db): add communication fabric models"
```

---

### Task 5: Persist Delivery Evidence

**Files:**
- Create: `apps/web/lib/communications/delivery-evidence.ts`
- Create: `apps/web/lib/communications/delivery-evidence.test.ts`
- Modify: `apps/web/lib/communications/dispatcher.ts`
- Modify: `apps/web/lib/communications/dispatcher.test.ts`

- [x] **Step 1: Write delivery evidence tests**

Create `apps/web/lib/communications/delivery-evidence.test.ts`:

```ts
import { beforeEach, describe, expect, it, vi } from "vitest";

const deliveryCreate = vi.fn();

vi.mock("@dpf/db", () => ({
  prisma: {
    communicationDeliveryAttempt: { create: deliveryCreate },
  },
}));

import { recordDeliveryAttempt } from "./delivery-evidence";

beforeEach(() => {
  vi.clearAllMocks();
  deliveryCreate.mockResolvedValue({ attemptId: "attempt-1" });
});

describe("recordDeliveryAttempt", () => {
  it("writes delivery evidence for a communication result", async () => {
    const result = await recordDeliveryAttempt({
      channel: "in-app",
      target: { targetType: "work-item", targetId: "WI-1", recipientUserId: "user-1" },
      urgency: "priority",
      result: { channel: "in-app", status: "sent", providerMessageId: "notif-1" },
    });

    expect(result.attemptId).toBe("attempt-1");
    expect(deliveryCreate).toHaveBeenCalledWith({
      data: {
        channelType: "in-app",
        targetType: "work-item",
        targetId: "WI-1",
        providerMessageId: "notif-1",
        status: "sent",
        urgency: "priority",
        errorCode: null,
        errorMessage: null,
        sentAt: expect.any(Date),
      },
      select: { attemptId: true },
    });
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/delivery-evidence.test.ts
```

Expected: FAIL because `delivery-evidence.ts` does not exist.

- [x] **Step 3: Add the evidence helper**

Create `apps/web/lib/communications/delivery-evidence.ts`:

```ts
import { prisma } from "@dpf/db";

import type {
  CommunicationDeliveryResult,
  CommunicationTarget,
  CommunicationUrgency,
} from "./channel-types";

export async function recordDeliveryAttempt(input: {
  channel: string;
  target: CommunicationTarget;
  urgency: CommunicationUrgency;
  result: CommunicationDeliveryResult;
}): Promise<{ attemptId: string }> {
  const sentAt = input.result.status === "sent" ? new Date() : undefined;

  return prisma.communicationDeliveryAttempt.create({
    data: {
      channelType: input.channel,
      targetType: input.target.targetType,
      targetId: input.target.targetId,
      providerMessageId: input.result.providerMessageId,
      status: input.result.status,
      urgency: input.urgency,
      errorCode: input.result.errorCode ?? null,
      errorMessage: input.result.errorMessage ?? null,
      sentAt,
    },
    select: { attemptId: true },
  });
}
```

- [x] **Step 4: Wire dispatcher to evidence helper**

Modify `apps/web/lib/communications/dispatcher.ts` so the successful and failed result paths call `recordDeliveryAttempt()` before returning.

Use this helper in the file:

```ts
async function withEvidence(
  input: SendCommunicationInput,
  result: CommunicationDeliveryResult,
): Promise<CommunicationDeliveryResult> {
  await recordDeliveryAttempt({
    channel: input.channel,
    target: input.target,
    urgency: input.urgency,
    result,
  });
  return result;
}
```

Import `recordDeliveryAttempt` from `./delivery-evidence` and wrap every dispatcher result with `withEvidence(input, result)`.

- [x] **Step 5: Update dispatcher test mock**

In `apps/web/lib/communications/dispatcher.test.ts`, add this mock alongside `notificationCreate`:

```ts
const deliveryCreate = vi.fn();
```

Expose it through the `@dpf/db` mock:

```ts
communicationDeliveryAttempt: { create: deliveryCreate },
```

In `beforeEach`, add:

```ts
deliveryCreate.mockResolvedValue({ attemptId: "attempt-1" });
```

Add an assertion to the sent-result test:

```ts
expect(deliveryCreate).toHaveBeenCalledWith(expect.objectContaining({
  data: expect.objectContaining({
    channelType: "in-app",
    targetType: "work-item",
    targetId: "WI-1",
    status: "sent",
  }),
}));
```

- [x] **Step 6: Run focused tests**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/delivery-evidence.test.ts lib/communications/dispatcher.test.ts
```

Expected: PASS.

- [x] **Step 7: Commit**

Run:

```powershell
git add apps/web/lib/communications/delivery-evidence.ts apps/web/lib/communications/delivery-evidence.test.ts apps/web/lib/communications/dispatcher.ts apps/web/lib/communications/dispatcher.test.ts
git commit -s -m "feat(communications): record delivery evidence"
```

---

### Task 6: Add Channel Binding Validation Helpers

**Files:**
- Create: `apps/web/lib/communications/channel-bindings.ts`
- Create: `apps/web/lib/communications/channel-bindings.test.ts`

- [x] **Step 1: Write validation tests**

Create `apps/web/lib/communications/channel-bindings.test.ts`:

```ts
import { describe, expect, it } from "vitest";

import { parseChannelBindingInput } from "./channel-bindings";

describe("parseChannelBindingInput", () => {
  it("normalizes supported channels and requires external subject", () => {
    const parsed = parseChannelBindingInput({
      principalId: "principal-1",
      channelType: "Microsoft Teams",
      providerKey: "microsoft365",
      externalSubject: "user@contoso.com",
      allowedUrgencies: ["priority", "urgent"],
    });

    expect(parsed.ok).toBe(true);
    if (parsed.ok) {
      expect(parsed.value.channelType).toBe("teams");
      expect(parsed.value.allowedUrgencies).toEqual(["priority", "urgent"]);
    }
  });

  it("rejects unsupported channels", () => {
    const parsed = parseChannelBindingInput({
      principalId: "principal-1",
      channelType: "fax",
      providerKey: "fax",
      externalSubject: "555-1212",
      allowedUrgencies: ["urgent"],
    });

    expect(parsed).toEqual({ ok: false, error: "Unsupported channel: fax" });
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/channel-bindings.test.ts
```

Expected: FAIL because `channel-bindings.ts` does not exist.

- [x] **Step 3: Implement validation helper**

Create `apps/web/lib/communications/channel-bindings.ts`:

```ts
import {
  COMMUNICATION_URGENCIES,
  normalizeCommunicationChannel,
  type CommunicationChannel,
  type CommunicationUrgency,
} from "./channel-types";

export interface ChannelBindingInput {
  principalId: string;
  employeeProfileId?: string;
  channelType: string;
  providerKey: string;
  providerAccountId?: string;
  externalSubject: string;
  displayLabel?: string;
  allowedUrgencies: string[];
}

export interface ParsedChannelBinding {
  principalId: string;
  employeeProfileId?: string;
  channelType: CommunicationChannel;
  providerKey: string;
  providerAccountId?: string;
  externalSubject: string;
  displayLabel?: string;
  allowedUrgencies: CommunicationUrgency[];
}

export function parseChannelBindingInput(
  input: ChannelBindingInput,
): { ok: true; value: ParsedChannelBinding } | { ok: false; error: string } {
  const channelType = normalizeCommunicationChannel(input.channelType);
  if (!channelType) {
    return { ok: false, error: `Unsupported channel: ${input.channelType}` };
  }

  const externalSubject = input.externalSubject.trim();
  if (!externalSubject) {
    return { ok: false, error: "External subject is required." };
  }

  const allowedUrgencies = input.allowedUrgencies.filter(
    (urgency): urgency is CommunicationUrgency =>
      (COMMUNICATION_URGENCIES as readonly string[]).includes(urgency),
  );

  if (allowedUrgencies.length === 0) {
    return { ok: false, error: "At least one valid urgency is required." };
  }

  return {
    ok: true,
    value: {
      principalId: input.principalId,
      employeeProfileId: input.employeeProfileId,
      channelType,
      providerKey: input.providerKey.trim(),
      providerAccountId: input.providerAccountId?.trim() || undefined,
      externalSubject,
      displayLabel: input.displayLabel?.trim() || undefined,
      allowedUrgencies,
    },
  };
}
```

- [x] **Step 4: Run focused tests**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications/channel-bindings.test.ts
```

Expected: PASS.

- [x] **Step 5: Commit**

Run:

```powershell
git add apps/web/lib/communications/channel-bindings.ts apps/web/lib/communications/channel-bindings.test.ts
git commit -s -m "feat(communications): add channel binding validation"
```

---

### Task 7: Add Admin Communications Hub Shell

**Files:**
- Create: `apps/web/app/(shell)/platform/tools/integrations/communications/page.tsx`
- Create: `apps/web/app/(shell)/platform/tools/integrations/communications/page.test.tsx`
- Modify: `apps/web/app/(shell)/platform/tools/integrations/page.tsx`
- Modify: `apps/web/app/(shell)/platform/tools/integrations/page.test.tsx`

- [x] **Step 1: Write the communications page render test**

Create `apps/web/app/(shell)/platform/tools/integrations/communications/page.test.tsx`:

```tsx
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import CommunicationsPage from "./page";

describe("CommunicationsPage", () => {
  it("renders the provider readiness groups", async () => {
    const html = renderToStaticMarkup(await CommunicationsPage());

    expect(html).toContain("Communications");
    expect(html).toContain("DPF-owned baseline");
    expect(html).toContain("Enterprise real-time");
    expect(html).toContain("Field and local messaging");
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run "app/(shell)/platform/tools/integrations/communications/page.test.tsx"
```

Expected: FAIL because the page does not exist.

- [x] **Step 3: Add the page**

Create `apps/web/app/(shell)/platform/tools/integrations/communications/page.tsx`:

```tsx
const groups = [
  {
    title: "DPF-owned baseline",
    description: "In-app notifications, mobile push, and email fallback are the governed default path.",
    items: ["In-app", "Push", "Email"],
  },
  {
    title: "Enterprise real-time",
    description: "Teams and Slack deliver quick action requests while DPF keeps the work record.",
    items: ["Microsoft Teams", "Slack"],
  },
  {
    title: "Field and local messaging",
    description: "WhatsApp and later Telegram support field workers and local operating models.",
    items: ["WhatsApp Business", "Telegram"],
  },
];

export default async function CommunicationsPage() {
  return (
    <main className="space-y-6">
      <header>
        <span className="text-xs font-semibold uppercase tracking-wide text-[var(--dpf-muted)]">
          Platform Tools
        </span>
        <h1 className="mt-1 text-2xl font-bold text-[var(--dpf-text)]">Communications</h1>
        <p className="mt-2 max-w-3xl text-sm text-[var(--dpf-muted)]">
          Governed employee reachability, provider readiness, and channel evidence. DPF remains the system of record; external tools are delivery adapters.
        </p>
      </header>

      <section className="grid gap-4 lg:grid-cols-3">
        {groups.map((group) => (
          <article
            key={group.title}
            className="rounded border border-[var(--dpf-border)] bg-[var(--dpf-surface-1)] p-4"
          >
            <h2 className="text-base font-semibold text-[var(--dpf-text)]">{group.title}</h2>
            <p className="mt-2 text-sm text-[var(--dpf-muted)]">{group.description}</p>
            <div className="mt-4 flex flex-wrap gap-2">
              {group.items.map((item) => (
                <span
                  key={item}
                  className="rounded border border-[var(--dpf-border)] bg-[var(--dpf-surface-2)] px-2 py-1 text-xs text-[var(--dpf-text)]"
                >
                  {item}
                </span>
              ))}
            </div>
          </article>
        ))}
      </section>
    </main>
  );
}
```

- [x] **Step 4: Add the communications card to the integrations landing page**

Modify `apps/web/app/(shell)/platform/tools/integrations/page.tsx` near the Microsoft 365 Communications card:

```tsx
<PlatformSummaryCard
  title="Communications"
  description="Governed employee reachability, delivery evidence, and channel-adapter readiness across in-app, push, email, Teams, Slack, WhatsApp, and later Telegram."
  href="/platform/tools/integrations/communications"
  accent="var(--dpf-info)"
  metrics={[
    { label: "Category", value: "Communications" },
    { label: "Model", value: "Fabric" },
  ]}
/>
```

- [x] **Step 5: Update landing page test**

In `apps/web/app/(shell)/platform/tools/integrations/page.test.tsx`, add expectations:

```ts
expect(html).toContain("Communications");
expect(html).toContain("/platform/tools/integrations/communications");
```

- [x] **Step 6: Run focused tests**

Run:

```powershell
pnpm --filter web exec vitest run "app/(shell)/platform/tools/integrations/communications/page.test.tsx" "app/(shell)/platform/tools/integrations/page.test.tsx"
```

Expected: PASS.

- [x] **Step 7: Commit**

Run:

```powershell
git add "apps/web/app/(shell)/platform/tools/integrations/communications/page.tsx" "apps/web/app/(shell)/platform/tools/integrations/communications/page.test.tsx" "apps/web/app/(shell)/platform/tools/integrations/page.tsx" "apps/web/app/(shell)/platform/tools/integrations/page.test.tsx"
git commit -s -m "feat(communications): add communications integration hub"
```

---

### Task 8: Add Employee Reachability Panel

**Files:**
- Create: `apps/web/components/employee/EmployeeReachabilityPanel.tsx`
- Create: `apps/web/components/employee/EmployeeReachabilityPanel.test.tsx`

- [x] **Step 1: Write panel render tests**

Create `apps/web/components/employee/EmployeeReachabilityPanel.test.tsx`:

```tsx
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { EmployeeReachabilityPanel } from "./EmployeeReachabilityPanel";

describe("EmployeeReachabilityPanel", () => {
  it("renders verified and pending channel bindings", () => {
    const html = renderToStaticMarkup(
      <EmployeeReachabilityPanel
        bindings={[
          { channel: "teams", label: "Microsoft Teams", status: "verified", lastTestedAt: "2026-05-15T10:00:00.000Z" },
          { channel: "whatsapp", label: "WhatsApp Business", status: "pending", lastTestedAt: null },
        ]}
      />,
    );

    expect(html).toContain("Reachability");
    expect(html).toContain("Microsoft Teams");
    expect(html).toContain("verified");
    expect(html).toContain("WhatsApp Business");
    expect(html).toContain("pending");
  });

  it("renders an empty state", () => {
    const html = renderToStaticMarkup(<EmployeeReachabilityPanel bindings={[]} />);
    expect(html).toContain("No employee channels are configured yet.");
  });
});
```

- [x] **Step 2: Run the failing test**

Run:

```powershell
pnpm --filter web exec vitest run components/employee/EmployeeReachabilityPanel.test.tsx
```

Expected: FAIL because the panel does not exist.

- [x] **Step 3: Add the panel**

Create `apps/web/components/employee/EmployeeReachabilityPanel.tsx`:

```tsx
type ReachabilityBinding = {
  channel: string;
  label: string;
  status: "pending" | "verified" | "failed" | "disabled";
  lastTestedAt: string | null;
};

export function EmployeeReachabilityPanel({ bindings }: { bindings: ReachabilityBinding[] }) {
  return (
    <section className="rounded border border-[var(--dpf-border)] bg-[var(--dpf-surface-1)] p-4">
      <header className="flex items-start justify-between gap-3">
        <div>
          <h2 className="text-base font-semibold text-[var(--dpf-text)]">Reachability</h2>
          <p className="mt-1 text-sm text-[var(--dpf-muted)]">
            Verified employee channels for governed notifications, approvals, and follow-ups.
          </p>
        </div>
      </header>

      {bindings.length === 0 ? (
        <p className="mt-4 rounded border border-[var(--dpf-border)] bg-[var(--dpf-surface-2)] p-3 text-sm text-[var(--dpf-muted)]">
          No employee channels are configured yet.
        </p>
      ) : (
        <ul className="mt-4 divide-y divide-[var(--dpf-border)] border-y border-[var(--dpf-border)]">
          {bindings.map((binding) => (
            <li key={`${binding.channel}:${binding.label}`} className="flex items-center justify-between gap-4 py-3">
              <div>
                <p className="text-sm font-medium text-[var(--dpf-text)]">{binding.label}</p>
                <p className="text-xs text-[var(--dpf-muted)]">
                  {binding.lastTestedAt ? `Last tested ${new Date(binding.lastTestedAt).toLocaleString()}` : "Not tested yet"}
                </p>
              </div>
              <span className="rounded border border-[var(--dpf-border)] bg-[var(--dpf-surface-2)] px-2 py-1 text-xs text-[var(--dpf-text)]">
                {binding.status}
              </span>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
}
```

- [x] **Step 4: Run focused test**

Run:

```powershell
pnpm --filter web exec vitest run components/employee/EmployeeReachabilityPanel.test.tsx
```

Expected: PASS.

- [x] **Step 5: Commit**

Run:

```powershell
git add apps/web/components/employee/EmployeeReachabilityPanel.tsx apps/web/components/employee/EmployeeReachabilityPanel.test.tsx
git commit -s -m "feat(employee): add reachability panel"
```

---

### Task 9: Verification Gate

**Files:**
- No new files.

- [x] **Step 1: Run focused communications tests**

Run:

```powershell
pnpm --filter web exec vitest run lib/communications components/employee/EmployeeReachabilityPanel.test.tsx "app/(shell)/platform/tools/integrations/communications/page.test.tsx" "app/(shell)/platform/tools/integrations/page.test.tsx"
```

Expected: PASS.

- [x] **Step 2: Run queue compatibility test path**

Run:

```powershell
pnpm --filter web exec vitest run lib/queue
```

Expected: PASS, or only pre-existing unrelated failures. If failures occur in files touched by this plan, fix them before continuing.

- [x] **Step 3: Run typecheck**

Run:

```powershell
pnpm --filter web typecheck
```

Expected: PASS.

- [x] **Step 4: Run production build**

Run:

```powershell
pnpm --filter web build
```

Expected: PASS.

- [x] **Step 5: UX verification**

Run the Docker-served app or the current production-path local runtime. Log in as `admin@dpf.local` using `ADMIN_PASSWORD` from the repo root `.env`, then verify:

- `/platform/tools/integrations` shows the Communications card.
- `/platform/tools/integrations/communications` renders the communications hub.
- Existing Microsoft 365 Communications and WhatsApp Business integration pages still render.

- [x] **Step 6: Commit verification fixes**

If verification required fixes, commit them:

```powershell
git add <fixed-files>
git commit -s -m "fix(communications): address verification findings"
```

---

### Task 10: Backlog Follow-up

> **Status note:** No dedicated `EP-COMMS-FABRIC` epic was filed during the Slice 0/1 ship. The Slice 0/1 work landed via PRs #619 + #628 against existing infrastructure surface area. Step boxes below are marked best-effort; before planning Slice 2 (Teams/Slack) or Slice 3 (WhatsApp), re-run Step 1 to confirm no overlapping epic exists, then run Step 2 to file the canonical epic. This is the recommended starting point for the next slice plan.

**Files:**
- No code files.

- [x] **Step 1: Search live backlog before creating new items**

Use DPF MCP tools:

```text
list_epics status=open hasOpenItems=true limit=100
list_backlog_items status=open limit=100
search_specs_and_plans query="employee communication fabric channel adapter notification"
```

Expected: Confirm whether a dedicated employee communication epic exists. If not, use existing `BI-INT-8D4F72` for benchmark work and create a new epic only after overlap review.

- [x] **Step 2: Proposed epic if no overlap exists**

Create:

```text
Employee Communication Fabric: Governed Employee Reachability, Channel Adapters, and Work Queue Interaction
```

Suggested items:

```text
1. Refactor queue notifications behind the communication adapter interface.
2. Add communication delivery evidence and channel binding models.
3. Add DPF-owned in-app, push, and email baseline dispatch.
4. Add admin communications hub and employee reachability surface.
5. Benchmark Teams, Slack, WhatsApp, Telegram, and webhook provider fit.
6. Add Teams and Slack outbound action adapters.
7. Convert WhatsApp Secretary Gateway into a channel-specific implementation plan.
```

- [x] **Step 3: Record execution evidence**

Use DPF MCP `record_execution_evidence` if available for the spec, plan, focused tests, typecheck, build, and UX verification outcome.

---

## Final Completion Checklist

- [x] All tasks committed with DCO sign-off.
- [x] Focused Vitest tests pass.
- [x] `pnpm --filter web typecheck` passes.
- [x] `pnpm --filter web build` passes.
- [x] Prisma migration applies cleanly.
- [x] UX path verified against the configured local app.
- [x] Existing WhatsApp Business and Microsoft 365 pages still render.
- [x] Backlog item or epic alignment recorded through MCP.
- [x] Branch pushed and PR opened according to DPF branch policy.
