# v0.39.0 Security Hardening + GDPR Path A Implementation Plan (v2)

> **For Claude:** Subagent-driven execution. Codex on the v2 plan first to verify code-shape grounding.

**Goal:** Apply 21 fixes across v0.36/v0.37 retrospective + B3 review residue, ship as v0.39.0 security hardening release.

**Source:** Retroactive `/codex` on v0.36 A1 (4 P1 + 6 P2 + 2 NIT). Retroactive `/codex` on v0.37 E1.3 (2 P1 + 7 P2 + 2 NIT). User decision: **all fixed, Path A on GDPR archive.**

**Branch:** `feat/v0.39-security-hardening` (branched from master @ 35da47a, working tree clean).

**Tech Stack:** TypeScript, node:sqlite, vitest. Same constraints as B3.

---

## Revision log

- **v1 (2026-04-30):** Initial plan from retrospective findings. Codex flagged 6 P1 + 6 P2 + 1 NIT — all root-caused to speculative code shapes (plan didn't read actual signatures).
- **v2 (2026-04-30):** Grounded every fix against actual code:
  - `supersede()` (api.ts:260) uses 2× `writeEntry` with own DB handles; CAS approach revised to do the UPDATE-with-WHERE directly via a fresh db handle, then `writeEntryDbOnly(db, newEntry)` inside the SAME transaction (introduced in commit 1 fix 1.0), then `writeEntryMirrors` after COMMIT.
  - `archiveRaw()` (api.ts:318) ALREADY tenant-checks at line 333. Removed redundant fix; promote/forget still need it.
  - `archiveRawMemory(db, id, opts)` (raw-archive.ts:26) ALREADY uses SAVEPOINT + FTS purge + audit. Path A is a one-line change at line 41 (redacted JSON instead of full row).
  - `validateApiKey(db, plaintext)` (auth.ts:61) is the real surface; key format is `scrypt$saltHex$hashHex` over the FULL plaintext key. Timing fix: precompute `DUMMY_HASH` once at module load via existing `hashKey()`, route miss path through `verifyKey(plaintext, DUMMY_HASH)` so scrypt always runs.
  - Real route paths: `/v1/memories/:id/archive`, `/v1/memories/:id/supersede`, `/v1/memories/:id/promote`, `/v1/memories/:id`, `/v1/auth/keys/:keyId`, `/v1/audit`, etc. Enumerated from server.ts:409-494.
  - `McpContext` (mcp/server.ts:76) has only `hippoRoot, tenantId, actor`. Add optional `clientKey?: string` field; HTTP server builds it from `hash(bearer + remoteAddr)`; stdio defaults to `'stdio-${pid}'`.
  - Slack DLQ replay needs original signature/timestamp stored. Migration v19 adds `signature TEXT, slack_timestamp TEXT` columns alongside `team_id, bucket, retry_count`.
  - `writeEntry` opens its own DB. Single-tx fix for Slack ingest race: use `afterWrite` hook (already exists from B3 in remember()) to do the `INSERT OR IGNORE slack_event_log` atomically inside writeEntry's transaction. The hook receives the open db handle.
  - Test design: replace `Promise.all` race tests with direct CAS-SQL assertion tests (verify `WHERE superseded_by IS NULL` returns changes=0 on the second concurrent attempt).
  - Auth timing claim: "reduced timing signal" not "equalized" — DB lookup branch still differs.
  - Manifest list explicit in commit 6.
- **v3 (post-codex round 2):** PROCEED-WITH-FIXES verdict. Applied: (1) supersede dangling-pointer closed via new helper in store.ts; (2) commit 2 MCP scope tightened to actual MCP tools (recall/remember/outcome); (3) commit 3 ingest snippet uses real var name `input.eventId`, explicit changes-check + DuplicateEventError throw; (4) commit 5 bearer route list corrected to actual server.ts routes (12 authed); (5) success criterion 2 mentions v20 backfill.
- **v4 (post-codex round 3):** Applied writeEntryDbOnly/writeEntryMirrors split, hippo_share fix, Risk 3 reframe, commit 2 message refresh, TODOS update.
- **v5 (post-codex round 4):** PROCEED-WITH-FIXES verdict. Applied: (1) supersede actually calls `writeEntryMirrors` after COMMIT (was missing in v4); (2) shareMemory signature is now `{ force?: boolean; tenantId?: string }` (tenantId optional for backward compat with existing `{ force: true }` callers; MCP/REST/CLI callers MUST pass it); (3) added Fix 2.5 — hippo_outcome's readEntry call gains tenant filter; (4) v0.40 deferred section no longer mentions writeEntry-with-db (done in v0.39); (5) audit-order change documented as a correctness improvement (audit committed atomically with row, not after mirrors).

---

## Success criteria (verifiable)

1. All 5 CRITICAL findings closed with regression tests proving cross-tenant attack is blocked.
2. Path A on `archiveRawMemory`: archived rows have `payload_json = JSON.stringify({redacted:true, archived_at, tenant_id, kind, reason})`. Migration v20 backfill redacts existing rows, extracting `tenant_id`/`kind` from the existing `payload_json` before overwriting. (v19 lands slack_dlq columns; v20 lands the Path A backfill.)
3. Bearer lockdown test parameterized over the full route table from `src/server.ts:parseRequest`.
4. Auth timing leak reduced: precomputed `DUMMY_HASH` at module init, miss path runs `verifyKey(plaintext, DUMMY_HASH)`. (Documented as "reduced timing signal" — DB hit/miss still measurable in cache.)
5. MCP `hippo_recall`, `hippo_remember`, `hippo_outcome` route through `src/api.ts` (audit + cross-tenant guards uniform); `hippo_share` gets a tenant guard via Fix 2.4. Other MCP tools (context, status, learn, conflicts, resolve, peers) keep their existing lower-level call sites in v0.39 — full audit deferred to v0.40.
6. SSE auth-rebind on a heartbeat (default 60s) + `MCP_SSE_MAX_AGE_SEC` (default 3600).
7. Slack ingest race closed via `afterWrite` hook — INSERT OR IGNORE slack_event_log + UPDATE memory_id all run inside writeEntry's transaction.
8. Slack deletion idempotency: extend `archiveRawMemory` with optional `afterArchive` hook that runs inside the same SAVEPOINT.
9. Slack DLQ records `team_id`, `bucket`, `retry_count`, `signature`, `slack_timestamp`. CLI `hippo slack dlq replay <id>` re-validates signature using stored values, then re-runs ingest.
10. Default-deny lower-level loader: documented + tested via MCP recall (after MCP-via-api lands).
11. Slack web-client `oldest` URL param test.
12. Slack signing-secret rotation: accept `SLACK_SIGNING_SECRET_PREVIOUS` during rollover.
13. PUBLIC_ROUTES regression tests (trailing slash, encoded slash, wrong method, query string).
14. Graceful shutdown: signal handler awaits `stop()` before `process.exit(0)`.
15. p99 latency claim retracted in CHANGELOG/TODOS (Path C — cheap and honest; revisit in v0.40+ if a real user asks).
16. Soak harness documented as scaffold-only, not a release gate.
17. All 931 tests still pass + ~25 new regression tests = ~956 total.
18. Schema migration v19 idempotent on existing v18 hippo databases.

## Out of scope (defer to v0.40.0)

- B3 dlPFC follow-ups (sequential-learning adapter contract, MCP/REST session_id plumbing — separate from MCP clientKey added here, vlPFC interference, --no-propagate, enforceDepthCap refactor, goal_recall_log compaction, paired_ab.py)
- New features
- A2 HTTP API hardening polish
- Hitting <50ms p99 (claim retracted, not solved)

---

## Task layout

Six commits, ordered by surface area. Each independently testable.

- **Commit 1:** api.ts cross-tenant + supersede CAS (CRITICAL #1, #2, #4 + tests)
- **Commit 2:** MCP cross-tenant outcome + MCP-via-api (CRITICAL #3 + MEDIUM #9)
- **Commit 3:** Slack ingestion hardening (CRITICAL #5 + MEDIUM #11-15 + NITs)
- **Commit 4:** Migration v20 + GDPR Path A redaction (HIGH #7) — slack_dlq schema additions land in commit 3's migration v19, see sequencing note below
- **Commit 5:** Server hardening (HIGH #6, #8 + MEDIUM #10 + NITs)
- **Commit 6:** Benchmark honesty + docs + version bump 0.39.0

**Note on commit 3 / 4 sequencing:** commit 3 lands code that *uses* new slack_dlq columns (signature, team_id, bucket); commit 4 lands the migration that adds those columns. To keep tests passing at every commit boundary, **migration v19 lands in commit 3** alongside the slack code, and commit 4 only adds the Path A redaction logic + a v20 backfill UPDATE for raw_archive. Sequence: commit 3 = code + migration v19; commit 4 = redaction code + migration v20 (backfill-only, redacts existing rows).

Revised commit list:
- **Commit 3:** Slack ingestion hardening + migration v19 (slack_dlq columns)
- **Commit 4:** GDPR Path A redaction + migration v20 (raw_archive backfill)

Schema target: v18 → v19 (commit 3) → v20 (commit 4). Then test pin moves 18→20.

---

## Commit 1 — api.ts cross-tenant + supersede CAS

**Files:**
- Modify: `src/store.ts` (add `writeEntryDbOnly` — composable lower-level variant)
- Modify: `src/api.ts` (promote, forget, supersede, authCreate)
- Modify: `src/server.ts` (authCreate route handler at /v1/auth/keys POST)
- Test: `tests/v039-api-tenant-isolation.test.ts` (create)

**Fixes:** CRITICAL #1 (promote), CRITICAL #2 (authCreate), CRITICAL #4 (supersede + dangling-pointer fix).

### Fix 1.0 — Split writeEntry: DB-only + mirrors (prerequisite for 1.3)

**Location:** `src/store.ts:950` (existing `writeEntry` — verified body at lines 950-1000).

Current `writeEntry(hippoRoot, entry, opts)` does THREE things in order:
1. **DB write** inside a SAVEPOINT: `upsertEntryRow(db, entry)` + `opts.afterWrite?(db, id)`.
2. **Filesystem mirrors** AFTER the SAVEPOINT releases: `writeMarkdownMirror(hippoRoot, entry)` + `writeIndexMirror(hippoRoot, buildIndexFromDb(db))`.
3. **Audit** via `audit(db, 'remember', ...)`.

For supersede's case, the new memory write needs to share the OUTER `BEGIN IMMEDIATE` of the CAS, but mirrors must run AFTER the outer COMMIT (so a rolled-back tx doesn't leave orphan markdown — same invariant the existing code preserves via post-SAVEPOINT placement).

**Refactor: split into two helpers.**

```ts
// New: DB-only path. Caller owns the open `db` handle. Runs SAVEPOINT +
// upsert + afterWrite hook + audit. Audit uses the same db. Caller is
// responsible for: opening db, BEGIN/COMMIT (if wrapping in a larger tx),
// closing db, AND calling writeEntryMirrors after the larger tx commits.
export function writeEntryDbOnly(
  db: DatabaseSyncLike,
  entry: MemoryEntry,
  opts?: { actor?: string; afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void },
): void {
  db.exec('SAVEPOINT write_entry');
  try {
    upsertEntryRow(db, entry);
    if (opts?.afterWrite) opts.afterWrite(db, entry.id);
    db.exec('RELEASE SAVEPOINT write_entry');
  } catch (e) {
    try {
      db.exec('ROLLBACK TO SAVEPOINT write_entry');
      db.exec('RELEASE SAVEPOINT write_entry');
    } catch {}
    throw e;
  }
  audit(db, 'remember', entry.id, {
    kind: entry.kind ?? 'distilled',
    scope: entry.scope ?? null,
  }, opts?.actor ?? 'cli', entry.tenantId);
}

// New: filesystem mirrors. Caller passes hippoRoot + an open db (for buildIndexFromDb).
// Runs AFTER the outer transaction commits.
export function writeEntryMirrors(hippoRoot: string, db: DatabaseSyncLike, entry: MemoryEntry): void {
  writeMarkdownMirror(hippoRoot, entry);
  writeIndexMirror(hippoRoot, buildIndexFromDb(db));
}

// Refactored: existing public API stays the same.
export function writeEntry(hippoRoot: string, entry: MemoryEntry, opts?: WriteEntryOpts): void {
  initStore(hippoRoot);
  const db = openHippoDb(hippoRoot);
  try {
    writeEntryDbOnly(db, entry, opts);
    writeEntryMirrors(hippoRoot, db, entry);
  } finally {
    closeHippoDb(db);
  }
}
```

Supersede uses `writeEntryDbOnly` inside the BEGIN IMMEDIATE; calls `writeEntryMirrors` after COMMIT (db handle still open). All existing tests pass — `writeEntry` semantics unchanged for callers.

**Audit-order note (codex round 4):** Original `writeEntry` runs audit AFTER mirrors (src/store.ts:982-984). The refactor moves audit INSIDE `writeEntryDbOnly` (before mirrors). This is a *correctness improvement*, not a regression: audit now commits atomically with the row INSERT, so a mirror failure cannot leave a recorded audit entry without a corresponding DB row. Document the change in CHANGELOG under "Hardening" rather than "BREAKING" since the user-visible audit trail still matches reality.

### Fix 1.1 — promote() tenant check

**Location:** `src/api.ts:224` (existing function body).

Read the existing promote function. Add at the top of the body (before `promoteToGlobal`):

```ts
const ownerDb = openHippoDb(ctx.hippoRoot);
try {
  const row = ownerDb.prepare(
    `SELECT tenant_id FROM memories WHERE id = ?`,
  ).get(opts.id) as { tenant_id: string } | undefined;
  // Match archiveRaw's not-found semantics: don't leak whether the id exists in another tenant.
  if (!row || row.tenant_id !== ctx.tenantId) {
    throw new Error(`memory not found: ${opts.id}`);
  }
} finally {
  closeHippoDb(ownerDb);
}
```

Apply the same pattern to `forget()` (api.ts:190) — current forget calls `forgetEntry(ctx.hippoRoot, id, ctx.tenantId)` which DOES tenant-filter at the SQL level (verify by reading src/store.ts:forgetEntry). If forgetEntry already filters by tenantId and returns "not found" on cross-tenant access, leave it alone — no fix needed. Reading is mandatory before modifying.

### Fix 1.2 — authCreate force ctx.tenantId

**Location:** `src/api.ts:374` + `src/server.ts:/v1/auth/keys POST handler`.

The plan's v1 said `AuthCreateOpts.tenantId` exists. Verify by reading the actual interface. If `tenantId` is in `AuthCreateOpts`:
- Remove the field from the interface
- Body of `authCreate` already used `ctx.tenantId` (verify); just stop passing the body field
- HTTP route handler in src/server.ts: ignore `body.tenantId`, pass only `{ label }` to `authCreate(ctx, { label })`

If `AuthCreateOpts.tenantId` does NOT exist (the body might already be using ctx.tenantId via Context propagation), find where the HTTP handler reads `body.tenantId` and verify it's not silently allowing override. Mark fix as already-fixed in that case.

### Fix 1.3 — supersede CAS

**Location:** `src/api.ts:260` (existing function body — readEntry + 2× writeEntry).

The shape: readEntry returns the old memory; supersede mutates `old.superseded_by = newEntry.id`; calls writeEntry twice (each opens its own DB handle); then writes audit on a third DB.

The race: between readEntry's "is this not yet superseded?" check and writeEntry of old, another process can supersede the same row. Both win, two new memories chain off the same old.

**Fix approach (single-handle CAS via `writeEntryDbOnly` from fix 1.0):** open a fresh db handle in api.ts:supersede; do BEGIN IMMEDIATE; do the CAS as direct SQL UPDATE with WHERE clause; if changes=0 throw CONFLICT; call `writeEntryDbOnly(db, newEntry)` so the new memory upsert + afterWrite + audit run inside the SAME transaction; append the user-facing `supersede` audit row; COMMIT. After COMMIT, call `writeEntryMirrors(ctx.hippoRoot, db, newEntry)` for the markdown/index mirrors (matching existing writeEntry's post-savepoint mirror invariant). The old memory's strength/last_retrieved/etc. are NOT updated — only the superseded_by pointer changed via the CAS UPDATE.

```ts
export function supersede(
  ctx: Context,
  oldId: string,
  newContent: string,
): { ok: true; oldId: string; newId: string } {
  // Read old (tenant-scoped). readEntry already filters by tenantId.
  const old: MemoryEntry | null = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
  if (!old) {
    throw new Error(`Memory not found: ${oldId}`);
  }
  // Guard: not already superseded. The CAS below races-safely closes the
  // window between this read and the write; this check just gives a clearer
  // error message in the common single-writer case.
  if (old.superseded_by) {
    throw new Error(
      `Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`,
    );
  }

  const newEntry = createMemory(newContent, {
    layer: old.layer ?? Layer.Episodic,
    tags: [...old.tags],
    pinned: old.pinned,
    source: old.source,
    confidence: 'verified',
    tenantId: ctx.tenantId,
  });

  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
  // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row) atomically.
  // Two concurrent supersedes: exactly one CAS wins (changes=1), the other
  // gets changes=0 and throws.
  const db = openHippoDb(ctx.hippoRoot);
  try {
    db.exec('BEGIN IMMEDIATE');
    try {
      // 1. CAS: only succeed if old.superseded_by IS NULL.
      const result = db.prepare(`
        UPDATE memories
        SET superseded_by = ?
        WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
      `).run(newEntry.id, oldId, ctx.tenantId);
      if ((result.changes ?? 0) === 0) {
        db.exec('ROLLBACK');
        throw new Error(`Memory ${oldId} already superseded by another writer`);
      }
      // 2. Write new memory inside same tx via writeEntryDbOnly (DB-only path).
      writeEntryDbOnly(db, newEntry, { actor: ctx.actor });
      // 3. Audit row inside same tx so chain is committed atomically.
      appendAuditEvent(db, {
        tenantId: ctx.tenantId,
        actor: ctx.actor,
        op: 'supersede',
        targetId: oldId,
        metadata: { newId: newEntry.id },
      });
      db.exec('COMMIT');
    } catch (err) {
      try { db.exec('ROLLBACK'); } catch {}
      throw err;
    }
    // Mirrors after COMMIT, while db handle is still open (read-only at this point).
    // Same invariant as existing writeEntry: mirror failure leaves disk MISSING
    // the markdown for the new memory (self-heals on next backfill via
    // writeIndexMirror reading the DB). DOES NOT desync DB or rollback the supersede.
    try {
      writeEntryMirrors(ctx.hippoRoot, db, newEntry);
    } catch (mirrorErr) {
      console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
    }
  } finally {
    closeHippoDb(db);
  }

  // Write the new memory inside the SAME transaction as the CAS — no
  // dangling-pointer window. Requires writeEntryDbOnly (added in this commit
  // to src/store.ts) which takes an external db handle and does NOT open
  // its own. The original writeEntry stays as the public single-handle API
  // for ergonomic callers; writeEntryDbOnly is the lower-level variant that
  // composable transactions use.
  // (See src/store.ts:writeEntryDbOnly — exposed in this commit.)
  // The full sequence inside BEGIN IMMEDIATE: CAS update on old → 
  // writeEntryDbOnly(new) → supersede audit row → COMMIT. (Note: writeEntryDbOnly
  // emits its OWN 'remember' audit for the new memory; the explicit appendAuditEvent
  // for op='supersede' carries the user-facing intent + chain pointer.) If any
  // step throws, the entire chain rolls back. Closes the dangling-pointer hole.

  return { ok: true, oldId, newId: newEntry.id };
}
```

**No dangling-pointer trade-off:** writeEntryDbOnly (fix 1.0) lets the new memory write join the BEGIN IMMEDIATE transaction. CAS + audit + new memory all commit atomically OR all roll back. Codex round-2 P1 closed.

### Tests (commit 1)

`tests/v039-api-tenant-isolation.test.ts`:

1. **promote: cross-tenant denied.** Tenant A pushes memory m1; tenant B promote(m1) → throws "memory not found".
2. **forget: cross-tenant denied.** Same shape; verify forget already enforces (might be "no fix needed, but test to lock invariant").
3. **archiveRaw: cross-tenant denied.** Verify the existing fix at api.ts:333.
4. **authCreate: body tenantId ignored.** HTTP POST /v1/auth/keys with bearer=tenantA-key, body=`{tenantId:'B', label:'x'}`. Resulting key belongs to A.
5. **supersede CAS: direct SQL race.** Open the test DB, manually pre-set `superseded_by = 'pre-existing'` on the row, call supersede() → throws "already superseded by another writer". Asserts the CAS UPDATE returns changes=0.
6. **supersede CAS: clean path.** Normal supersede → throws nothing, both rows land, chain is correct.
7. **supersede CAS: tenant-scoped CAS.** Tenant A's supersede attempt on tenant B's row → throws "Memory not found" (readEntry returns null at the top).

### Commit message
```
fix(api): cross-tenant authorization + supersede CAS + writeEntryDbOnly

- store.ts: factor writeEntryDbOnly (composable variant taking external db);
  writeEntry becomes a thin wrapper. Public semantics unchanged.
- api.ts promote: verify memory belongs to ctx.tenantId before promoteToGlobal
- api.ts forget: confirm tenant scoping (already enforced via forgetEntry)
- api.ts authCreate: ignore body.tenantId, force ctx.tenantId at HTTP layer
- api.ts supersede: BEGIN IMMEDIATE wrapping CAS-update on old + writeEntryDbOnly
  for new + audit row, all atomic. No dangling-pointer window.
- 7 regression tests for each cross-tenant attack path + CAS race
```

---

## Commit 2 — MCP cross-tenant + MCP-via-api

**Files:**
- Modify: `src/mcp/server.ts` (McpContext + lastRecalledIds keying + handlers via api.ts)
- Modify: `src/server.ts` (build clientKey when constructing McpContext for HTTP)
- Test: `tests/v039-mcp-tenant-isolation.test.ts` (create)

**Fixes:** CRITICAL #3, MEDIUM #9.

### Fix 2.1 — McpContext.clientKey + tenant-keyed lastRecalledIds

**Location:** `src/mcp/server.ts:76` (interface) + module scope (lastRecalledIds).

```ts
export interface McpContext {
  hippoRoot: string;
  tenantId: string;
  actor: string;
  /**
   * Per-client key for state isolation under HTTP-MCP. For stdio: 'stdio-${pid}'
   * (one process = one client). For HTTP-SSE / HTTP MCP: hash(bearer + remoteAddr)
   * built by src/server.ts when constructing McpContext for the request.
   * Optional for backwards compatibility; defaults to `${tenantId}:default`.
   */
  clientKey?: string;
}
```

Replace `let lastRecalledIds: string[] = []` with `const lastRecalledIds = new Map<string, string[]>()` keyed by `ctx.clientKey ?? \`${ctx.tenantId}:default\``. Update both writers (the recall handler that sets it) and readers (the outcome handler).

### Fix 2.2 — clientKey construction in server.ts for HTTP MCP

**Location:** `src/server.ts:/mcp` route handler.

Find where the MCP request is dispatched in src/server.ts (search for `handleMcpRequest`). Where the McpContext is constructed for the HTTP request, add:

```ts
import { createHash } from 'node:crypto';

function buildMcpClientKey(req: IncomingMessage): string {
  const auth = readAuthHeader(req);
  const tokenHash = auth.kind === 'bearer'
    ? createHash('sha256').update(auth.token).digest('hex').slice(0, 16)
    : 'noauth';
  const addr = req.socket.remoteAddress ?? 'unknown';
  return `http:${tokenHash}:${addr}`;
}

// when building McpContext for HTTP:
const mcpCtx: McpContext = {
  hippoRoot,
  tenantId,
  actor: 'mcp',
  clientKey: buildMcpClientKey(req),
};
```

For stdio (the existing fall-through path that doesn't pass McpContext), default `clientKey` to `'stdio-' + process.pid` inside mcp/server.ts when `ctx.clientKey` is undefined.

### Fix 2.3 — MCP handlers route through src/api.ts (scoped)

**Location:** `src/mcp/server.ts` tool dispatch (cases at :259 hippo_recall, :276 hippo_remember, :311 hippo_outcome).

**Scope (codex round-2 P1):** MCP only exposes 10 tools today (recall, remember, outcome, context, status, learn, conflicts, resolve, share, peers). Of those, only `hippo_recall`, `hippo_remember`, `hippo_outcome` have api.ts counterparts that route through cross-tenant guards + audit. The rest call lower-level helpers directly (e.g., hippo_share → shareMemory, hippo_resolve → conflict helpers, hippo_context/status/learn → store/search helpers).

**This commit:** route `hippo_recall`, `hippo_remember`, `hippo_outcome` through `recall(ctx, ...)`, `remember(ctx, ...)`, and the existing outcome flow (which `lastRecalledIds` keying already protects per Fix 2.1). Build `Context` from `McpContext`: `{ hippoRoot: mcpCtx.hippoRoot, tenantId: mcpCtx.tenantId, actor: 'mcp' }`.

**Critical exception — hippo_share (codex round-3 P1):** `shareMemory(localRoot, id)` at src/shared.ts:282 calls `readEntry(localRoot, id)` WITHOUT tenant filter. MCP `hippo_share` tool exposes this to any bearer. Tenant A can copy tenant B's memory to global. Real cross-tenant exfiltration. **Must fix in v0.39 (Fix 2.4 below).**

**v0.40 follow-up (added to TODOS in commit 6):** for the remaining 6 MCP tools (context, status, learn, conflicts, resolve, peers), audit each readEntry/loadSearchEntries call site for tenant filtering. Some already pass tenantId (verified: search* helpers at src/shared.ts:96-97, 172-173 do); others may not. Full audit + guard pass deferred to v0.40 — none of the remaining 6 are as direct an exfil path as share.

**Negative scope:** the original v1 plan said "MCP forget cross-tenant denied" test — there is no MCP forget tool. Drop that test. The cross-tenant guard for forget is exercised by the api.ts test in commit 1.

### Fix 2.4 — hippo_share cross-tenant guard

**Location:** `src/shared.ts:282` (shareMemory) + MCP handler at `src/mcp/server.ts:436` (case 'hippo_share').

**Bug:** `shareMemory(localRoot, id)` reads via `readEntry(localRoot, id)` (line 277) — no tenant filter. Bearer for tenant A calling MCP `hippo_share` with tenant B's id reads + copies tenant B's memory to the global store.

**Fix (path a — fix at the source, backward-compat):**

```ts
// src/shared.ts: extend signature; tenantId is OPTIONAL so existing
// callers passing only { force: true } still compile. Callers that pass
// tenantId get tenant-scoped readEntry; callers that don't get the legacy
// unscoped path. MCP/REST hosts MUST pass tenantId.
export function shareMemory(
  localRoot: string,
  id: string,
  options: { force?: boolean; tenantId?: string } = {},
): MemoryEntry | null {
  const entry = readEntry(localRoot, id, options.tenantId);  // tenantId may be undefined → unscoped legacy path
  if (!entry) throw new Error(`Memory not found: ${id}`);
  // ...rest unchanged
}
```

`readEntry(hippoRoot, id, tenantId?)` already supports the optional tenantId at src/store.ts:1007 — passing it filters by tenant, returning null on cross-tenant attempts; passing undefined returns first match (legacy behavior).

**Required call-site updates (must pass tenantId):**
- `src/mcp/server.ts:436` (`case 'hippo_share'`): pass `ctx.tenantId`.
- `src/server.ts` if there's a REST share endpoint (verify; if exists, pass `tenantId`).
- `src/cli.ts:5758` (`hippo share` command): pass resolved tenantId.

**Acceptable legacy unscoped sites (v0.40 audit):**
- `src/shared.ts:370` internal call — review whether this is local-only single-tenant context or a real exfil path.

The "Memory not found" error matches archiveRaw's not-found semantics, doesn't leak existence.

### Fix 2.5 — hippo_outcome readEntry tenant filter

**Location:** `src/mcp/server.ts:317` (case 'hippo_outcome' calls readEntry).

Codex round 4 spotted: `readEntry(hippoRoot, id)` without tenantId. Even though `lastRecalledIds` keying (Fix 2.1) protects against cross-client outcome poisoning, the readEntry call itself can leak whether a memory exists in another tenant via timing/error-message difference.

**Fix:** pass `ctx.tenantId` to readEntry. Same shape as fix 2.4.

### Tests (commit 2)

`tests/v039-mcp-tenant-isolation.test.ts`:

1. **lastRecalledIds keyed by clientKey.** Two simulated clients with different clientKey values both recall against the same tenant. Each client's outcome only touches its own recalled set.
2. **MCP recall produces audit_log with actor='mcp'.** Proves the api.ts route reaches audit.
3. **MCP remember produces audit_log with actor='mcp'.** Same shape — the remember tool now routes through api.ts.
4. **stdio backward-compat.** McpContext with no clientKey provided → uses `stdio-${pid}` fallback, no errors.
5. **Cross-tenant outcome blocked under HTTP-MCP.** Two HTTP-MCP simulated clients with different bearers but same tenant — outcome from client B doesn't touch client A's recall set (already covered by #1, but exercise via the actual buildMcpClientKey path).
6. **hippo_share cross-tenant denied.** Tenant A pushes memory m1; tenant B's MCP context calls `hippo_share` with id=m1 → throws "Memory not found"; global store does NOT receive m1.

### Commit message
```
fix(mcp): client-key isolation + recall/remember/outcome via api.ts + hippo_share guard

- McpContext gains optional clientKey; HTTP-MCP builds hash(bearer + remote addr),
  stdio defaults to 'stdio-${pid}'
- lastRecalledIds becomes Map<clientKey, string[]> — outcome from client B
  cannot touch client A's recall set
- hippo_recall/hippo_remember route through src/api.ts (audit + guards uniform)
- shareMemory accepts tenantId, passes through readEntry filter — closes
  hippo_share cross-tenant exfiltration via MCP
- 6 regression tests including hippo_share cross-tenant denied
- v0.40 follow-up: tenant-guard audit on remaining MCP tools (context/status/
  learn/conflicts/resolve/peers)
```

---

## Commit 3 — Slack ingestion hardening + migration v19

**Files:**
- Modify: `src/connectors/slack/tenant-routing.ts` (fail-closed on unknown team)
- Modify: `src/connectors/slack/ingest.ts` (use afterWrite hook for atomic event_log + memory)
- Modify: `src/connectors/slack/deletion.ts` (use afterArchive hook — added in raw-archive.ts)
- Modify: `src/raw-archive.ts` (extend with optional afterArchive hook callback)
- Modify: `src/connectors/slack/dlq.ts` (write team_id, bucket, signature, slack_timestamp)
- Modify: `src/connectors/slack/signature.ts` (rotation: read SLACK_SIGNING_SECRET_PREVIOUS too)
- Modify: `src/server.ts` (slack-events handler uses fail-closed routing + DLQ team_id capture)
- Modify: `src/cli.ts` (cmdSlack `dlq replay` subcommand)
- Modify: `src/db.ts` (migration v19: slack_dlq columns)
- Test: `tests/v039-slack-hardening.test.ts` (create)

**Fixes:** CRITICAL #5, MEDIUM #11-15, NITs (web-client `oldest`, signing rotation).

### Fix 3.1 — Migration v19: slack_dlq columns

**Location:** `src/db.ts` MIGRATIONS array.

Append after v18:

```ts
{
  version: 19,
  up: (db) => {
    if (!tableHasColumn(db, 'slack_dlq', 'team_id')) {
      db.exec(`ALTER TABLE slack_dlq ADD COLUMN team_id TEXT`);
    }
    if (!tableHasColumn(db, 'slack_dlq', 'bucket')) {
      db.exec(`ALTER TABLE slack_dlq ADD COLUMN bucket TEXT NOT NULL DEFAULT 'parse_error'`);
      // SQLite ALTER TABLE cannot add CHECK; bucket value enforcement is app-level.
    }
    if (!tableHasColumn(db, 'slack_dlq', 'retry_count')) {
      db.exec(`ALTER TABLE slack_dlq ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0`);
    }
    if (!tableHasColumn(db, 'slack_dlq', 'signature')) {
      db.exec(`ALTER TABLE slack_dlq ADD COLUMN signature TEXT`);
    }
    if (!tableHasColumn(db, 'slack_dlq', 'slack_timestamp')) {
      db.exec(`ALTER TABLE slack_dlq ADD COLUMN slack_timestamp TEXT`);
    }
  },
}
```

Bump `CURRENT_SCHEMA_VERSION = 19`. Update test pins (b3, a3, a5, pr2) from 18 → 19.

### Fix 3.2 — Unknown team_id fail-closed (CRITICAL #5)

**Location:** `src/connectors/slack/tenant-routing.ts`.

Replace `resolveTenantForTeam` to count slack_workspaces rows and fail closed:

```ts
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;
  if (row) return row.tenant_id;

  const total = (db.prepare(`SELECT COUNT(*) AS c FROM slack_workspaces`).get() as { c: number }).c;
  if (total === 0) {
    // Single-workspace install: env fallback is safe.
    return process.env.HIPPO_TENANT?.trim() || 'default';
  }

  if (process.env.SLACK_ALLOW_UNKNOWN_TEAM_FALLBACK === '1') {
    return process.env.HIPPO_TENANT?.trim() || 'default';
  }

  return null; // fail closed
}
```

In `src/server.ts` slack-events route handler: if `resolveTenantForTeam` returns null, write to DLQ with `bucket='unroutable'` (capturing team_id from raw body), respond 200 OK to Slack (mandatory ACK), do NOT ingest.

### Fix 3.3 — afterArchive hook in raw-archive.ts

**Location:** `src/raw-archive.ts:26`.

Extend `ArchiveOpts` and the function body:

```ts
export interface ArchiveOpts {
  reason: string;
  who: string;
  /**
   * Optional hook invoked inside the same SAVEPOINT as the archive INSERT/DELETE.
   * Used by Slack deletion to mark the deletion event seen atomically — a crash
   * mid-archive must not leave the deletion event untracked. Throwing rolls back
   * the entire archive.
   */
  afterArchive?: (db: DatabaseSyncLike, archivedMemoryId: string) => void;
}

// in the function body, after the audit append + before RELEASE SAVEPOINT:
if (opts.afterArchive) {
  opts.afterArchive(db, id);
}
```

In `src/connectors/slack/deletion.ts`, pass `afterArchive` that calls `markEventSeen(db, eventId, archivedMemoryId)` using the SAME `db` handle.

### Fix 3.4 — Ingest race fix via afterWrite

**Location:** `src/connectors/slack/ingest.ts`.

The B3 work added an `afterWrite` hook to `remember()` (api.ts:RememberOpts.afterWrite, threaded into store.ts:writeEntry SAVEPOINT). Current ingest.ts already uses it at line 64 to call `markEventSeen(db, input.eventId, memoryId)` — but `markEventSeen` is a plain `INSERT OR IGNORE` that silently succeeds on duplicate, leaving the second worker's memory write committed.

**Fix:** replace the silent INSERT OR IGNORE with an explicit changes-check that throws on duplicate:

```ts
// In ingest.ts ingestMessage afterWrite (replacing the markEventSeen call):
afterWrite: (db, memoryId) => {
  const inserted = db.prepare(
    `INSERT OR IGNORE INTO slack_event_log (event_id, ingested_at, memory_id) VALUES (?, ?, ?)`,
  ).run(input.eventId, new Date().toISOString(), memoryId);
  if ((inserted.changes ?? 0) === 0) {
    // Two-worker race: another writer reserved this event_id between the
    // pre-check and the write. Throw to roll back the SAVEPOINT in writeEntry
    // — the memory row gets discarded, idempotency holds.
    throw new DuplicateEventError(input.eventId);
  }
},
```

`input.eventId` (camelCase) — verified actual variable name at src/connectors/slack/ingest.ts:13 + :38. Wrap the outer `remember()` call in try/catch mapping `DuplicateEventError` to `{status: 'skipped_duplicate'}`. Other errors propagate.

The pre-check `hasSeenEvent` at ingest.ts:38 stays as a fast-path optimisation (avoids opening the SAVEPOINT for known duplicates), but is no longer load-bearing for correctness — the afterWrite throw is.

### Fix 3.5 — DLQ writes capture team_id, bucket, signature, slack_timestamp

**Location:** `src/connectors/slack/dlq.ts:writeToDlq`.

Extend the function signature:

```ts
export interface DlqWriteOpts {
  tenantId: string | null;  // null = unroutable
  teamId?: string;
  rawPayload: string;
  error: string;
  bucket: 'parse_error' | 'unroutable' | 'signature_fail';
  signature?: string;
  slackTimestamp?: string;
}

export function writeToDlq(db: DatabaseSyncLike, opts: DlqWriteOpts): number {
  const result = db.prepare(`
    INSERT INTO slack_dlq
      (tenant_id, team_id, raw_payload, error, received_at, bucket, signature, slack_timestamp)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    opts.tenantId ?? '__unroutable__',
    opts.teamId ?? null,
    opts.rawPayload,
    opts.error,
    new Date().toISOString(),
    opts.bucket,
    opts.signature ?? null,
    opts.slackTimestamp ?? null,
  );
  return Number(result.lastInsertRowid);
}
```

In `src/server.ts` slack-events handler, capture `signature` from `X-Slack-Signature` header and `slackTimestamp` from `X-Slack-Request-Timestamp`. On parse error, attempt to extract `team_id` from the raw body via cheap regex `/"team_id"\s*:\s*"([^"]+)"/` — if found, populate `teamId`. On signature failure, write to bucket=`signature_fail`. On unknown team, bucket=`unroutable`.

### Fix 3.6 — DLQ replay CLI

**Location:** `src/cli.ts:cmdSlack` (existing dispatch).

Add subcommand `replay`:

```bash
hippo slack dlq replay <id> [--force]
```

Behavior:
1. SELECT row by id from slack_dlq.
2. If `signature` and `slack_timestamp` are present, re-run signature verification using the current `SLACK_SIGNING_SECRET` (not previous — replay uses current state). If fails AND `--force` is false, error out.
3. Re-parse the raw_payload, run through the normal ingest path.
4. On success: UPDATE slack_dlq SET retried_at = ?, retry_count = retry_count + 1.
5. On failure: UPDATE retry_count only; leave the row.

### Fix 3.7 — Signing-secret rotation

**Location:** `src/connectors/slack/signature.ts`.

```ts
export function verifySlackSignature(
  rawBody: string,
  signature: string,
  timestamp: string,
  signingSecret: string,
  previousSecret?: string,
): boolean {
  if (verifyOne(rawBody, signature, timestamp, signingSecret)) return true;
  if (previousSecret && verifyOne(rawBody, signature, timestamp, previousSecret)) return true;
  return false;
}
```

In server.ts handler, read both `SLACK_SIGNING_SECRET` and `SLACK_SIGNING_SECRET_PREVIOUS` from env; pass both. Document the rotation flow in CHANGELOG: deploy with current+previous, verify both work, drop previous after rollover.

### Fix 3.8 — Web-client oldest test

Already covered in tests below. See test 9.

### Tests (commit 3)

`tests/v039-slack-hardening.test.ts`:

1. **Migration v19 schema additions.** Schema version is 19; slack_dlq has team_id/bucket/retry_count/signature/slack_timestamp columns.
2. **Unknown team + empty workspaces → fallback.** No slack_workspaces rows; unknown team_id → resolves to env tenant.
3. **Unknown team + non-empty workspaces → fail closed.** One slack_workspaces row registered to team_X; event from team_Y → resolveTenantForTeam returns null.
4. **Escape hatch.** Same as #3 with `SLACK_ALLOW_UNKNOWN_TEAM_FALLBACK=1` → resolves to env tenant.
5. **Server slack-events: unroutable goes to DLQ.** Same as #3 via the HTTP route; assert slack_dlq has bucket='unroutable' and team_id captured.
6. **Ingest race: duplicate event_id.** Manually call ingestMessage twice with same event_id → second returns `skipped_duplicate`, slack_event_log has 1 row, memories has 1 row.
7. **Deletion afterArchive atomic.** Mock raw-archive to throw inside afterArchive callback → archive rolls back, memory still exists, slack_event_log not marked.
8. **DLQ replay clean path.** Seed a parse_error row with valid signature; replay → retry_count=1, ingest succeeds, memory created.
9. **Web-client emits `oldest` on first-page resume only.** Spy fetch → first call URL contains `oldest=<latest_ts>`; subsequent cursor pages do not have `oldest`.
10. **Signing-secret rotation.** Verify signature signed with previous secret passes when both env vars set; signature signed with random fails.
11. **DLQ team_id captured from malformed body.** Send malformed envelope with valid team_id field → DLQ row has team_id populated.

### Commit message
```
fix(slack): hardening + migration v19

- tenant-routing fail-closed when slack_workspaces non-empty + unknown team
  (escape hatch SLACK_ALLOW_UNKNOWN_TEAM_FALLBACK=1)
- ingest race closed via afterWrite hook (atomic event_log + memory)
- deletion idempotency closed via new afterArchive hook in raw-archive.ts
- DLQ schema: team_id, bucket, retry_count, signature, slack_timestamp
- hippo slack dlq replay <id> command
- signing-secret rotation: SLACK_SIGNING_SECRET_PREVIOUS during rollover
- 11 regression tests including web-client oldest URL param
```

---

## Commit 4 — GDPR Path A redaction + migration v20

**Files:**
- Modify: `src/raw-archive.ts:41` (redact payload_json)
- Modify: `src/db.ts` (migration v20: backfill existing raw_archive rows)
- Test: `tests/v039-gdpr-path-a.test.ts` (create)

**Fixes:** HIGH #7.

### Fix 4.1 — archiveRawMemory writes redacted payload

**Location:** `src/raw-archive.ts:41`.

Read the existing function. The current line is:

```ts
).run(id, new Date().toISOString(), opts.reason, opts.who, JSON.stringify(row, bigintSafeReplacer));
```

Replace with:

```ts
const redactedPayload = JSON.stringify({
  redacted: true,
  archived_at: new Date().toISOString(),
  tenant_id: row.tenant_id ?? 'default',
  kind: row.kind,
  reason: opts.reason,
  // Note: original content is NOT stored. Path A semantics — true RTBF.
  // The audit_log row appended below carries op='archive_raw' for compliance audit.
});
).run(id, new Date().toISOString(), opts.reason, opts.who, redactedPayload);
```

Existing audit (line 62-69) and FTS purge (line 49-56) remain unchanged. The audit row IS the compliance record; raw_archive is now metadata-only.

### Fix 4.2 — Migration v20: backfill redact existing rows

**Location:** `src/db.ts` migration v20.

```ts
{
  version: 20,
  up: (db) => {
    // Path A backfill: redact every existing raw_archive.payload_json so
    // historical archives match the new metadata-only contract. Read each
    // row, parse the existing JSON to extract tenant_id and kind (best
    // effort), then UPDATE with the redacted shape. Rows with unparseable
    // legacy JSON get redacted with tenant_id='unknown', kind='unknown'.
    const rows = db.prepare(`SELECT id, archived_at, reason, payload_json FROM raw_archive`).all() as Array<{
      id: number;
      archived_at: string;
      reason: string;
      payload_json: string;
    }>;
    const update = db.prepare(`UPDATE raw_archive SET payload_json = ? WHERE id = ?`);
    for (const row of rows) {
      let tenant = 'unknown';
      let kind = 'unknown';
      try {
        const parsed = JSON.parse(row.payload_json) as { tenant_id?: string; kind?: string };
        tenant = parsed.tenant_id ?? 'unknown';
        kind = parsed.kind ?? 'unknown';
      } catch {
        // Unparseable legacy payload — redact with unknowns.
      }
      const redacted = JSON.stringify({
        redacted: true,
        archived_at: row.archived_at,
        tenant_id: tenant,
        kind,
        reason: row.reason,
        migration: 'v20_redact',
      });
      update.run(redacted, row.id);
    }
  },
}
```

Bump `CURRENT_SCHEMA_VERSION = 20`. Update test pins (b3, a3, a5, pr2, the v19 test from commit 3) from 19 → 20.

### Tests (commit 4)

`tests/v039-gdpr-path-a.test.ts`:

1. **Schema v20.** Test pins on master files updated to 20.
2. **Fresh archive Path A.** Push a kind='raw' memory; archiveRaw it; SELECT raw_archive — payload_json contains `{"redacted":true,...}` and NOT the original content.
3. **Migration v20 backfill.** Seed a v18 DB by hand-inserting a raw_archive row with full content payload; open the DB (triggering migrations); SELECT — payload now redacted, tenant_id and kind preserved.
4. **Migration v20 with malformed legacy payload.** Seed a row with payload_json = 'not-json'; migrate; redacted with unknowns.
5. **Audit row preserved.** archiveRaw still writes audit_log entry with op='archive_raw' (compliance record), even though raw_archive content is gone.
6. **No re-recall after archive.** Already covered in B3 tests, but verify under Path A — the memory is gone, raw_archive is metadata-only, recall returns nothing.

### Commit message
```
feat(gdpr): Path A archive redaction - true RTBF on raw_archive

BREAKING (data shape, not API): raw_archive.payload_json now stores
{redacted:true, archived_at, tenant_id, kind, reason} instead of full
memory content. Migration v20 redacts existing rows in place, preserving
tenant_id and kind from the legacy payload when parseable.

Compliance audit trail remains via audit_log (op='archive_raw').
6 regression tests including legacy-payload-malformed migration path.
```

---

## Commit 5 — Server hardening

**Files:**
- Modify: `src/server.ts` (signal-await, SSE rebind + max-age)
- Modify: `src/auth.ts` (DUMMY_HASH, miss-path runs verifyKey)
- Modify: `tests/server-bearer-lockdown.test.ts` (route parity, parameterized)
- Test: `tests/v039-server-hardening.test.ts` (create — PUBLIC_ROUTES regression, default-deny lower-level, SSE)

**Fixes:** HIGH #6, #8, MEDIUM #10, NITs (PUBLIC_ROUTES, signal race).

### Fix 5.1 — Bearer lockdown route parity

**Location:** `tests/server-bearer-lockdown.test.ts:33`.

The actual route table from src/server.ts (verified by reading lines 349-742):

- `GET /health` — public (no auth)
- `POST /v1/memories` — authed (remember)
- `GET /v1/memories` — authed (recall)
- `POST /v1/memories/:id/archive` — authed
- `POST /v1/memories/:id/supersede` — authed
- `POST /v1/memories/:id/promote` — authed
- `DELETE /v1/memories/:id` — authed (forget)
- `POST /v1/auth/keys` — authed (authCreate)
- `GET /v1/auth/keys` — authed (list)
- `DELETE /v1/auth/keys/:keyId` — authed (revoke)
- `GET /v1/audit` — authed
- `POST /v1/connectors/slack/events` — public (HMAC-verified, NOT bearer-authed; covered by separate Slack tests)
- `POST /mcp` — authed (MCP request)
- `GET /mcp/stream` — authed (SSE)

No `GET /mcp` route. Codex round-2 corrected v1's invented routes.

Parameterize the test:

```ts
const AUTHED_ROUTES: Array<{ method: string; path: string; body?: object }> = [
  { method: 'POST', path: '/v1/memories', body: { content: 'x' } },
  { method: 'GET', path: '/v1/memories?q=test' },
  { method: 'POST', path: '/v1/memories/abc/archive', body: { reason: 'x' } },
  { method: 'POST', path: '/v1/memories/abc/supersede', body: { content: 'y' } },
  { method: 'POST', path: '/v1/memories/abc/promote' },
  { method: 'DELETE', path: '/v1/memories/abc' },
  { method: 'POST', path: '/v1/auth/keys', body: { label: 'x' } },
  { method: 'GET', path: '/v1/auth/keys' },
  { method: 'DELETE', path: '/v1/auth/keys/hk_xyz' },
  { method: 'GET', path: '/v1/audit' },
  { method: 'POST', path: '/mcp', body: { jsonrpc: '2.0', method: 'tools/list', id: 1 } },
  { method: 'GET', path: '/mcp/stream' },
];
```

12 authed routes total. `/v1/connectors/slack/events` is public-by-design (HMAC-verified) — covered separately in Slack hardening tests. `/health` is public — no test needed.

Per route: assert HTTP 401 with `HIPPO_REQUIRE_AUTH=1` and missing/bad bearer.

### Fix 5.2 — Auth timing leak via DUMMY_HASH

**Location:** `src/auth.ts`.

```ts
// Constant dummy hash precomputed once at module load — used to equalize
// scrypt cost on the miss path so key-id existence cannot be timed.
// Stored format identical to real hashes: scrypt$saltHex$hashHex.
const DUMMY_PLAINTEXT = 'hk_dummy_constant_padding_for_timing.dummy_secret_padding_for_timing_x';
const DUMMY_HASH = hashKey(DUMMY_PLAINTEXT);

export function validateApiKey(db: DatabaseSyncLike, plaintext: string): ValidateResult {
  const dot = plaintext.indexOf('.');
  if (dot < 0) {
    // Still run verifyKey to keep timing similar even on malformed input.
    verifyKey('hk_xxx.yyy', DUMMY_HASH);
    return { valid: false };
  }
  const keyId = plaintext.slice(0, dot);
  const row = db
    .prepare(`SELECT key_hash, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
    .get(keyId) as { key_hash: string; tenant_id: string; revoked_at: string | null } | undefined;

  // Always run verifyKey — on miss/revoked, against DUMMY_HASH so scrypt cost is paid.
  // This reduces timing signal between hit and miss, but the DB lookup itself can
  // still leak via cache effects. v0.40 follow-up: add request-level rate limit on
  // /v1/* to bound enumeration.
  const target = (row && !row.revoked_at) ? row.key_hash : DUMMY_HASH;
  const matches = verifyKey(plaintext, target);
  if (!row || row.revoked_at || !matches) return { valid: false };
  return { valid: true, tenantId: row.tenant_id, keyId };
}
```

### Fix 5.3 — SSE auth-rebind + max-age

**Location:** `src/server.ts:/mcp/stream` handler.

Add a heartbeat interval (default 60s) that re-validates the bearer. If revoked or fails, close the stream with reason. Also enforce `MCP_SSE_MAX_AGE_SEC` (default 3600). Document in CHANGELOG.

```ts
const SSE_HEARTBEAT_MS = 60 * 1000;
const SSE_MAX_AGE_MS = (parseInt(process.env.MCP_SSE_MAX_AGE_SEC ?? '3600', 10) || 3600) * 1000;

// inside the SSE handler, after initial auth check:
const startedAt = Date.now();
const heartbeat = setInterval(() => {
  if (Date.now() - startedAt >= SSE_MAX_AGE_MS) {
    res.write(`event: closed\ndata: {"reason":"max_age_exceeded"}\n\n`);
    res.end();
    clearInterval(heartbeat);
    return;
  }
  // Re-validate bearer
  try {
    requireAuth(req, hippoRoot);
    res.write(`: heartbeat\n\n`);
  } catch {
    res.write(`event: closed\ndata: {"reason":"auth_revoked"}\n\n`);
    res.end();
    clearInterval(heartbeat);
  }
}, SSE_HEARTBEAT_MS);

req.on('close', () => clearInterval(heartbeat));
```

### Fix 5.4 — Graceful shutdown signal-await

**Location:** `src/server.ts:846, 855`.

Replace synchronous signal handlers with async-aware:

```ts
let shuttingDown = false;
async function gracefulShutdown(signal: string): Promise<void> {
  if (shuttingDown) return;
  shuttingDown = true;
  console.error(`Received ${signal}, shutting down...`);
  try {
    await server.stop();
  } catch (err) {
    console.error('Error during stop:', err);
  } finally {
    process.exit(0);
  }
}

process.on('SIGTERM', () => { void gracefulShutdown('SIGTERM'); });
process.on('SIGINT', () => { void gracefulShutdown('SIGINT'); });
```

### Fix 5.5 — PUBLIC_ROUTES regression tests

`tests/v039-server-hardening.test.ts`:

```ts
describe('PUBLIC_ROUTES — bypass attempts', () => {
  it('trailing slash on slack events does not bypass (still checks HMAC)', async () => {
    // POST /v1/connectors/slack/events/  with no signature → 401 (HMAC required)
  });
  it('encoded slash variant does not bypass', async () => {
    // POST /v1/connectors/slack%2Fevents → 401 (route mismatch, falls through to authed handler)
  });
  it('wrong method on public route requires auth', async () => {
    // GET /v1/connectors/slack/events without bearer → 401 (only POST is public)
  });
  it('query string on public route does not affect routing', async () => {
    // POST /v1/connectors/slack/events?bypass=1 → still HMAC-verified
  });
});
```

### Fix 5.6 — Default-deny lower-level loader test

After commit 2 lands MCP-via-api, add a test:

```ts
it('MCP recall with no scope hides slack:private:* memories (default-deny)', async () => {
  // Push a memory with scope='slack:private:CXXX'
  // Call MCP recall with no scope
  // Assert no slack:private memory in results
});
```

### Tests (commit 5)

In addition to PUBLIC_ROUTES + default-deny above:

1. **Bearer lockdown parameterized** — every authed route returns 401 without bearer (HIPPO_REQUIRE_AUTH=1).
2. **Auth timing reduced.** Measure verifyKey time on hit vs. miss-with-DUMMY_HASH; assert within 30% of each other (loose bound — exact-equal is fragile in CI).
3. **SSE max-age closes stream.** Set MCP_SSE_MAX_AGE_SEC=2; open stream; wait 3s; assert stream closed with reason='max_age_exceeded'.
4. **SSE auth revoked closes stream.** Open stream with bearer; revoke key via DELETE /v1/auth/keys/:keyId; trigger heartbeat; assert stream closed with reason='auth_revoked'.
5. **Graceful shutdown.** Start server, send SIGTERM mid-request, verify request completes before exit (mock with a slow handler).

### Commit message
```
fix(server): bearer route parity, auth timing reduction, SSE rebind, signal handler

- bearer lockdown test parameterized over 12 authed routes
- auth.ts: DUMMY_HASH precomputed at module load; miss path runs verifyKey
  against DUMMY_HASH so scrypt cost is paid (reduced timing signal — DB
  lookup still differs, v0.40 rate limit follow-up)
- /mcp/stream: 60s heartbeat re-validates bearer; MCP_SSE_MAX_AGE_SEC limit
- SIGTERM/SIGINT: await server.stop() before process.exit
- 5 PUBLIC_ROUTES regression tests + default-deny lower-level test
```

---

## Commit 6 — Benchmark honesty + docs + version bump 0.39.0

**Files:**
- Modify: `benchmarks/a1/p99-recall.ts` (retract <50ms claim, document current state)
- Modify: `benchmarks/a1/soak.ts` README (document as scaffold-only)
- Modify: `CHANGELOG.md`, `README.md`, `RESEARCH.md`, `TODOS.md`
- Modify: `package.json`, `package-lock.json`, `openclaw.plugin.json`, `extensions/openclaw-plugin/openclaw.plugin.json`, `extensions/openclaw-plugin/package.json`, `src/server.ts:VERSION`, `src/mcp/server.ts:serverInfo.version` (bump to 0.39.0)
- No new tests; full suite must pass.

### Fix 6.1 — p99 retraction (Path C from prior plan)

In `benchmarks/a1/p99-recall.ts` README/header comment: add note:
> v0.39: the <50ms p99 target stated in v0.36 plan is retracted. Measured 58.4ms on 10k store, sequential single-thread. Server-mode concurrent measurement is a v0.40+ effort.

In `TODOS.md` v0.39 section: remove "A1 p99 latency hardening (58.4ms → <50ms)" line OR move to "long-term, no current target".

### Fix 6.2 — Soak harness scaffold

In `benchmarks/a1/soak.ts` header comment: add note:
> Scaffold for 24h soak runs. Not currently a release gate. Operators can run manually; no CI integration in v0.39.

In CHANGELOG: do not claim "soak run results" as evidence.

### Fix 6.3 — Version bump

Bump every manifest from 0.38.0 → 0.39.0:
- `package.json` (root)
- `package-lock.json` (sync via npm install)
- `openclaw.plugin.json`
- `extensions/openclaw-plugin/openclaw.plugin.json`
- `extensions/openclaw-plugin/package.json`
- `src/server.ts` `VERSION` const
- `src/mcp/server.ts` serverInfo.version

Verify with grep: `grep -rn '"version".*"0.38.0"' --include='*.json' . | grep -v node_modules | grep -v package-lock` → zero hits.

### Fix 6.4 — Docs

**CHANGELOG.md** (top):

```markdown
## 0.39.0 (2026-04-30)

### Security (CRITICAL)
- **Cross-tenant authorization:** `promote()` now verifies memory belongs to ctx.tenantId before promoteToGlobal. `authCreate` ignores body.tenantId and forces ctx.tenantId at HTTP layer.
- **Supersede CAS race:** `supersede()` wraps the transition in BEGIN IMMEDIATE with `WHERE superseded_by IS NULL`; concurrent attempts now throw CONFLICT instead of producing double chains.
- **MCP cross-tenant outcome poisoning:** `lastRecalledIds` now keyed by per-client `clientKey` (HTTP: hash(bearer + remote IP), stdio: 'stdio-${pid}'). Outcome from client B cannot touch client A's recall set.
- **Slack unknown-team fallback:** when `slack_workspaces` is non-empty and the incoming `team_id` is unmapped, the event is sent to DLQ as `unroutable` instead of silently ingesting into the env-default tenant. Escape hatch: `SLACK_ALLOW_UNKNOWN_TEAM_FALLBACK=1`.

### Privacy (BREAKING data shape)
- **GDPR Path A on raw_archive:** archived memories no longer retain content in `raw_archive.payload_json`. Stored shape is `{redacted:true, archived_at, tenant_id, kind, reason}`. Migration v20 redacts existing rows in place. Compliance audit trail preserved via `audit_log`.

### Hardening
- MCP HTTP handlers route through `src/api.ts` so audit + cross-tenant guards apply uniformly
- Bearer lockdown test parameterized over the full 12-route table
- Auth timing leak reduced: `DUMMY_HASH` precomputed at module load; miss path runs scrypt
- `/mcp/stream` re-validates bearer on a 60s heartbeat; new `MCP_SSE_MAX_AGE_SEC` (default 3600s) caps stream age
- Graceful shutdown awaits `server.stop()` before `process.exit`
- Slack ingest race closed via `afterWrite` hook (atomic event_log + memory)
- Slack deletion idempotency closed via new `afterArchive` hook in `archiveRawMemory`
- Slack DLQ: schema additions (team_id, bucket, retry_count, signature, slack_timestamp); `hippo slack dlq replay <id>` command
- Slack signing-secret rotation: accept `SLACK_SIGNING_SECRET_PREVIOUS` during rollover

### Schema
- Migration v19: slack_dlq columns (team_id, bucket, retry_count, signature, slack_timestamp)
- Migration v20: raw_archive.payload_json redacted in place (Path A backfill)

### Retracted
- v0.36 <50ms p99 latency target. v0.36 ships at 58.4ms (sequential single-thread). No current target; revisit in v0.40+ if a real user asks.

### Deferred to v0.40
- Tenant-guard audit on remaining MCP tools (context, status, learn, conflicts, resolve, peers) + any unscoped readEntry/loadSearchEntries call sites in CLI/dashboard/refine
- Request-level rate limit on /v1/* to bound key-id enumeration
- p99 hardening
- 24h soak harness as a real release gate (currently scaffold)
- B3 dlPFC follow-ups (sequential-learning adapter contract, etc.)
```

**README.md:** add "What's new in v0.39.0" entry: "Security hardening release: 5 CRITICAL cross-tenant fixes (CVE candidates), GDPR Path A on archive (true RTBF), MCP per-client isolation, Slack ingestion race + idempotency hardening, auth timing leak reduction." No new feature receipts.

**RESEARCH.md:** unchanged unless an architectural note is needed.

**TODOS.md:** clear v0.38.x security follow-ups (now done). Add v0.40 follow-ups: tenant-guard audit on remaining MCP tools (context, status, learn, conflicts, resolve, peers — share is fixed in this release per fix 2.4); request-level rate limit on /v1/* to bound key-id enumeration; p99 hardening; 24h soak as a real release gate (currently scaffold); B3 dlPFC stretch items.

### Final test pass

`npm run build` clean. `npx vitest run` → 931 + ~25 new = ~956 passing.

### Commit message
```
chore: bump to v0.39.0 - security hardening release

5 CRITICAL cross-tenant fixes + GDPR Path A redaction + MCP isolation
+ Slack ingestion hardening + auth timing reduction + SSE rebind
+ graceful shutdown. Migration v19 (slack_dlq) + v20 (raw_archive backfill).
~25 new regression tests, full suite at ~956.
```

---

## Risks (v2)

**Risk 1: Migration v20 redaction destroys existing raw_archive content.**
Path A is intentional. CHANGELOG must call this out as BREAKING data shape. If any user actually wanted retention semantics, they need to roll back to v0.38 OR copy raw_archive content out before upgrading. Rollback path: down-migration not provided — v0.39 is forward-only on this column.

**Risk 2: MCP-via-api refactor breaks an MCP client silently.**
Mitigation: end-to-end test with both stdio (existing test path) and HTTP-MCP (new test path). If we don't have an HTTP-MCP test client in tests/, add a minimal one that issues a tools/list + tools/call request.

**Risk 3: Supersede mirror failure post-COMMIT.**
The DB transaction (CAS + new memory upsert + audit) commits atomically. Filesystem mirrors run AFTER COMMIT — if writeMarkdownMirror or writeIndexMirror throws, the DB is already committed but disk lacks the markdown for the new memory. This matches the existing `writeEntry` invariant and self-heals on next backfill. NOT a dangling-pointer DB inconsistency — that hole is closed in v0.39 via `writeEntryDbOnly` joining the CAS transaction.

**Risk 4: Auth timing fix is "reduced" not "fixed".**
Documented honestly. DB hit/miss branch still measurable via cache. v0.40: add request-level rate limit on /v1/* to bound enumeration attempts. Plus encourage operators to run behind a reverse-proxy with rate limiting today.

**Risk 5: SSE max-age default 3600s breaks long-lived MCP clients.**
Configurable via env. Document in CHANGELOG. Most Claude Code / openclaw MCP sessions are bounded; long-lived agents that stay connected >1h are unusual but supported via env override.

**Risk 6: DLQ replay using current SLACK_SIGNING_SECRET fails on old rows signed with rotated-out secret.**
Acceptable: replay is best-effort; if the signature can no longer be verified, replay must fail unless `--force` is passed. Document in CLI help.

**Risk 7: Slack ingest race fix re-uses B3 afterWrite hook.**
The afterWrite hook is an existing extension point from B3, well-tested. Risk surface is the new throw-on-duplicate semantic — verify the catch in ingestMessage maps `DuplicateEventError` cleanly.

**Risk 8: SSE heartbeat re-validation under load.**
Every 60s, every active stream. With 100 streams: 100 DB queries / 60s = manageable. Document the cost; v0.40 can cache validations briefly.

---

## Execution

1. /codex on this v2 plan FIRST — verify no new defects vs the actual code shapes referenced.
2. If codex says PROCEED: dispatch subagents per commit, in order:
   - commit 1 → /review of commit 1 diff → commit 2 → /review of commit 2 → ...
   - Each subagent dispatch is one commit's work; /review between commits catches drift early.
3. After commit 6: full /review on the merged-branch diff → /self-review → /ship-check.
4. Squash merge → npm publish v0.39.0 → tag → global install verify → hippo capture.

If any commit's tests fail and the fix isn't obvious in 2 iterations, STOP and report rather than escalate. /full-power authorizes rigour, not shortcuts.
