# E1.3 Slack Append-Only Ingestion Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Stream Slack workspace events into hippo as `kind='raw'` rows with full provenance, idempotency, cursor-based backfill, source-deletion sync, permission mirroring, and a dead-letter queue. The first end-to-end ingestion connector built on the A3+A5+A1 stack.

**Architecture:** The Slack adapter is a thin module under `src/connectors/slack/` that translates Slack events (webhooks + Web API backfill) into `api.remember()` calls with `kind='raw'`, `source='slack'`, `artifact_ref='slack://team/channel/ts'`, and a `scope` derived from channel privacy. A new HTTP route `POST /v1/connectors/slack/events` verifies Slack's HMAC signature, deduplicates against a `slack_event_log` table, and routes deletions through `archiveRawMemory()` to honour the append-only invariant. Backfill paginates the conversations.history API with a per-channel cursor stored in `slack_cursors`; rate-limit (429) responses pause and resume. Malformed events land in `slack_dlq` for offline review.

**Tech Stack:** TypeScript, node:sqlite, node:http (existing A1 server), Node's built-in `crypto` (HMAC-SHA256 timing-safe), real DB tests via vitest (no mocks), `WebClient`-shaped fetch interface so the test harness can inject canned responses without a network.

**Non-goals (V1):**
- No write-back to Slack (zero source-system writes). Read-only adapter.
- No worker queue. Webhook handler is synchronous; Slack's 3s budget is plenty for an idempotency check + one `INSERT`.
- No browser-automation fallback. Slack's Events API + Web API are both first-class.
- No rich-text rendering. The connector stores the message text verbatim and lets `recall` rank it.

**ROADMAP success criterion:** 1000-event smoke test with no source-system writes; recall surfaces incident context faster than transcript replay on 10 staged scenarios. Both must pass before tagging this work shipped.

---

## Background reading (do this BEFORE Task 1)

Read these in order. Each links into a downstream task — skipping = drift.

- `ROADMAP-RESEARCH.md` lines 240-249, 123-129 — the E1.3 spec and the per-connector test invariants this plan must hit.
- `src/api.ts` (full file, ~457 lines) — domain layer. Tasks 7-11 call into `remember()`, `archiveRaw()`, and `auditList()` exactly as written. Don't reinvent.
- `src/raw-archive.ts` — the only legitimate way to delete `kind='raw'` rows. Source-deletion (Task 11) routes through here.
- `src/db.ts` migrations 14-16 — A3 envelope (kind/scope/owner/artifact_ref) and the append-only trigger on `kind='raw'`. Task 1 adds migration 17 alongside.
- `src/server.ts` lines 312-595 — the route table and `buildContextWithAuth` helper. Task 12 inserts `/v1/connectors/slack/events` here.
- `src/importers.ts` lines 86-92 — explicit comment marking E1.x connectors as the boundary that must use `kind='raw'`. Task 7 honours that.
- Slack docs: <https://api.slack.com/events-api> (signed envelope shape), <https://api.slack.com/authentication/verifying-requests-from-slack> (signature algorithm), <https://api.slack.com/methods/conversations.history> (backfill pagination, `cursor` + `response_metadata.next_cursor`).

---

## Task 1: Schema v17 — slack_event_log, slack_cursors, slack_dlq

**Why:** Three durable tables: idempotency (one row per Slack event_id), backfill cursors (per-channel resume point), and a DLQ for events that can't be parsed. All three live in the hippo SQLite db so a single backup captures connector state.

**Files:**
- Modify: `src/db.ts` — append migration block to the migrations array (the version field is the next integer after 16).
- Create: `tests/slack-schema.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-schema.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore } from '../src/store.js';
import { openHippoDb, closeHippoDb } from '../src/db.js';

describe('schema v17 — slack ingestion tables', () => {
  let root: string;
  beforeEach(() => {
    root = mkdtempSync(join(tmpdir(), 'hippo-slack-schema-'));
    initStore(root);
  });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('creates slack_event_log with PRIMARY KEY on event_id', () => {
    const db = openHippoDb(root);
    try {
      const cols = db.prepare(`PRAGMA table_info(slack_event_log)`).all() as Array<Record<string, unknown>>;
      const eventIdCol = cols.find((c) => c.name === 'event_id');
      expect(eventIdCol).toBeDefined();
      expect(eventIdCol!.pk).toBe(1);
      // Re-insert same event_id must throw (PK uniqueness).
      db.prepare(`INSERT INTO slack_event_log (event_id, ingested_at, memory_id) VALUES (?, ?, ?)`).run('E1', '2026-04-29T00:00:00Z', 'm1');
      expect(() =>
        db.prepare(`INSERT INTO slack_event_log (event_id, ingested_at, memory_id) VALUES (?, ?, ?)`).run('E1', '2026-04-29T00:00:01Z', 'm2'),
      ).toThrow();
    } finally {
      closeHippoDb(db);
    }
  });

  it('creates slack_cursors keyed on (tenant_id, channel_id)', () => {
    const db = openHippoDb(root);
    try {
      db.prepare(`INSERT INTO slack_cursors (tenant_id, channel_id, latest_ts, updated_at) VALUES (?, ?, ?, ?)`).run('default', 'C1', '1700000000.000000', '2026-04-29T00:00:00Z');
      // Same composite key collides; INSERT OR REPLACE supported.
      expect(() =>
        db.prepare(`INSERT INTO slack_cursors (tenant_id, channel_id, latest_ts, updated_at) VALUES (?, ?, ?, ?)`).run('default', 'C1', '1700000001.000000', '2026-04-29T00:00:01Z'),
      ).toThrow();
    } finally {
      closeHippoDb(db);
    }
  });

  it('creates slack_dlq with autoincrementing id and received_at', () => {
    const db = openHippoDb(root);
    try {
      db.prepare(`INSERT INTO slack_dlq (tenant_id, raw_payload, error, received_at) VALUES (?, ?, ?, ?)`).run('default', '{}', 'parse error', '2026-04-29T00:00:00Z');
      const row = db.prepare(`SELECT id FROM slack_dlq LIMIT 1`).get() as { id: number };
      expect(row.id).toBe(1);
    } finally {
      closeHippoDb(db);
    }
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npx vitest run tests/slack-schema.test.ts`
Expected: FAIL with `no such table: slack_event_log`.

**Step 3: Implement migration v17**

Append to the migrations array in `src/db.ts` (after the v16 block):

```ts
{
  version: 17,
  up: (db) => {
    // E1.3 Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
    // See docs/plans/2026-04-29-e1.3-slack-ingestion.md.
    db.exec(`
      CREATE TABLE IF NOT EXISTS slack_event_log (
        event_id TEXT PRIMARY KEY,
        ingested_at TEXT NOT NULL,
        memory_id TEXT
      )
    `);
    db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_event_log_memory ON slack_event_log(memory_id) WHERE memory_id IS NOT NULL`);
    db.exec(`
      CREATE TABLE IF NOT EXISTS slack_cursors (
        tenant_id TEXT NOT NULL,
        channel_id TEXT NOT NULL,
        latest_ts TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        PRIMARY KEY (tenant_id, channel_id)
      )
    `);
    db.exec(`
      CREATE TABLE IF NOT EXISTS slack_dlq (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        tenant_id TEXT NOT NULL,
        raw_payload TEXT NOT NULL,
        error TEXT NOT NULL,
        received_at TEXT NOT NULL,
        retried_at TEXT
      )
    `);
    db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_dlq_tenant_received ON slack_dlq(tenant_id, received_at)`);
    // Multi-tenant routing seam (review patch #6). Empty by default — single-
    // tenant deployments resolve via HIPPO_TENANT fallback. Multi-workspace
    // deployments populate this table to map team_id → tenant_id.
    db.exec(`
      CREATE TABLE IF NOT EXISTS slack_workspaces (
        team_id TEXT PRIMARY KEY,
        tenant_id TEXT NOT NULL,
        added_at TEXT NOT NULL
      )
    `);
  },
},
```

Also add a fourth test case to `tests/slack-schema.test.ts`:

```ts
it('creates slack_workspaces with team_id PK', () => {
  const db = openHippoDb(root);
  try {
    db.prepare(`INSERT INTO slack_workspaces (team_id, tenant_id, added_at) VALUES (?, ?, ?)`).run('T_ACME', 'acme', '2026-04-29T00:00:00Z');
    expect(() =>
      db.prepare(`INSERT INTO slack_workspaces (team_id, tenant_id, added_at) VALUES (?, ?, ?)`).run('T_ACME', 'other', '2026-04-29T00:00:01Z'),
    ).toThrow();
  } finally {
    closeHippoDb(db);
  }
});
```

**Step 4: Run test to verify it passes**

Run: `npx vitest run tests/slack-schema.test.ts`
Expected: 3 passed.

**Step 5: Commit**

```bash
git add src/db.ts tests/slack-schema.test.ts
git commit -m "feat(slack): schema v17 for ingestion idempotency, cursors, DLQ"
```

---

## Task 2: Slack types

**Why:** A small typed surface for the Slack event shapes the adapter consumes. Centralised so future changes to Slack's payload shape are one file, not eight.

**Files:**
- Create: `src/connectors/slack/types.ts`
- Create: `tests/slack-types.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-types.test.ts
import { describe, it, expect } from 'vitest';
import {
  isSlackEventEnvelope,
  isSlackMessageEvent,
  type SlackEventEnvelope,
  type SlackMessageEvent,
} from '../src/connectors/slack/types.js';

describe('slack types', () => {
  it('accepts a well-formed event envelope', () => {
    const envelope: SlackEventEnvelope = {
      type: 'event_callback',
      team_id: 'T1',
      event_id: 'Ev1',
      event_time: 1700000000,
      event: { type: 'message', channel: 'C1', user: 'U1', text: 'hi', ts: '1700000000.000100' },
    };
    expect(isSlackEventEnvelope(envelope)).toBe(true);
  });

  it('accepts a message_deleted subtype', () => {
    const evt = {
      type: 'message',
      subtype: 'message_deleted',
      channel: 'C1',
      deleted_ts: '1700000000.000100',
      ts: '1700000001.000200',
    };
    expect(isSlackMessageEvent(evt)).toBe(true);
    expect((evt as SlackMessageEvent).subtype).toBe('message_deleted');
  });

  it('rejects malformed envelopes', () => {
    expect(isSlackEventEnvelope({ type: 'event_callback' })).toBe(false);
    expect(isSlackEventEnvelope(null)).toBe(false);
    expect(isSlackEventEnvelope({ event_id: 'Ev1', event: {} })).toBe(false);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-types.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/types.ts
/**
 * Slack Events API envelope shapes used by the ingestion connector.
 * Spec: https://api.slack.com/events-api
 */

export interface SlackUrlVerification {
  type: 'url_verification';
  challenge: string;
  token?: string;
}

export interface SlackMessageEvent {
  type: 'message';
  subtype?: 'message_deleted' | 'message_changed' | 'channel_join' | string;
  channel: string;
  channel_type?: 'channel' | 'group' | 'im' | 'mpim';
  user?: string;
  text?: string;
  ts: string;
  thread_ts?: string;
  /** Present on subtype='message_deleted'. */
  deleted_ts?: string;
}

export interface SlackEventEnvelope {
  type: 'event_callback';
  team_id: string;
  event_id: string;
  event_time: number;
  event: SlackMessageEvent | { type: string; [k: string]: unknown };
}

export type SlackInbound = SlackEventEnvelope | SlackUrlVerification;

export function isSlackEventEnvelope(x: unknown): x is SlackEventEnvelope {
  if (!x || typeof x !== 'object') return false;
  const o = x as Record<string, unknown>;
  return (
    o.type === 'event_callback' &&
    typeof o.team_id === 'string' &&
    typeof o.event_id === 'string' &&
    typeof o.event_time === 'number' &&
    !!o.event &&
    typeof o.event === 'object' &&
    typeof (o.event as Record<string, unknown>).type === 'string'
  );
}

export function isSlackMessageEvent(x: unknown): x is SlackMessageEvent {
  if (!x || typeof x !== 'object') return false;
  const o = x as Record<string, unknown>;
  return o.type === 'message' && typeof o.channel === 'string' && typeof o.ts === 'string';
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-types.test.ts`
Expected: 3 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/types.ts tests/slack-types.test.ts
git commit -m "feat(slack): event envelope and message types"
```

---

## Task 3: HMAC signature verification

**Why:** Every Slack webhook arrives with `X-Slack-Signature` and `X-Slack-Request-Timestamp`. The signature is `v0=<hex>` where `<hex>` is `HMAC-SHA256(signingSecret, "v0:<ts>:<rawBody>")`. We verify it with `crypto.timingSafeEqual` and reject requests older than 5 minutes (replay defence). Without this, anyone who guesses the URL can write into our memory store.

**Files:**
- Create: `src/connectors/slack/signature.ts`
- Create: `tests/slack-signature.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-signature.test.ts
import { describe, it, expect } from 'vitest';
import { createHmac } from 'crypto';
import { verifySlackSignature } from '../src/connectors/slack/signature.js';

const SECRET = 'shhh';

function sign(ts: string, body: string): string {
  const mac = createHmac('sha256', SECRET).update(`v0:${ts}:${body}`).digest('hex');
  return `v0=${mac}`;
}

describe('verifySlackSignature', () => {
  const now = Math.floor(Date.now() / 1000);

  it('accepts a valid signature within the 5min window', () => {
    const body = '{"hello":"world"}';
    const ts = String(now);
    const sig = sign(ts, body);
    expect(verifySlackSignature({ rawBody: body, timestamp: ts, signature: sig, signingSecret: SECRET, now })).toBe(true);
  });

  it('rejects a tampered body', () => {
    const ts = String(now);
    const sig = sign(ts, '{"hello":"world"}');
    expect(verifySlackSignature({ rawBody: '{"hello":"evil"}', timestamp: ts, signature: sig, signingSecret: SECRET, now })).toBe(false);
  });

  it('rejects a stale timestamp (>5min skew)', () => {
    const stale = String(now - 6 * 60);
    const body = '{}';
    const sig = sign(stale, body);
    expect(verifySlackSignature({ rawBody: body, timestamp: stale, signature: sig, signingSecret: SECRET, now })).toBe(false);
  });

  it('rejects a malformed signature header', () => {
    expect(verifySlackSignature({ rawBody: '{}', timestamp: String(now), signature: 'garbage', signingSecret: SECRET, now })).toBe(false);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-signature.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/signature.ts
import { createHmac, timingSafeEqual } from 'crypto';

export interface VerifyOpts {
  rawBody: string;
  timestamp: string;
  signature: string;
  signingSecret: string;
  /** Current unix seconds. Injectable for tests. */
  now?: number;
  /** Max allowed skew in seconds. Default 5 minutes (Slack's recommendation). */
  skewSeconds?: number;
}

export function verifySlackSignature(opts: VerifyOpts): boolean {
  const { rawBody, timestamp, signature, signingSecret } = opts;
  const now = opts.now ?? Math.floor(Date.now() / 1000);
  const skew = opts.skewSeconds ?? 5 * 60;
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(now - ts) > skew) return false;
  if (!signature.startsWith('v0=')) return false;
  const expected = `v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`;
  const a = Buffer.from(signature, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-signature.test.ts`
Expected: 4 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/signature.ts tests/slack-signature.test.ts
git commit -m "feat(slack): HMAC-SHA256 signature verification with replay window"
```

---

## Task 4: Idempotency log helpers

**Why:** Slack retries unacknowledged webhooks up to 3 times within 1 minute. The same `event_id` must not produce two memories. `slack_event_log` records every event we've seen; on replay we early-return without re-writing.

**Files:**
- Create: `src/connectors/slack/idempotency.ts`
- Create: `tests/slack-idempotency.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-idempotency.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore } from '../src/store.js';
import { openHippoDb, closeHippoDb } from '../src/db.js';
import { hasSeenEvent, markEventSeen, lookupMemoryByEvent } from '../src/connectors/slack/idempotency.js';

describe('slack idempotency', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-idem-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('hasSeenEvent returns false then true after marking', () => {
    const db = openHippoDb(root);
    try {
      expect(hasSeenEvent(db, 'Ev1')).toBe(false);
      markEventSeen(db, 'Ev1', 'm123');
      expect(hasSeenEvent(db, 'Ev1')).toBe(true);
      expect(lookupMemoryByEvent(db, 'Ev1')).toBe('m123');
    } finally { closeHippoDb(db); }
  });

  it('markEventSeen is a no-op on duplicate (INSERT OR IGNORE)', () => {
    const db = openHippoDb(root);
    try {
      markEventSeen(db, 'Ev1', 'm123');
      // Second call must not throw and must not overwrite the original memory_id.
      markEventSeen(db, 'Ev1', 'mDIFFERENT');
      expect(lookupMemoryByEvent(db, 'Ev1')).toBe('m123');
    } finally { closeHippoDb(db); }
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-idempotency.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/idempotency.ts
import type { DatabaseSyncLike } from '../../db.js';

export function hasSeenEvent(db: DatabaseSyncLike, eventId: string): boolean {
  const row = db.prepare(`SELECT 1 FROM slack_event_log WHERE event_id = ?`).get(eventId);
  return !!row;
}

export function markEventSeen(db: DatabaseSyncLike, eventId: string, memoryId: string | null): void {
  db.prepare(`INSERT OR IGNORE INTO slack_event_log (event_id, ingested_at, memory_id) VALUES (?, ?, ?)`)
    .run(eventId, new Date().toISOString(), memoryId);
}

export function lookupMemoryByEvent(db: DatabaseSyncLike, eventId: string): string | null {
  const row = db.prepare(`SELECT memory_id FROM slack_event_log WHERE event_id = ?`).get(eventId) as
    | { memory_id: string | null }
    | undefined;
  return row?.memory_id ?? null;
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-idempotency.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/idempotency.ts tests/slack-idempotency.test.ts
git commit -m "feat(slack): idempotency log helpers"
```

---

## Task 5: Dead-letter queue helpers

**Why:** A webhook with a malformed body (Slack-side bug, future API change, signature passes but JSON is wrong) must not crash the route or get retried forever. Park the raw payload + error in `slack_dlq`, ack 200, move on. Operators inspect via CLI later.

**Files:**
- Create: `src/connectors/slack/dlq.ts`
- Create: `tests/slack-dlq.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-dlq.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore } from '../src/store.js';
import { openHippoDb, closeHippoDb } from '../src/db.js';
import { writeToDlq, listDlq, markDlqRetried } from '../src/connectors/slack/dlq.js';

describe('slack DLQ', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-dlq-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('captures raw payload + error and lists oldest-first', () => {
    const db = openHippoDb(root);
    try {
      writeToDlq(db, { tenantId: 'default', rawPayload: '{"a":1}', error: 'parse fail' });
      writeToDlq(db, { tenantId: 'default', rawPayload: '{"b":2}', error: 'unknown event type' });
      const items = listDlq(db, { tenantId: 'default' });
      expect(items).toHaveLength(2);
      expect(items[0].error).toBe('parse fail');
      expect(items[0].retriedAt).toBeNull();
    } finally { closeHippoDb(db); }
  });

  it('markDlqRetried sets retried_at', () => {
    const db = openHippoDb(root);
    try {
      writeToDlq(db, { tenantId: 'default', rawPayload: '{}', error: 'boom' });
      const [item] = listDlq(db, { tenantId: 'default' });
      markDlqRetried(db, item.id);
      const [after] = listDlq(db, { tenantId: 'default' });
      expect(after.retriedAt).not.toBeNull();
    } finally { closeHippoDb(db); }
  });

  it('listDlq scopes by tenantId', () => {
    const db = openHippoDb(root);
    try {
      writeToDlq(db, { tenantId: 'default', rawPayload: '{}', error: 'a' });
      writeToDlq(db, { tenantId: 'acme', rawPayload: '{}', error: 'b' });
      expect(listDlq(db, { tenantId: 'default' })).toHaveLength(1);
      expect(listDlq(db, { tenantId: 'acme' })).toHaveLength(1);
    } finally { closeHippoDb(db); }
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-dlq.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/dlq.ts
import type { DatabaseSyncLike } from '../../db.js';

export interface DlqItem {
  id: number;
  tenantId: string;
  rawPayload: string;
  error: string;
  receivedAt: string;
  retriedAt: string | null;
}

export interface WriteDlqOpts {
  tenantId: string;
  rawPayload: string;
  error: string;
}

export function writeToDlq(db: DatabaseSyncLike, opts: WriteDlqOpts): number {
  const result = db
    .prepare(`INSERT INTO slack_dlq (tenant_id, raw_payload, error, received_at) VALUES (?, ?, ?, ?)`)
    .run(opts.tenantId, opts.rawPayload, opts.error, new Date().toISOString());
  return Number(result.lastInsertRowid);
}

export function listDlq(db: DatabaseSyncLike, opts: { tenantId: string; limit?: number }): DlqItem[] {
  const rows = db
    .prepare(`SELECT id, tenant_id, raw_payload, error, received_at, retried_at FROM slack_dlq WHERE tenant_id = ? ORDER BY received_at ASC LIMIT ?`)
    .all(opts.tenantId, opts.limit ?? 100) as Array<Record<string, unknown>>;
  return rows.map((r) => ({
    id: Number(r.id),
    tenantId: String(r.tenant_id),
    rawPayload: String(r.raw_payload),
    error: String(r.error),
    receivedAt: String(r.received_at),
    retriedAt: r.retried_at == null ? null : String(r.retried_at),
  }));
}

export function markDlqRetried(db: DatabaseSyncLike, id: number): void {
  db.prepare(`UPDATE slack_dlq SET retried_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-dlq.test.ts`
Expected: 3 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/dlq.ts tests/slack-dlq.test.ts
git commit -m "feat(slack): dead-letter queue helpers"
```

---

## Task 6: Channel privacy → scope mapping

**Why:** Permission mirroring is the second-most-important invariant after idempotency. Public-channel messages get `scope='slack:public:<Cxxx>'`; private channels and DMs get `scope='slack:private:<Cxxx>'`. Recall scope filtering (already wired in `loadSearchEntries` per-row scope column) then keeps private content out of public-scoped queries.

**Files:**
- Create: `src/connectors/slack/scope.ts`
- Create: `tests/slack-scope.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-scope.test.ts
import { describe, it, expect } from 'vitest';
import { scopeFromChannel } from '../src/connectors/slack/scope.js';

describe('scopeFromChannel', () => {
  it('public channel → slack:public:<id>', () => {
    expect(scopeFromChannel({ id: 'C123', is_private: false, is_im: false, is_mpim: false })).toBe('slack:public:C123');
  });
  it('private channel → slack:private:<id>', () => {
    expect(scopeFromChannel({ id: 'C456', is_private: true })).toBe('slack:private:C456');
  });
  it('DM (im) → slack:private:<id>', () => {
    expect(scopeFromChannel({ id: 'D1', is_im: true })).toBe('slack:private:D1');
  });
  it('group DM (mpim) → slack:private:<id>', () => {
    expect(scopeFromChannel({ id: 'G1', is_mpim: true })).toBe('slack:private:G1');
  });
  it('unknown → defaults to private (fail closed)', () => {
    expect(scopeFromChannel({ id: 'X1' })).toBe('slack:private:X1');
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-scope.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/scope.ts

export interface ChannelMeta {
  id: string;
  is_private?: boolean;
  is_im?: boolean;
  is_mpim?: boolean;
}

/**
 * Map a Slack channel into a hippo scope string.
 *
 * Default to private when privacy is undetermined. The cost of leaking a
 * public channel into private scope (recall returns nothing) is far smaller
 * than the cost of leaking a private channel into public scope (data exposed
 * to a tenant that should not see it).
 */
export function scopeFromChannel(ch: ChannelMeta): string {
  const isPublic = ch.is_private === false && !ch.is_im && !ch.is_mpim;
  return isPublic ? `slack:public:${ch.id}` : `slack:private:${ch.id}`;
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-scope.test.ts`
Expected: 5 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/scope.ts tests/slack-scope.test.ts
git commit -m "feat(slack): channel privacy to scope mapping (fail-closed)"
```

---

## Task 7: Message → MemoryEntry transform

**Why:** The shape conversion from a Slack `SlackMessageEvent` to the `RememberOpts` consumed by `api.remember()`. Crucial that this stamps `kind='raw'`, `source='slack'`, `artifact_ref='slack://<team>/<channel>/<ts>'`, and the scope from Task 6. The artifact_ref doubles as the lookup key for source-deletion (Task 11).

**Files:**
- Create: `src/connectors/slack/transform.ts`
- Create: `tests/slack-transform.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-transform.test.ts
import { describe, it, expect } from 'vitest';
import { messageToRememberOpts } from '../src/connectors/slack/transform.js';

describe('messageToRememberOpts', () => {
  it('produces kind=raw + slack:// artifact_ref + scope from channel privacy', () => {
    const opts = messageToRememberOpts({
      teamId: 'T1',
      channel: { id: 'C1', is_private: false },
      message: { type: 'message', channel: 'C1', user: 'U1', text: 'incident at 3pm', ts: '1700000000.000100' },
    });
    expect(opts.kind).toBe('raw');
    expect(opts.scope).toBe('slack:public:C1');
    expect(opts.artifactRef).toBe('slack://T1/C1/1700000000.000100');
    expect(opts.content).toBe('incident at 3pm');
    expect(opts.tags).toEqual(expect.arrayContaining(['source:slack', 'channel:C1']));
  });

  it('skips empty bodies (returns null)', () => {
    const opts = messageToRememberOpts({
      teamId: 'T1',
      channel: { id: 'C1' },
      message: { type: 'message', channel: 'C1', ts: '1700000000.000100' },
    });
    expect(opts).toBeNull();
  });

  it('private channel → slack:private:<id>', () => {
    const opts = messageToRememberOpts({
      teamId: 'T1',
      channel: { id: 'C2', is_private: true },
      message: { type: 'message', channel: 'C2', user: 'U1', text: 'secret', ts: '1700000000.000100' },
    });
    expect(opts!.scope).toBe('slack:private:C2');
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-transform.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/transform.ts
import type { RememberOpts } from '../../api.js';
import { scopeFromChannel, type ChannelMeta } from './scope.js';
import type { SlackMessageEvent } from './types.js';

export interface TransformInput {
  teamId: string;
  channel: ChannelMeta;
  message: SlackMessageEvent;
}

/**
 * Convert a SlackMessageEvent into RememberOpts for api.remember(). Returns
 * null when the message has no usable body (e.g. system events, bot reactions
 * without text). Caller treats null as "skip but mark idempotency seen".
 */
export function messageToRememberOpts(input: TransformInput): RememberOpts | null {
  const text = input.message.text?.trim();
  if (!text) return null;
  const artifactRef = `slack://${input.teamId}/${input.channel.id}/${input.message.ts}`;
  const tags = [
    'source:slack',
    `channel:${input.channel.id}`,
    ...(input.message.user ? [`user:${input.message.user}`] : []),
    ...(input.message.thread_ts ? [`thread:${input.message.thread_ts}`] : []),
  ];
  return {
    content: text,
    kind: 'raw',
    scope: scopeFromChannel(input.channel),
    artifactRef,
    tags,
  };
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-transform.test.ts`
Expected: 3 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/transform.ts tests/slack-transform.test.ts
git commit -m "feat(slack): message-to-RememberOpts transform with kind=raw"
```

---

## Task 8: ingestMessage — atomic idempotent write via api.remember afterWrite hook

**Why:** The single function the webhook handler and backfill loop both call. Steps: idempotency check → transform → `api.remember()` (with `afterWrite` callback that stamps the event log inside the same transaction) → return. Slack retries within 1 minute, so a crash between the memory write and the event-log mark would duplicate the row on the very next retry. This task therefore extends `api.remember` with an `afterWrite?: (db, memoryId) => void` callback (review patch #3) so the connector can mark idempotency atomically with the underlying upsert.

**Files:**
- Modify: `src/api.ts` — add `afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void` to `RememberOpts`. Pass it through to `writeEntry` (which already opens a transaction); invoke inside that transaction post-INSERT, pre-COMMIT.
- Modify: `src/store.ts` — accept and invoke the `afterWrite` callback inside `writeEntry`'s existing transaction. (Inspect the file first; if `writeEntry` does not currently wrap its INSERT in a transaction, wrap it now — the audit-emit hook already calls into the same db handle, so the transaction is the natural seam.)
- Create: `src/connectors/slack/ingest.ts`
- Create: `tests/slack-ingest.test.ts`
- Create: `tests/api-remember-after-write.test.ts` — proves the hook fires inside the same transaction (rollback drops both the memory and the side-effect).

**Step 1: Write the failing test**

```ts
// tests/slack-ingest.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore, loadAllEntries } from '../src/store.js';
import { ingestMessage } from '../src/connectors/slack/ingest.js';

const ctx = (root: string) => ({ hippoRoot: root, tenantId: 'default', actor: 'connector:slack' });

describe('ingestMessage', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-ingest-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('writes a kind=raw memory and is idempotent on replay', () => {
    const evt = {
      teamId: 'T1',
      channel: { id: 'C1', is_private: false },
      message: { type: 'message' as const, channel: 'C1', user: 'U1', text: 'hello', ts: '1700.0001' },
      eventId: 'Ev1',
    };
    const r1 = ingestMessage(ctx(root), evt);
    expect(r1.status).toBe('ingested');
    expect(r1.memoryId).toBeDefined();

    const r2 = ingestMessage(ctx(root), evt);
    expect(r2.status).toBe('duplicate');
    expect(r2.memoryId).toBe(r1.memoryId);

    const entries = loadAllEntries(root);
    const slackEntries = entries.filter((e) => e.source === 'slack' || e.tags.includes('source:slack'));
    expect(slackEntries).toHaveLength(1);
    expect(slackEntries[0].kind).toBe('raw');
  });

  it('marks empty-body messages as skipped without writing', () => {
    const evt = {
      teamId: 'T1',
      channel: { id: 'C1' },
      message: { type: 'message' as const, channel: 'C1', ts: '1700.0001' },
      eventId: 'Ev2',
    };
    const r = ingestMessage(ctx(root), evt);
    expect(r.status).toBe('skipped');
    // Replay still returns duplicate, not skipped, because seen is marked.
    expect(ingestMessage(ctx(root), evt).status).toBe('duplicate');
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-ingest.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

First extend `RememberOpts` in `src/api.ts`:

```ts
export interface RememberOpts {
  content: string;
  kind?: MemoryKind;
  scope?: string;
  owner?: string;
  artifactRef?: string;
  tags?: string[];
  /**
   * Optional hook invoked inside the same transaction as the underlying
   * memories INSERT. Used by ingestion connectors (E1.3+) to stamp
   * idempotency / cursor rows atomically with the memory row, so a crash
   * mid-write cannot produce a memory without its corresponding side-effect
   * log row (or vice versa).
   */
  afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void;
}
```

Inside `remember()`, thread the callback to `writeEntry`. In `src/store.ts`, `writeEntry` should wrap its INSERT + the `afterWrite` invocation in a single transaction (BEGIN/COMMIT, or SAVEPOINT if nested). If the function already runs the INSERT inside a transaction, hook the callback in just before COMMIT. If it does not, add `db.exec('BEGIN')` / `db.exec('COMMIT')` around the upsert + callback, with `ROLLBACK` on throw.

Then:

```ts
// src/connectors/slack/ingest.ts
import { remember, type Context } from '../../api.js';
import { openHippoDb, closeHippoDb } from '../../db.js';
import { hasSeenEvent, markEventSeen, lookupMemoryByEvent } from './idempotency.js';
import { messageToRememberOpts } from './transform.js';
import type { ChannelMeta } from './scope.js';
import type { SlackMessageEvent } from './types.js';

export interface IngestInput {
  teamId: string;
  channel: ChannelMeta;
  message: SlackMessageEvent;
  /** Slack event_id for the envelope (or for backfill, a synthesized stable id). */
  eventId: string;
}

export type IngestStatus = 'ingested' | 'duplicate' | 'skipped';

export interface IngestResult {
  status: IngestStatus;
  memoryId: string | null;
}

export function ingestMessage(ctx: Context, input: IngestInput): IngestResult {
  // Idempotency check: if already seen, return the cached memory_id without
  // re-running the transform or hitting api.remember.
  const db = openHippoDb(ctx.hippoRoot);
  try {
    if (hasSeenEvent(db, input.eventId)) {
      return { status: 'duplicate', memoryId: lookupMemoryByEvent(db, input.eventId) };
    }
  } finally {
    closeHippoDb(db);
  }

  const opts = messageToRememberOpts(input);
  if (!opts) {
    const db2 = openHippoDb(ctx.hippoRoot);
    try { markEventSeen(db2, input.eventId, null); }
    finally { closeHippoDb(db2); }
    return { status: 'skipped', memoryId: null };
  }

  // Atomic write: the afterWrite callback runs inside writeEntry's transaction,
  // so the memory row and the slack_event_log row commit (or roll back) together.
  // Slack's 1-minute retry window can no longer produce a duplicate via the
  // crash-between-handles race that the original draft acknowledged in TODOS.md.
  const result = remember(
    { ...ctx, actor: ctx.actor || 'connector:slack' },
    {
      ...opts,
      afterWrite: (db, memoryId) => markEventSeen(db, input.eventId, memoryId),
    },
  );
  return { status: 'ingested', memoryId: result.id };
}
```

Add the proof test for the transaction guarantee:

```ts
// tests/api-remember-after-write.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore, loadAllEntries } from '../src/store.js';
import { remember } from '../src/api.js';

describe('remember.afterWrite is transactional', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-after-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('rolls back the memory row when afterWrite throws', () => {
    expect(() =>
      remember({ hippoRoot: root, tenantId: 'default', actor: 'test' }, {
        content: 'doomed',
        afterWrite: () => { throw new Error('boom'); },
      }),
    ).toThrow(/boom/);
    expect(loadAllEntries(root).filter((e) => e.content === 'doomed')).toHaveLength(0);
  });
});
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-ingest.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/ingest.ts tests/slack-ingest.test.ts
git commit -m "feat(slack): idempotent ingestMessage with skip handling"
```

---

## Task 9: Source-deletion sync (handleMessageDeleted)

**Why:** GDPR. When a Slack user deletes a message, hippo must invalidate the corresponding memory or stand accused of being a parallel store-of-record. The deletion arrives as a `subtype='message_deleted'` event with `deleted_ts`. We resolve `slack://<team>/<channel>/<deleted_ts>` to a memory_id via `artifact_ref` lookup, then route through `archiveRawMemory()` (the only legal path for `kind='raw'`).

**Files:**
- Create: `src/connectors/slack/deletion.ts`
- Create: `tests/slack-deletion.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-deletion.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore, loadAllEntries } from '../src/store.js';
import { ingestMessage } from '../src/connectors/slack/ingest.js';
import { handleMessageDeleted } from '../src/connectors/slack/deletion.js';
import { openHippoDb, closeHippoDb } from '../src/db.js';

const ctx = (root: string) => ({ hippoRoot: root, tenantId: 'default', actor: 'connector:slack' });

describe('handleMessageDeleted', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-del-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('archives the matching kind=raw row via archiveRawMemory', () => {
    const ingested = ingestMessage(ctx(root), {
      teamId: 'T1',
      channel: { id: 'C1', is_private: false },
      message: { type: 'message', channel: 'C1', user: 'U1', text: 'doomed', ts: '1700.0001' },
      eventId: 'Ev1',
    });
    expect(ingested.status).toBe('ingested');

    const result = handleMessageDeleted(ctx(root), {
      teamId: 'T1',
      channelId: 'C1',
      deletedTs: '1700.0001',
      eventId: 'EvDel1',
    });
    expect(result.status).toBe('archived');

    const remaining = loadAllEntries(root).filter((e) => e.id === ingested.memoryId);
    expect(remaining).toHaveLength(0);

    // raw_archive table now has the row.
    const db = openHippoDb(root);
    try {
      const arch = db.prepare(`SELECT memory_id, reason FROM raw_archive WHERE memory_id = ?`).get(ingested.memoryId!) as Record<string, unknown>;
      expect(arch).toBeDefined();
      expect(String(arch.reason)).toContain('source_deleted');
    } finally { closeHippoDb(db); }
  });

  it('returns not_found for unknown artifact_ref (idempotent on replay)', () => {
    const r = handleMessageDeleted(ctx(root), {
      teamId: 'T1', channelId: 'C1', deletedTs: '9999.9999', eventId: 'EvDel2',
    });
    expect(r.status).toBe('not_found');
  });

  it('cross-tenant deletion event cannot archive another tenants row (review patch #1)', () => {
    // Ingest under tenant 'acme'.
    const acmeCtx = { hippoRoot: root, tenantId: 'acme', actor: 'connector:slack' };
    const ingested = ingestMessage(acmeCtx, {
      teamId: 'T1',
      channel: { id: 'C1', is_private: false },
      message: { type: 'message', channel: 'C1', user: 'U1', text: 'acme secret', ts: '1700.0001' },
      eventId: 'EvAcme1',
    });
    expect(ingested.status).toBe('ingested');

    // Fire deletion under tenant 'default' for the same artifact_ref.
    const defaultCtx = { hippoRoot: root, tenantId: 'default', actor: 'connector:slack' };
    const r = handleMessageDeleted(defaultCtx, {
      teamId: 'T1', channelId: 'C1', deletedTs: '1700.0001', eventId: 'EvDelCross',
    });
    expect(r.status).toBe('not_found');

    // Acme row is still there; raw_archive has no row.
    const remaining = loadAllEntries(root).filter((e) => e.id === ingested.memoryId);
    expect(remaining).toHaveLength(1);
    const db = openHippoDb(root);
    try {
      const archCount = db.prepare(`SELECT COUNT(*) as c FROM raw_archive WHERE memory_id = ?`).get(ingested.memoryId!) as { c: number };
      expect(Number(archCount.c)).toBe(0);
    } finally { closeHippoDb(db); }
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-deletion.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/deletion.ts
import { archiveRaw, type Context } from '../../api.js';
import { openHippoDb, closeHippoDb } from '../../db.js';
import { markEventSeen, hasSeenEvent } from './idempotency.js';

export interface DeletionInput {
  teamId: string;
  channelId: string;
  deletedTs: string;
  eventId: string;
}

export type DeletionStatus = 'archived' | 'not_found' | 'duplicate';

export interface DeletionResult {
  status: DeletionStatus;
  memoryId: string | null;
}

export function handleMessageDeleted(ctx: Context, input: DeletionInput): DeletionResult {
  const db = openHippoDb(ctx.hippoRoot);
  let memoryId: string | null = null;
  try {
    if (hasSeenEvent(db, input.eventId)) {
      return { status: 'duplicate', memoryId: null };
    }
    const ref = `slack://${input.teamId}/${input.channelId}/${input.deletedTs}`;
    const row = db.prepare(`SELECT id FROM memories WHERE artifact_ref = ? AND tenant_id = ? AND kind = 'raw'`).get(ref, ctx.tenantId) as { id?: string } | undefined;
    memoryId = row?.id ?? null;
  } finally {
    closeHippoDb(db);
  }
  if (!memoryId) {
    const db2 = openHippoDb(ctx.hippoRoot);
    try { markEventSeen(db2, input.eventId, null); }
    finally { closeHippoDb(db2); }
    return { status: 'not_found', memoryId: null };
  }
  archiveRaw(ctx, memoryId, `source_deleted:slack:${input.teamId}:${input.channelId}:${input.deletedTs}`);
  const db3 = openHippoDb(ctx.hippoRoot);
  try { markEventSeen(db3, input.eventId, memoryId); }
  finally { closeHippoDb(db3); }
  return { status: 'archived', memoryId };
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-deletion.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/deletion.ts tests/slack-deletion.test.ts
git commit -m "feat(slack): source-deletion sync via archiveRawMemory"
```

---

## Task 10: Permission mirroring (recall scope filter)

**Why:** ROADMAP test "Permission mirroring: Slack-private channel does not leak across scopes". A recall scoped to `slack:public:C1` must NOT return memories with `scope='slack:private:C2'`. Today `loadSearchEntries` filters by tenant; verify it already filters by scope (or add it). This task may be a no-op + test if the column is already honoured. We assert behaviour with an integration test against api.recall.

**Files:**
- Modify: `src/api.ts` — add optional `scope` filter to `RecallOpts` and thread it into `loadSearchEntries`. Inspect `src/store.ts:loadSearchEntries` signature first; if the third parameter already accepts a scope, just plumb it. If not, add a fourth `scopeFilter` param.
- Create: `tests/slack-permission-mirror.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-permission-mirror.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore } from '../src/store.js';
import { ingestMessage } from '../src/connectors/slack/ingest.js';
import { recall } from '../src/api.js';

const ctx = (root: string) => ({ hippoRoot: root, tenantId: 'default', actor: 'connector:slack' });

describe('slack permission mirroring', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-perm-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('private-channel content does not leak when querying public scope', () => {
    ingestMessage(ctx(root), {
      teamId: 'T1', channel: { id: 'CPUB', is_private: false },
      message: { type: 'message', channel: 'CPUB', user: 'U1', text: 'public secret', ts: '1.1' },
      eventId: 'EvPub',
    });
    ingestMessage(ctx(root), {
      teamId: 'T1', channel: { id: 'CPRIV', is_private: true },
      message: { type: 'message', channel: 'CPRIV', user: 'U1', text: 'private secret', ts: '2.2' },
      eventId: 'EvPriv',
    });

    const pubResults = recall(ctx(root), { query: 'secret', scope: 'slack:public:CPUB' });
    expect(pubResults.results.some((r) => r.content.includes('private secret'))).toBe(false);
    expect(pubResults.results.some((r) => r.content.includes('public secret'))).toBe(true);
  });

  // Review patch #4: empty/undefined scope must default-deny private rows.
  // Without this guarantee, a frontend caller forgetting to pass `scope` exposes
  // every private channel to a public query.
  it('no-scope query default-denies private rows', () => {
    ingestMessage(ctx(root), {
      teamId: 'T1', channel: { id: 'CPUB', is_private: false },
      message: { type: 'message', channel: 'CPUB', user: 'U1', text: 'public alpha', ts: '1.1' },
      eventId: 'EvPubA',
    });
    ingestMessage(ctx(root), {
      teamId: 'T1', channel: { id: 'CPRIV', is_private: true },
      message: { type: 'message', channel: 'CPRIV', user: 'U1', text: 'private alpha', ts: '2.2' },
      eventId: 'EvPrivA',
    });
    const r = recall(ctx(root), { query: 'alpha' }); // no scope
    expect(r.results.some((x) => x.content.includes('private alpha'))).toBe(false);
    expect(r.results.some((x) => x.content.includes('public alpha'))).toBe(true);
  });

  // Review patch #4: mismatched scope (channel does not exist) returns zero.
  it('mismatched scope returns zero results', () => {
    ingestMessage(ctx(root), {
      teamId: 'T1', channel: { id: 'CPUB', is_private: false },
      message: { type: 'message', channel: 'CPUB', user: 'U1', text: 'beta', ts: '1.1' },
      eventId: 'EvPubB',
    });
    const r = recall(ctx(root), { query: 'beta', scope: 'slack:public:CDOES_NOT_EXIST' });
    expect(r.results).toHaveLength(0);
  });

  // Review patch #4: tenant-mismatched scope. Tenant B writes a private row
  // with scope='slack:private:CSHARED'. Tenant A queries the same scope string
  // and must get nothing — recall is tenant-scoped before scope-scoped.
  it('tenant-mismatched scope does not leak across tenants', () => {
    const ctxA = (r: string) => ({ hippoRoot: r, tenantId: 'tenantA', actor: 'cli' });
    const ctxB = (r: string) => ({ hippoRoot: r, tenantId: 'tenantB', actor: 'cli' });
    ingestMessage(ctxB(root), {
      teamId: 'T1', channel: { id: 'CSHARED', is_private: true },
      message: { type: 'message', channel: 'CSHARED', user: 'U1', text: 'tenantB secret', ts: '3.3' },
      eventId: 'EvShared',
    });
    const r = recall(ctxA(root), { query: 'secret', scope: 'slack:private:CSHARED' });
    expect(r.results).toHaveLength(0);
  });
});
```

The default-deny rule in the first new test is load-bearing: implement `recall()` so the absence of `opts.scope` filters OUT any row whose scope starts with `'slack:private:'`. Sketch:

```ts
const all = loadSearchEntries(ctx.hippoRoot, opts.query, undefined, ctx.tenantId);
let entries = all;
if (opts.scope !== undefined && opts.scope !== '') {
  entries = all.filter((e) => e.scope === opts.scope);
} else {
  // Default-deny: a no-scope caller cannot see private slack channels.
  entries = all.filter((e) => !(e.scope ?? '').startsWith('slack:private:'));
}
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-permission-mirror.test.ts`
Expected: FAIL — `RecallOpts` has no `scope` field, OR results include private content.

**Step 3: Implement**

In `src/api.ts`, add to `RecallOpts`:

```ts
export interface RecallOpts {
  query: string;
  limit?: number;
  mode?: 'bm25' | 'hybrid' | 'physics';
  /** Restrict results to memories matching this scope exactly. */
  scope?: string;
}
```

Inside `recall()`, filter the entries by scope before slicing to `limit`:

```ts
const all = loadSearchEntries(ctx.hippoRoot, opts.query, undefined, ctx.tenantId);
const entries = opts.scope ? all.filter((e) => e.scope === opts.scope) : all;
```

If `loadSearchEntries` already supports a scope filter argument, prefer the in-DB filter to avoid hauling private rows into memory. Inspect the signature; the inline filter above is the safe fallback that also works on the current shipped surface.

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-permission-mirror.test.ts`
Expected: 1 passed.

**Step 5: Commit**

```bash
git add src/api.ts tests/slack-permission-mirror.test.ts
git commit -m "feat(api): scope filter on recall for permission mirroring"
```

---

## Task 11: Webhook route + URL verification + tenant-routing seam + lockdown test

**Why:** Slack POSTs events to a single URL. The route must (a) handle the one-time `url_verification` handshake, (b) verify the HMAC signature on every other request, (c) resolve `body.team_id` → tenant via the `slack_workspaces` table (review patch #6) with a fallback to `HIPPO_TENANT`, (d) parse the envelope, (e) call `ingestMessage` or `handleMessageDeleted`, (f) on parse failure write to DLQ and STILL ack 200 (otherwise Slack retries forever). All under the 1MB body cap that A1 already enforces.

**Critical auth shape (review patch #2):** the Slack route is the FIRST and ONLY public `/v1/*` route. Without an explicit allow-list, a future global Bearer middleware silently breaks it (or, worse, the gate gets removed because "Slack needs to be public" — bypass surface). This task introduces a `PUBLIC_ROUTES` constant in `server.ts` and a lockdown test asserting every other `/v1/*` route returns 401 without Bearer (so a future regression that drops the Bearer gate can't ship green).

**Files:**
- Modify: `src/server.ts` — add the route, import the connector helpers, add `PUBLIC_ROUTES` constant, route Slack lookups through `resolveTenantForTeam`.
- Create: `src/connectors/slack/tenant-routing.ts` — `resolveTenantForTeam(db, teamId): string | null` helper. Falls back to null when no row; caller uses `HIPPO_TENANT` then.
- Create: `tests/slack-webhook-route.test.ts`
- Create: `tests/slack-tenant-routing.test.ts` — proves `slack_workspaces` rows route, empty table falls back.
- Create: `tests/server-bearer-lockdown.test.ts` — proves every `/v1/*` route except `/v1/connectors/slack/events` returns 401 with no Bearer when the loopback fallback is disabled.

**Step 1: Write the failing test**

```ts
// tests/slack-webhook-route.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createHmac } from 'crypto';
import { initStore, loadAllEntries } from '../src/store.js';
import { serve } from '../src/server.js';

const SECRET = 'shhh';

function sign(ts: string, body: string): string {
  return `v0=${createHmac('sha256', SECRET).update(`v0:${ts}:${body}`).digest('hex')}`;
}

describe('POST /v1/connectors/slack/events', () => {
  let root: string;
  let handle: { stop: () => Promise<void>; port: number };

  beforeEach(async () => {
    root = mkdtempSync(join(tmpdir(), 'hippo-slack-route-'));
    initStore(root);
    process.env.SLACK_SIGNING_SECRET = SECRET;
    handle = await serve({ hippoRoot: root, host: '127.0.0.1', port: 0 });
  });
  afterEach(async () => {
    delete process.env.SLACK_SIGNING_SECRET;
    await handle.stop();
    rmSync(root, { recursive: true, force: true });
  });

  it('echoes the URL verification challenge', async () => {
    const body = JSON.stringify({ type: 'url_verification', challenge: 'abc123' });
    const ts = String(Math.floor(Date.now() / 1000));
    const res = await fetch(`http://127.0.0.1:${handle.port}/v1/connectors/slack/events`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'x-slack-request-timestamp': ts, 'x-slack-signature': sign(ts, body) },
      body,
    });
    expect(res.status).toBe(200);
    const json = await res.json();
    expect(json.challenge).toBe('abc123');
  });

  it('rejects missing/invalid signature with 401', async () => {
    const body = JSON.stringify({ type: 'event_callback', team_id: 'T', event_id: 'E', event_time: 0, event: { type: 'message', channel: 'C1', ts: '1.1', text: 'hi' } });
    const res = await fetch(`http://127.0.0.1:${handle.port}/v1/connectors/slack/events`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body,
    });
    expect(res.status).toBe(401);
  });

  it('writes a memory on a valid event_callback', async () => {
    const body = JSON.stringify({
      type: 'event_callback', team_id: 'T1', event_id: 'EvA', event_time: Math.floor(Date.now() / 1000),
      event: { type: 'message', channel: 'C1', user: 'U1', text: 'hello', ts: '1700000000.000100' },
    });
    const ts = String(Math.floor(Date.now() / 1000));
    const res = await fetch(`http://127.0.0.1:${handle.port}/v1/connectors/slack/events`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'x-slack-request-timestamp': ts, 'x-slack-signature': sign(ts, body) },
      body,
    });
    expect(res.status).toBe(200);
    const entries = loadAllEntries(root);
    expect(entries.some((e) => e.tags.includes('source:slack') && e.kind === 'raw')).toBe(true);
  });

  it('writes malformed payloads to DLQ but still ACKs 200', async () => {
    const body = '{"type":"event_callback","event":{}}'; // missing required fields
    const ts = String(Math.floor(Date.now() / 1000));
    const res = await fetch(`http://127.0.0.1:${handle.port}/v1/connectors/slack/events`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'x-slack-request-timestamp': ts, 'x-slack-signature': sign(ts, body) },
      body,
    });
    expect(res.status).toBe(200);
    // DLQ row exists.
    const { openHippoDb, closeHippoDb } = await import('../src/db.js');
    const db = openHippoDb(root);
    try {
      const row = db.prepare(`SELECT COUNT(*) as c FROM slack_dlq`).get() as { c: number };
      expect(Number(row.c)).toBe(1);
    } finally { closeHippoDb(db); }
  });

  // Review patch #7: missing secret returns 404 (route appears unmounted), not 503.
  it('returns 404 when SLACK_SIGNING_SECRET is unset (no config leak)', async () => {
    delete process.env.SLACK_SIGNING_SECRET;
    const body = '{}';
    const res = await fetch(`http://127.0.0.1:${handle.port}/v1/connectors/slack/events`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body,
    });
    expect(res.status).toBe(404);
    process.env.SLACK_SIGNING_SECRET = SECRET;
  });

  // Review patch #2 positive case: route accepts requests with valid signature
  // and NO Authorization header (because it's in PUBLIC_ROUTES).
  it('accepts valid signature with no Bearer (PUBLIC_ROUTES allow-list)', async () => {
    const body = JSON.stringify({
      type: 'event_callback', team_id: 'T1', event_id: 'EvNoBearer', event_time: Math.floor(Date.now() / 1000),
      event: { type: 'message', channel: 'C1', user: 'U1', text: 'no-bearer ok', ts: '1700000001.000200' },
    });
    const ts = String(Math.floor(Date.now() / 1000));
    const res = await fetch(`http://127.0.0.1:${handle.port}/v1/connectors/slack/events`, {
      method: 'POST',
      // No Authorization header on purpose.
      headers: { 'content-type': 'application/json', 'x-slack-request-timestamp': ts, 'x-slack-signature': sign(ts, body) },
      body,
    });
    expect(res.status).toBe(200);
  });
});
```

Add the lockdown test in `tests/server-bearer-lockdown.test.ts`. It mints an API key, then asserts every other `/v1/*` route returns 401 without Bearer (when loopback-no-auth is disabled via `HIPPO_REQUIRE_AUTH=1` or equivalent — inspect existing server.ts auth shape and pick the smallest existing knob; if none exists, set a request-time `X-Hippo-Force-Auth` header that the server respects in tests).

```ts
// tests/server-bearer-lockdown.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore } from '../src/store.js';
import { serve } from '../src/server.js';

describe('server Bearer lockdown', () => {
  let root: string;
  let handle: { stop: () => Promise<void>; port: number };

  beforeEach(async () => {
    root = mkdtempSync(join(tmpdir(), 'hippo-lockdown-'));
    initStore(root);
    process.env.HIPPO_REQUIRE_AUTH = '1';
    handle = await serve({ hippoRoot: root, host: '127.0.0.1', port: 0 });
  });
  afterEach(async () => { delete process.env.HIPPO_REQUIRE_AUTH; await handle.stop(); rmSync(root, { recursive: true, force: true }); });

  const v1Routes: Array<{ method: string; path: string; body?: string }> = [
    { method: 'POST', path: '/v1/memories', body: '{"content":"x"}' },
    { method: 'GET', path: '/v1/memories?q=x' },
    { method: 'POST', path: '/v1/auth/keys', body: '{}' },
    { method: 'GET', path: '/v1/auth/keys' },
    { method: 'GET', path: '/v1/audit' },
  ];

  it('all /v1/* routes except slack require Bearer', async () => {
    for (const r of v1Routes) {
      const res = await fetch(`http://127.0.0.1:${handle.port}${r.path}`, {
        method: r.method,
        headers: { 'content-type': 'application/json' },
        body: r.body,
      });
      expect(res.status, `${r.method} ${r.path}`).toBe(401);
    }
  });
});
```

Note: if `server.ts` does not yet expose a way to disable the loopback-no-auth fallback for tests, this task ALSO adds that knob (ideally `HIPPO_REQUIRE_AUTH=1`). It's a one-liner but it's load-bearing for the lockdown invariant.

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-webhook-route.test.ts`
Expected: FAIL — route returns 404 for all four cases.

**Step 3: Implement**

First, write the tenant-routing helper:

```ts
// src/connectors/slack/tenant-routing.ts
import type { DatabaseSyncLike } from '../../db.js';

/**
 * Look up the tenant_id for a Slack team_id. Returns null when no row exists,
 * which signals "use the deployment's HIPPO_TENANT fallback". Multi-workspace
 * deployments populate slack_workspaces; single-workspace deployments leave
 * the table empty and rely on the env fallback.
 */
export function resolveTenantForTeam(db: DatabaseSyncLike, teamId: string): string | null {
  const row = db.prepare(`SELECT tenant_id FROM slack_workspaces WHERE team_id = ?`).get(teamId) as
    | { tenant_id?: string }
    | undefined;
  return row?.tenant_id ?? null;
}
```

Add a small test (`tests/slack-tenant-routing.test.ts`) asserting (a) populated row routes to that tenant, (b) empty table returns null, (c) lookup is exact-match (no prefix bleed).

Then in `src/server.ts`, near the top of the file (alongside the existing route constants), add:

```ts
// Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
// New unauth routes MUST be added here AND get a corresponding entry in
// tests/server-bearer-lockdown.test.ts. Do not gate by `path.startsWith`
// match elsewhere — pattern-positional auth is bypass-by-accident.
const PUBLIC_ROUTES: ReadonlySet<string> = new Set(['POST /v1/connectors/slack/events']);
```

Adjust the existing Bearer enforcement seam (`buildContextWithAuth` or whichever helper currently runs auth) to consult `PUBLIC_ROUTES` and skip the Bearer requirement when `${method} ${path}` is in the set. The Slack route still verifies its own HMAC signature — auth is replaced, not removed.

Then, after the `/v1/audit` block and before the MCP routes, add the route itself:

```ts
// POST /v1/connectors/slack/events — Slack Events API webhook.
//
// Verifies HMAC signature, handles url_verification handshake, resolves
// team_id → tenantId via slack_workspaces (falls back to HIPPO_TENANT),
// dispatches event_callback envelopes to ingestMessage / handleMessageDeleted,
// and parks malformed payloads in slack_dlq. Always ACKs 200 on signed
// requests (apart from URL verification which echoes the challenge) so
// Slack does not enter the retry storm. Auth is signature-based, NOT
// Bearer — a Slack signing secret is the only credential.
if (method === 'POST' && path === '/v1/connectors/slack/events') {
  const rawBody = await readBody(req);
  const secret = process.env.SLACK_SIGNING_SECRET;
  if (!secret) {
    // Review patch #7: 404 (route appears unmounted) instead of 503,
    // so a probe cannot distinguish "route gated off by config" from
    // "route does not exist on this build".
    res.writeHead(404, JSON_HEADERS);
    res.end(JSON.stringify({ error: 'not found' }));
    return;
  }
  const sig = req.headers['x-slack-signature'];
  const tsHdr = req.headers['x-slack-request-timestamp'];
  if (typeof sig !== 'string' || typeof tsHdr !== 'string' ||
      !verifySlackSignature({ rawBody, timestamp: tsHdr, signature: sig, signingSecret: secret })) {
    throw new HttpError(401, 'invalid Slack signature');
  }
  // Resolve tenant: parse the body once to peek at team_id, then look up
  // slack_workspaces. We re-use the parsed body below for routing, so we
  // don't pay the JSON.parse cost twice.
  let body: unknown;
  try { body = JSON.parse(rawBody); } catch {
    const db = openHippoDb(opts.hippoRoot);
    try { writeToDlq(db, { tenantId: process.env.HIPPO_TENANT ?? 'default', rawPayload: rawBody, error: 'invalid JSON' }); }
    finally { closeHippoDb(db); }
    sendJson(res, 200, { ok: true, status: 'dlq' });
    return;
  }
  if (body && typeof body === 'object' && (body as Record<string, unknown>).type === 'url_verification') {
    sendJson(res, 200, { challenge: String((body as Record<string, unknown>).challenge ?? '') });
    return;
  }
  let resolvedTenant = process.env.HIPPO_TENANT ?? 'default';
  if (isSlackEventEnvelope(body)) {
    const db = openHippoDb(opts.hippoRoot);
    try {
      const mapped = resolveTenantForTeam(db, body.team_id);
      if (mapped) resolvedTenant = mapped;
    } finally { closeHippoDb(db); }
  }
  const ctx = {
    hippoRoot: opts.hippoRoot,
    tenantId: resolvedTenant,
    actor: 'connector:slack',
  };
  if (!isSlackEventEnvelope(body)) {
    const db = openHippoDb(ctx.hippoRoot);
    try { writeToDlq(db, { tenantId: ctx.tenantId, rawPayload: rawBody, error: 'not an event_callback envelope' }); }
    finally { closeHippoDb(db); }
    sendJson(res, 200, { ok: true, status: 'dlq' });
    return;
  }
  const inner = body.event;
  if (isSlackMessageEvent(inner)) {
    if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
      const r = handleMessageDeleted(ctx, {
        teamId: body.team_id, channelId: inner.channel, deletedTs: inner.deleted_ts, eventId: body.event_id,
      });
      sendJson(res, 200, { ok: true, status: r.status });
      return;
    }
    const r = ingestMessage(ctx, {
      teamId: body.team_id,
      // Channel privacy isn't on the inner event; use channel_type as a proxy.
      // 'group'|'im'|'mpim' → private. 'channel' → public. Unknown → private (fail closed).
      channel: { id: inner.channel, is_private: inner.channel_type !== 'channel', is_im: inner.channel_type === 'im', is_mpim: inner.channel_type === 'mpim' },
      message: inner,
      eventId: body.event_id,
    });
    sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
    return;
  }
  // Non-message event types: log to DLQ for offline review (e.g. reactions).
  const db = openHippoDb(ctx.hippoRoot);
  try { writeToDlq(db, { tenantId: ctx.tenantId, rawPayload: rawBody, error: `unhandled event type: ${inner?.type ?? 'unknown'}` }); }
  finally { closeHippoDb(db); }
  sendJson(res, 200, { ok: true, status: 'dlq' });
  return;
}
```

Add the imports at the top of `src/server.ts`:

```ts
import { verifySlackSignature } from './connectors/slack/signature.js';
import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
import { ingestMessage } from './connectors/slack/ingest.js';
import { handleMessageDeleted } from './connectors/slack/deletion.js';
import { writeToDlq } from './connectors/slack/dlq.js';
import { resolveTenantForTeam } from './connectors/slack/tenant-routing.js';
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-webhook-route.test.ts`
Expected: 4 passed.

**Step 5: Commit**

```bash
git add src/server.ts tests/slack-webhook-route.test.ts
git commit -m "feat(server): /v1/connectors/slack/events webhook route"
```

---

## Task 12: Rate-limit aware fetch helper

**Why:** Slack's Web API returns `429 Too Many Requests` with a `Retry-After` header when limits are exceeded. The backfill loop must back off, sleep, retry. Without this, a 1000-event backfill on a busy workspace turns into hundreds of dropped pages.

**Files:**
- Create: `src/connectors/slack/ratelimit.ts`
- Create: `tests/slack-ratelimit.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-ratelimit.test.ts
import { describe, it, expect, vi } from 'vitest';
import { fetchWithRetry } from '../src/connectors/slack/ratelimit.js';

describe('fetchWithRetry', () => {
  it('retries once after 429 with Retry-After honoured', async () => {
    const sleeps: number[] = [];
    const sleep = (ms: number) => { sleeps.push(ms); return Promise.resolve(); };
    const calls = [
      { status: 429, headers: { get: (h: string) => h === 'retry-after' ? '0.05' : null }, json: async () => ({}) },
      { status: 200, headers: { get: () => null }, json: async () => ({ ok: true }) },
    ];
    let i = 0;
    const fakeFetch = vi.fn(async () => calls[i++] as Response);
    const r = await fetchWithRetry({ url: 'http://x', fetchImpl: fakeFetch as typeof fetch, sleep, maxRetries: 3 });
    expect(r.status).toBe(200);
    expect(fakeFetch).toHaveBeenCalledTimes(2);
    expect(sleeps[0]).toBe(50); // 0.05s in ms
  });

  it('gives up after maxRetries and throws', async () => {
    const fakeFetch = vi.fn(async () => ({ status: 429, headers: { get: () => '0.01' }, json: async () => ({}) }) as Response);
    await expect(fetchWithRetry({ url: 'http://x', fetchImpl: fakeFetch as typeof fetch, sleep: () => Promise.resolve(), maxRetries: 2 })).rejects.toThrow(/rate.?limited/i);
    expect(fakeFetch).toHaveBeenCalledTimes(3); // initial + 2 retries
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-ratelimit.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/ratelimit.ts

export interface RetryOpts {
  url: string;
  init?: RequestInit;
  fetchImpl?: typeof fetch;
  sleep?: (ms: number) => Promise<void>;
  maxRetries?: number;
}

export async function fetchWithRetry(opts: RetryOpts): Promise<Response> {
  const f = opts.fetchImpl ?? fetch;
  const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
  const max = opts.maxRetries ?? 3;
  let attempt = 0;
  while (true) {
    const r = await f(opts.url, opts.init);
    if (r.status !== 429) return r;
    if (attempt >= max) throw new Error(`rate-limited after ${attempt + 1} attempts: ${opts.url}`);
    const ra = r.headers.get('retry-after');
    const delaySec = ra ? Number(ra) : Math.pow(2, attempt);
    await sleep(Math.max(0, delaySec) * 1000);
    attempt++;
  }
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-ratelimit.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/ratelimit.ts tests/slack-ratelimit.test.ts
git commit -m "feat(slack): fetchWithRetry honouring Retry-After"
```

---

## Task 13: Backfill loop with cursor persistence

**Why:** New workspace, missed window, server downtime: any of these requires a backfill. We page through `conversations.history`, persist the latest seen `ts` to `slack_cursors` after every page, and resume from there on restart. The fetcher is injected so tests can drive it without HTTP.

**Files:**
- Create: `src/connectors/slack/backfill.ts`
- Create: `tests/slack-backfill.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-backfill.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore, loadAllEntries } from '../src/store.js';
import { backfillChannel, type SlackHistoryFetcher } from '../src/connectors/slack/backfill.js';
import { openHippoDb, closeHippoDb } from '../src/db.js';

const ctx = (root: string) => ({ hippoRoot: root, tenantId: 'default', actor: 'connector:slack' });

describe('backfillChannel', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-bf-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('paginates, ingests, and persists the cursor', async () => {
    const fetcher: SlackHistoryFetcher = async ({ cursor }) => {
      if (!cursor) return { messages: [{ type: 'message', channel: 'C1', user: 'U1', text: 'a', ts: '1.1' }, { type: 'message', channel: 'C1', user: 'U1', text: 'b', ts: '2.2' }], next_cursor: 'PAGE2' };
      if (cursor === 'PAGE2') return { messages: [{ type: 'message', channel: 'C1', user: 'U1', text: 'c', ts: '3.3' }], next_cursor: null };
      throw new Error('unexpected cursor: ' + cursor);
    };
    const r = await backfillChannel(ctx(root), { teamId: 'T1', channel: { id: 'C1', is_private: false }, fetcher });
    expect(r.ingested).toBe(3);

    const db = openHippoDb(root);
    try {
      const row = db.prepare(`SELECT latest_ts FROM slack_cursors WHERE tenant_id=? AND channel_id=?`).get('default', 'C1') as { latest_ts: string };
      expect(row.latest_ts).toBe('3.3');
    } finally { closeHippoDb(db); }

    expect(loadAllEntries(root).filter((e) => e.tags.includes('source:slack'))).toHaveLength(3);
  });

  it('resumes from the saved cursor (idempotent on rerun)', async () => {
    let phase = 1;
    const fetcher: SlackHistoryFetcher = async () => {
      if (phase === 1) {
        phase = 2;
        return { messages: [{ type: 'message', channel: 'C1', user: 'U1', text: 'a', ts: '1.1' }], next_cursor: null };
      }
      // Second pass returns the same message — replay must not duplicate.
      return { messages: [{ type: 'message', channel: 'C1', user: 'U1', text: 'a', ts: '1.1' }], next_cursor: null };
    };
    await backfillChannel(ctx(root), { teamId: 'T1', channel: { id: 'C1' }, fetcher });
    await backfillChannel(ctx(root), { teamId: 'T1', channel: { id: 'C1' }, fetcher });
    expect(loadAllEntries(root).filter((e) => e.tags.includes('source:slack'))).toHaveLength(1);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-backfill.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/backfill.ts
import type { Context } from '../../api.js';
import { openHippoDb, closeHippoDb } from '../../db.js';
import { ingestMessage } from './ingest.js';
import type { SlackMessageEvent } from './types.js';
import type { ChannelMeta } from './scope.js';

export interface SlackHistoryPage {
  messages: SlackMessageEvent[];
  next_cursor: string | null;
}

export type SlackHistoryFetcher = (args: { channelId: string; cursor: string | null }) => Promise<SlackHistoryPage>;

export interface BackfillOpts {
  teamId: string;
  channel: ChannelMeta;
  fetcher: SlackHistoryFetcher;
  /** Stop after this many messages. Default: unlimited. */
  maxMessages?: number;
}

function readCursor(root: string, tenantId: string, channelId: string): string | null {
  const db = openHippoDb(root);
  try {
    const row = db.prepare(`SELECT latest_ts FROM slack_cursors WHERE tenant_id=? AND channel_id=?`).get(tenantId, channelId) as { latest_ts?: string } | undefined;
    return row?.latest_ts ?? null;
  } finally { closeHippoDb(db); }
}

function writeCursor(root: string, tenantId: string, channelId: string, latestTs: string): void {
  const db = openHippoDb(root);
  try {
    db.prepare(`INSERT INTO slack_cursors (tenant_id, channel_id, latest_ts, updated_at) VALUES (?,?,?,?)
                ON CONFLICT(tenant_id, channel_id) DO UPDATE SET latest_ts = excluded.latest_ts, updated_at = excluded.updated_at`)
      .run(tenantId, channelId, latestTs, new Date().toISOString());
  } finally { closeHippoDb(db); }
}

export async function backfillChannel(ctx: Context, opts: BackfillOpts): Promise<{ ingested: number; pages: number }> {
  let cursor: string | null = readCursor(ctx.hippoRoot, ctx.tenantId, opts.channel.id);
  let ingested = 0;
  let pages = 0;
  let latestTs: string | null = cursor;
  while (true) {
    const page = await opts.fetcher({ channelId: opts.channel.id, cursor });
    pages++;
    for (const msg of page.messages) {
      const r = ingestMessage(ctx, {
        teamId: opts.teamId,
        channel: opts.channel,
        message: msg,
        // Backfill has no Slack event_id; synthesize a stable one from the artifact_ref
        // so a re-run dedupes against the same row.
        eventId: `backfill:${opts.teamId}:${opts.channel.id}:${msg.ts}`,
      });
      if (r.status === 'ingested') ingested++;
      if (!latestTs || msg.ts > latestTs) latestTs = msg.ts;
      if (opts.maxMessages && ingested >= opts.maxMessages) {
        if (latestTs) writeCursor(ctx.hippoRoot, ctx.tenantId, opts.channel.id, latestTs);
        return { ingested, pages };
      }
    }
    if (latestTs) writeCursor(ctx.hippoRoot, ctx.tenantId, opts.channel.id, latestTs);
    if (!page.next_cursor) break;
    cursor = page.next_cursor;
  }
  return { ingested, pages };
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-backfill.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/backfill.ts tests/slack-backfill.test.ts
git commit -m "feat(slack): cursor-persisted backfill loop"
```

---

## Task 14: CLI — `hippo slack backfill` and `hippo slack dlq`

**Why:** Operators need a way to kick off a backfill (initial onboarding, recovery from outage) and inspect the DLQ without writing SQL. Two subcommands; both thin wrappers over the connector helpers.

**Files:**
- Modify: `src/cli.ts` — add `case 'slack':` dispatch + handlers `cmdSlackBackfill` / `cmdSlackDlqList`.
- Create: `tests/slack-cli.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-cli.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { execFileSync } from 'child_process';
import { initStore } from '../src/store.js';
import { writeToDlq } from '../src/connectors/slack/dlq.js';
import { openHippoDb, closeHippoDb } from '../src/db.js';

const cli = (cwd: string, args: string[]) => execFileSync('node', ['bin/hippo.js', ...args], { cwd, encoding: 'utf8', env: { ...process.env, HIPPO_HOME: join(cwd, '.hippo') } });

describe('hippo slack CLI', () => {
  let root: string;
  beforeEach(() => {
    root = mkdtempSync(join(tmpdir(), 'hippo-slack-cli-'));
    initStore(join(root, '.hippo'));
  });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('hippo slack dlq list prints DLQ rows', () => {
    const db = openHippoDb(join(root, '.hippo'));
    try { writeToDlq(db, { tenantId: 'default', rawPayload: '{"x":1}', error: 'bad event' }); }
    finally { closeHippoDb(db); }
    const out = cli(root, ['slack', 'dlq', 'list']);
    expect(out).toContain('bad event');
  });

  it('hippo slack backfill --help mentions --channel and --since', () => {
    const out = cli(root, ['slack', 'backfill', '--help']);
    expect(out).toMatch(/--channel/);
    expect(out).toMatch(/--since/);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-cli.test.ts`
Expected: FAIL — `hippo slack` is unknown command.

**Step 3: Implement**

In `src/cli.ts`, add the dispatch and two handlers. Sketch:

```ts
function cmdSlackBackfill(hippoRoot: string, flags: Record<string, string | boolean | string[]>): void {
  const channel = flags.channel as string | undefined;
  if (!channel) {
    console.error('hippo slack backfill --channel <id> [--since ISO]');
    console.error('  --channel  Slack channel id (required, e.g. C0123ABC)');
    console.error('  --since    backfill from ISO timestamp (default: cursor)');
    process.exit(1);
  }
  // Real fetcher: requires SLACK_BOT_TOKEN. Out of scope for V1 unless the env is set.
  // For now, emit a friendly error so operators wire the token before running.
  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) {
    console.error('SLACK_BOT_TOKEN is not set. Backfill requires a Slack bot token with channels:history scope.');
    process.exit(2);
  }
  // Real fetcher implementation: see src/connectors/slack/backfill.ts. Wire here.
  // (Full HTTP fetcher implementation deferred to Task 15.)
  console.log(`Backfill of ${channel} not yet wired to live HTTP fetcher (Task 15).`);
}

function cmdSlackDlqList(hippoRoot: string, _flags: Record<string, string | boolean | string[]>): void {
  const db = openHippoDb(hippoRoot);
  try {
    const items = listDlq(db, { tenantId: process.env.HIPPO_TENANT ?? 'default' });
    for (const it of items) {
      console.log(`${it.id}\t${it.receivedAt}\t${it.error}`);
    }
  } finally { closeHippoDb(db); }
}

function cmdSlack(hippoRoot: string, args: string[], flags: Record<string, string | boolean | string[]>): void {
  const sub = args[0];
  if (sub === 'backfill') return cmdSlackBackfill(hippoRoot, flags);
  if (sub === 'dlq' && args[1] === 'list') return cmdSlackDlqList(hippoRoot, flags);
  console.error('Usage: hippo slack <backfill|dlq list> [...]');
  process.exit(1);
}
```

Add `case 'slack': cmdSlack(hippoRoot, args, flags); break;` in the main dispatcher. Import `listDlq` and the open/close DB helpers if not already imported.

**Step 4: Run to verify it passes**

Run: `npm run build && npx vitest run tests/slack-cli.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/cli.ts tests/slack-cli.test.ts
git commit -m "feat(cli): hippo slack backfill + slack dlq list"
```

---

## Task 15: Live conversations.history fetcher (HTTP)

**Why:** Task 13's loop accepts a fetcher; Task 14's CLI says "not wired". This task wires it. Single function `slackHistoryFetcher(token, fetcher?)` returning a `SlackHistoryFetcher`. Uses `fetchWithRetry` from Task 12. Tested against a mocked `fetch` so we don't hit Slack in CI.

**Files:**
- Create: `src/connectors/slack/web-client.ts`
- Modify: `src/cli.ts` — replace the "not yet wired" stub with a real fetcher call.
- Create: `tests/slack-web-client.test.ts`

**Step 1: Write the failing test**

```ts
// tests/slack-web-client.test.ts
import { describe, it, expect, vi } from 'vitest';
import { slackHistoryFetcher } from '../src/connectors/slack/web-client.js';

describe('slackHistoryFetcher', () => {
  it('GETs conversations.history with bearer token + cursor', async () => {
    const calls: Array<{ url: string; init?: RequestInit }> = [];
    const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => {
      calls.push({ url, init });
      return {
        status: 200,
        headers: { get: () => null },
        json: async () => ({ ok: true, messages: [{ type: 'message', channel: 'C1', text: 'hi', ts: '1.1' }], response_metadata: { next_cursor: 'NEXT' } }),
      } as unknown as Response;
    });
    const fetcher = slackHistoryFetcher('xoxb-fake', fakeFetch as unknown as typeof fetch);
    const page = await fetcher({ channelId: 'C1', cursor: null });
    expect(page.messages).toHaveLength(1);
    expect(page.next_cursor).toBe('NEXT');
    expect(calls[0].url).toContain('channel=C1');
    const auth = (calls[0].init?.headers as Record<string, string>).authorization;
    expect(auth).toBe('Bearer xoxb-fake');
  });

  it('throws when ok=false', async () => {
    const fakeFetch = vi.fn(async () => ({
      status: 200, headers: { get: () => null }, json: async () => ({ ok: false, error: 'not_in_channel' }),
    } as unknown as Response));
    const fetcher = slackHistoryFetcher('t', fakeFetch as unknown as typeof fetch);
    await expect(fetcher({ channelId: 'C1', cursor: null })).rejects.toThrow(/not_in_channel/);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-web-client.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// src/connectors/slack/web-client.ts
import { fetchWithRetry } from './ratelimit.js';
import type { SlackHistoryFetcher } from './backfill.js';

export function slackHistoryFetcher(token: string, fetchImpl?: typeof fetch): SlackHistoryFetcher {
  return async ({ channelId, cursor }) => {
    const url = new URL('https://slack.com/api/conversations.history');
    url.searchParams.set('channel', channelId);
    url.searchParams.set('limit', '200');
    if (cursor) url.searchParams.set('cursor', cursor);
    const r = await fetchWithRetry({
      url: url.toString(),
      init: { method: 'GET', headers: { authorization: `Bearer ${token}` } },
      fetchImpl,
    });
    const body = await r.json() as { ok: boolean; error?: string; messages?: unknown[]; response_metadata?: { next_cursor?: string } };
    if (!body.ok) throw new Error(`slack: ${body.error ?? 'unknown error'}`);
    return {
      messages: (body.messages ?? []).filter((m): m is import('./types.js').SlackMessageEvent =>
        !!m && typeof m === 'object' && (m as Record<string, unknown>).type === 'message' && typeof (m as Record<string, unknown>).ts === 'string',
      // Slack returns messages without `channel`; stamp it from the request.
      ).map((m) => ({ ...m, channel: channelId } as import('./types.js').SlackMessageEvent)),
      next_cursor: body.response_metadata?.next_cursor ?? null,
    };
  };
}
```

Then in `src/cli.ts`, replace the stub `cmdSlackBackfill` body:

```ts
const sinceIso = flags.since as string | undefined;
const fetcher = slackHistoryFetcher(token);
// Note: --since is honoured by the loop's max-cursor filter; for V1 we treat
// "since" as advisory and rely on the slack_cursors row to drive resume.
const ctx = { hippoRoot, tenantId: process.env.HIPPO_TENANT ?? 'default', actor: 'cli:slack-backfill' };
backfillChannel(ctx, { teamId: process.env.SLACK_TEAM_ID ?? 'T_UNKNOWN', channel: { id: channel, is_private: false }, fetcher })
  .then((r) => console.log(`backfill ${channel}: ${r.ingested} new messages across ${r.pages} pages`))
  .catch((e: Error) => { console.error('backfill failed:', e.message); process.exit(3); });
void sinceIso;
```

Add the import.

**Step 4: Run to verify it passes**

Run: `npm run build && npx vitest run tests/slack-web-client.test.ts`
Expected: 2 passed.

**Step 5: Commit**

```bash
git add src/connectors/slack/web-client.ts src/cli.ts tests/slack-web-client.test.ts
git commit -m "feat(slack): live conversations.history fetcher + CLI wiring"
```

---

## Task 16: 1000-event smoke benchmark

**Why:** ROADMAP success criterion: "1000-event smoke test with no source-system writes". Generate 1000 synthetic Slack events, replay each twice through the webhook route, assert (a) exactly 1000 memories, (b) all are `kind='raw'`, (c) zero outbound HTTP requests to slack.com made by the connector, (d) replay latency stays under 500ms / event.

**Files:**
- Create: `benchmarks/e1.3/slack-1000-event-smoke.ts`
- Create: `tests/slack-smoke-1000.test.ts` (the gating test that runs the smoke at low volume so CI doesn't take 10min)

**Step 1: Write the failing test**

```ts
// tests/slack-smoke-1000.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore, loadAllEntries } from '../src/store.js';
import { runSlackSmoke } from '../benchmarks/e1.3/slack-1000-event-smoke.js';

describe('slack 1000-event smoke (CI subset)', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-smoke-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('100 events, replayed twice, produces exactly 100 raw memories', async () => {
    const result = await runSlackSmoke({ hippoRoot: root, count: 100, replay: 2 });
    expect(result.uniqueMemories).toBe(100);
    expect(result.outboundHttp).toBe(0);
    const all = loadAllEntries(root).filter((e) => e.tags.includes('source:slack'));
    expect(all).toHaveLength(100);
    expect(all.every((e) => e.kind === 'raw')).toBe(true);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-smoke-1000.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

```ts
// benchmarks/e1.3/slack-1000-event-smoke.ts
import { ingestMessage } from '../../src/connectors/slack/ingest.js';
import { loadAllEntries } from '../../src/store.js';

export interface SmokeOpts {
  hippoRoot: string;
  count: number;
  replay: number;
}

export interface SmokeResult {
  uniqueMemories: number;
  totalIngestCalls: number;
  outboundHttp: number;
  durationMs: number;
}

export async function runSlackSmoke(opts: SmokeOpts): Promise<SmokeResult> {
  const ctx = { hippoRoot: opts.hippoRoot, tenantId: 'default', actor: 'connector:slack' };
  const start = Date.now();
  let calls = 0;

  // Review patch #5: spy globalThis.fetch and throw on any call. This proves
  // the no-outbound-HTTP invariant for real, instead of hardcoding a 0 that
  // would silently lie if a future code path added a fetch() call.
  const origFetch = globalThis.fetch;
  let outboundHttp = 0;
  globalThis.fetch = ((..._args: unknown[]) => {
    outboundHttp++;
    throw new Error('outbound HTTP forbidden during slack smoke');
  }) as typeof fetch;

  try {
    for (let pass = 0; pass < opts.replay; pass++) {
      for (let i = 0; i < opts.count; i++) {
        ingestMessage(ctx, {
          teamId: 'T1',
          channel: { id: 'C1', is_private: false },
          message: { type: 'message', channel: 'C1', user: 'U1', text: `msg ${i}`, ts: `${1700000000 + i}.000100` },
          eventId: `Ev-${i}`,
        });
        calls++;
      }
    }
  } finally {
    globalThis.fetch = origFetch;
  }

  const uniqueMemories = loadAllEntries(opts.hippoRoot).filter((e) => e.tags.includes('source:slack')).length;
  return { uniqueMemories, totalIngestCalls: calls, outboundHttp, durationMs: Date.now() - start };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  // Standalone run: tmp dir, 1000 events, replay=2, log result.
  const { mkdtempSync, rmSync } = await import('fs');
  const { tmpdir } = await import('os');
  const { join } = await import('path');
  const { initStore } = await import('../../src/store.js');
  const root = mkdtempSync(join(tmpdir(), 'hippo-slack-smoke-cli-'));
  try {
    initStore(root);
    const r = await runSlackSmoke({ hippoRoot: root, count: 1000, replay: 2 });
    console.log(JSON.stringify(r, null, 2));
    if (r.uniqueMemories !== 1000) { console.error('FAIL: uniqueMemories !== 1000'); process.exit(1); }
    if (r.outboundHttp !== 0) { console.error('FAIL: outboundHttp !== 0'); process.exit(1); }
    if (r.durationMs / r.totalIngestCalls > 500) { console.error('FAIL: per-call latency >500ms'); process.exit(1); }
    console.log('PASS');
  } finally {
    rmSync(root, { recursive: true, force: true });
  }
}
```

**Step 4: Run to verify it passes**

Run: `npx vitest run tests/slack-smoke-1000.test.ts && node dist/benchmarks/e1.3/slack-1000-event-smoke.js`
Expected: vitest 1 passed; standalone bench prints PASS.

**Step 5: Commit**

```bash
git add benchmarks/e1.3/slack-1000-event-smoke.ts tests/slack-smoke-1000.test.ts
git commit -m "feat(slack): 1000-event smoke benchmark with idempotency assertion"
```

---

## Task 17: Roadmap success — 10 incident-context recall scenarios

**Why:** ROADMAP: "recall surfaces incident context faster than transcript replay on 10 staged scenarios". Define 10 scenarios as a YAML/JSON corpus: each has (a) a synthetic Slack channel transcript (50-200 messages), (b) an incident query, (c) the message ids that constitute the answer. Score recall by `precision@10` against the labelled answer set; compare against a baseline of "linear transcript scan returning the most-recent N messages" — recall must beat that on at least 7/10 scenarios.

**Files:**
- Create: `benchmarks/e1.3/scenarios.json` — 10 scenarios with synthetic transcripts.
- Create: `benchmarks/e1.3/incident-recall-eval.ts` — runs the evaluation.
- Create: `tests/slack-incident-recall.test.ts` — gating test.

**Step 1: Write the failing test**

```ts
// tests/slack-incident-recall.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { initStore } from '../src/store.js';
import { runIncidentRecallEval } from '../benchmarks/e1.3/incident-recall-eval.js';

describe('slack incident recall (success criterion)', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-slack-eval-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('beats transcript-tail baseline on at least 7/10 scenarios', async () => {
    const r = await runIncidentRecallEval({ hippoRoot: root });
    expect(r.scenariosBeaten).toBeGreaterThanOrEqual(7);
    expect(r.scenarios).toHaveLength(10);
  });
});
```

**Step 2: Run to verify it fails**

Run: `npx vitest run tests/slack-incident-recall.test.ts`
Expected: FAIL — module not found.

**Step 3: Implement**

Create `benchmarks/e1.3/scenarios.json` with 10 entries shaped like:

```json
[
  {
    "id": "S1-db-outage",
    "channel": "C-incidents",
    "transcript": [
      { "user": "U1", "text": "checkout API throwing 500s since 14:02 UTC", "ts": "1700000001.000000" },
      { "user": "U2", "text": "confirmed, error rate jumped from 0.01% to 4%", "ts": "1700000010.000000" },
      "...50-200 lines of ambient channel chat..."
    ],
    "query": "what caused the checkout 500s on Nov 14?",
    "answer_ts": ["1700000001.000000", "1700000010.000000"]
  }
]
```

Then `benchmarks/e1.3/incident-recall-eval.ts`:

```ts
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { ingestMessage } from '../../src/connectors/slack/ingest.js';
import { recall, type Context } from '../../src/api.js';

interface Scenario {
  id: string;
  channel: string;
  transcript: Array<{ user: string; text: string; ts: string }>;
  query: string;
  answer_ts: string[];
}

export async function runIncidentRecallEval(opts: { hippoRoot: string }): Promise<{ scenarios: Array<{ id: string; recallPrecision: number; baselinePrecision: number; beat: boolean }>; scenariosBeaten: number }> {
  const here = dirname(fileURLToPath(import.meta.url));
  const scenarios = JSON.parse(readFileSync(join(here, 'scenarios.json'), 'utf-8')) as Scenario[];
  const ctx: Context = { hippoRoot: opts.hippoRoot, tenantId: 'default', actor: 'eval:slack' };
  const results: Array<{ id: string; recallPrecision: number; baselinePrecision: number; beat: boolean }> = [];
  for (const sc of scenarios) {
    // Review patch #8: stamp a per-message sentinel so recall-result matching
    // is deterministic. The sentinel `[s:<scenario>:<ts>]` is unique across
    // the entire eval corpus — content equality + ambient phrase repetition
    // can no longer produce false positives.
    const sentinel = (m: { ts: string }) => `[s:${sc.id}:${m.ts}]`;
    for (const m of sc.transcript) {
      ingestMessage(ctx, {
        teamId: 'T1', channel: { id: sc.channel, is_private: false },
        message: { type: 'message', channel: sc.channel, user: m.user, text: `${m.text} ${sentinel(m)}`, ts: m.ts },
        eventId: `${sc.id}:${m.ts}`,
      });
    }
    const r = recall(ctx, { query: sc.query, limit: 10, scope: `slack:public:${sc.channel}` });
    const recallHit = sc.answer_ts.filter((ts) =>
      r.results.some((res) => res.content.includes(sentinel({ ts }))),
    ).length;
    const recallPrecision = recallHit / sc.answer_ts.length;
    // Baseline: last 10 messages of the raw transcript (before sentinel stamping).
    const tail = sc.transcript.slice(-10);
    const baselineHit = sc.answer_ts.filter((ts) => tail.some((m) => m.ts === ts)).length;
    const baselinePrecision = baselineHit / sc.answer_ts.length;
    results.push({ id: sc.id, recallPrecision, baselinePrecision, beat: recallPrecision > baselinePrecision });
  }
  return { scenarios: results, scenariosBeaten: results.filter((r) => r.beat).length };
}
```

The sentinel approach (review patch #8) is intentionally low-tech: zero changes to `api.recall`'s response shape, deterministic, and faster than a second SQL round-trip to look up artifact_ref. Document the sentinel format in `scenarios.json`'s schema header so the corpus stays portable.

Compose 10 realistic incident scenarios. Each scenario gets ~100-line transcripts where the answer messages are buried in the middle so the tail-baseline misses them.

**Step 4: Run to verify it passes**

Run: `npm run build && npx vitest run tests/slack-incident-recall.test.ts`
Expected: 1 passed (recall beats tail-baseline on ≥7/10).

If recall scores below 7/10, that's a real signal — investigate before forcing the test to pass. The likely failure modes are (a) BM25 tokenizer differences against Slack's casing, (b) scope filter excluding the answer rows, (c) tags not boosting the right candidates.

**Step 5: Commit**

```bash
git add benchmarks/e1.3/scenarios.json benchmarks/e1.3/incident-recall-eval.ts tests/slack-incident-recall.test.ts
git commit -m "feat(slack): 10-scenario incident recall eval (beats tail baseline)"
```

---

## Task 18: Wrap-up — docs, comments, importer relabel

**Why:** Update the inline comments in `src/importers.ts` and `src/capture.ts` (they currently flag E1.x as a future boundary). Update CHANGELOG. Add a brief Slack section to README.

**Files:**
- Modify: `src/importers.ts:86-92` — flip the comment from "When E1.x lands…" to "E1.3 shipped 2026-04-29; ChatGPT/Claude/Cursor exports remain `kind='distilled'` per the original reasoning."
- Modify: `src/capture.ts` — same treatment near the kind decision.
- Modify: `CHANGELOG.md` — add a new section at the top under the next version (likely v0.37.0; bump in /publish-repo).
- Modify: `README.md` — short "Slack ingestion" section pointing at `hippo slack backfill --channel <id>` and the webhook URL pattern.
- Modify: `TODOS.md` — remove the "Importers/capture relabel pass" item from A3 follow-ups (it's done now), and add v2 follow-ups: per-team-id tenant routing, multi-tenant DLQ retention, the eval's content-vs-artifact_ref TODO.

No tests (these are doc updates).

**Step 1: Read the files**

`Read` each file at the named lines. Verify the comment text before flipping.

**Step 2: Apply the edits**

Edit each file. Match the existing tone (no marketing copy in CHANGELOG, no em dashes anywhere).

**Step 3: Build to confirm nothing broke**

Run: `npm run build && npx vitest run`
Expected: build clean, ALL tests pass (including the 17 new files added in Tasks 1-17).

**Step 4: Commit**

```bash
git add src/importers.ts src/capture.ts CHANGELOG.md README.md TODOS.md
git commit -m "docs(slack): wrap up E1.3 — relabel pass + CHANGELOG + README"
```

---

## Definition of Done

A reviewer accepting this work checks:

1. `npx vitest run` — every new test file green (schema, types, signature, idempotency, dlq, scope, transform, ingest, deletion, permission-mirror, webhook-route, tenant-routing, bearer-lockdown, ratelimit, backfill, cli, web-client, smoke-1000, incident-recall, api-remember-after-write) plus the existing suite untouched.
2. `node dist/benchmarks/e1.3/slack-1000-event-smoke.js` — prints `PASS` (1000 unique memories, **zero outbound HTTP proven by globalThis.fetch spy**, <500ms/event).
3. `npx vitest run tests/slack-incident-recall.test.ts` — recall beats tail baseline on ≥7/10 staged scenarios using the per-message sentinel match (review patch #8).
4. `grep -rn "kind: 'raw'" src/connectors/slack/` — exactly one hit (in `transform.ts`).
5. `grep -rn "archiveRawMemory\|archiveRaw" src/connectors/slack/` — exactly one call (in `deletion.ts` via `api.archiveRaw`).
6. Schema migration v17 runs on a fresh db and on a v16 db without errors. Tables created: `slack_event_log`, `slack_cursors`, `slack_dlq`, `slack_workspaces` (review patch #6).
7. Webhook route returns 404 with `SLACK_SIGNING_SECRET` unset (review patch #7), 401 on bad/missing signatures, 200 on valid signature with no Bearer (review patch #2 PUBLIC_ROUTES).
8. `tests/server-bearer-lockdown.test.ts` proves every other `/v1/*` route returns 401 without Bearer (review patch #2).
9. `tests/api-remember-after-write.test.ts` proves the `afterWrite` hook is transactional (review patch #3 — rollback drops the memory).
10. `tests/slack-deletion.test.ts` includes the cross-tenant case (review patch #1 — tenant A cannot archive tenant B's row).
11. `tests/slack-permission-mirror.test.ts` covers no-scope default-deny, mismatched-scope-zero, and tenant-mismatched-scope (review patch #4).
12. `hippo slack dlq list` prints rows; `hippo slack backfill --channel C0000` errors clearly when `SLACK_BOT_TOKEN` is unset.
13. `TODOS.md` updated with the relabel item removed and the v2 follow-ups added.
14. The CHANGELOG entry says what shipped, not what was tried.

## Out of scope (track for v2)

- Slash-command write-backs (`/hippo remember <text>`). V1 is read-only.
- Thread-aware ranking (treat `thread_ts` as a parent boost).
- DLQ replay command (`hippo slack dlq retry <id>`).
- Eval scoring by `artifact_ref` instead of sentinel match (the sentinel is fine for the synthetic corpus; real-traffic evals may want artifact_ref, which would extend `RecallResultItem`).
- Incremental real-time backfill (today's loop drains the channel; for live workspaces add a "stop at cursor" mode).
- Operator UI for `slack_workspaces` (today's table is populated via direct SQL; v2 adds `hippo slack workspaces add --team T --tenant t`).
