# v1.7.1 Review-Tail Implementation Plan (rev. 2 — consolidated)

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

**Goal:** Close the 6 INFO findings deferred from the v1.7.0 `/review` 5-specialist pass plus the codex-flagged `unknown:legacy` leak. Patch release. The `unknown:legacy` fix is at the producer (SQL) layer, not per-consumer (root-cause-over-patches).

**Architecture:**
- Test-only hardening on the existing v1.7.0 surface (BM25 provenance + scorerWindow opt-in + F4 byte-cmp + F5 typed contract).
- One **producer-layer** scope-filter fix in `loadSearchRows` SQL plus a new `loadRecallSearchEntries` helper, so `unknown:legacy` cannot leak via any future recall consumer.
- One small **test hook** in `isFtsAvailable` (`HIPPO_DISABLE_FTS=1` env var) to make LIKE-fallback testing deterministic — `ensureOptionalFts` re-creates and re-backfills `memories_fts` on every `openHippoDb` (db.ts:998-1029), so the original "DROP TABLE / DELETE FROM" approaches were both no-ops.

**Tech Stack:** TypeScript, vitest, node:sqlite + FTS5, real DB for tests (no mocks — verified hippo memory).

**Branch:** `feat/v1.7.1-review-tail` off master (already created).

**Revision history:**
- rev. 1 (committed `d19b45a`): initial draft, 7 tasks, narrow api.ts:393 patch.
- rev. 2 (this file): incorporates eng-review + codex-adversarial revisions. B2 promoted from one-line patch to producer-layer SQL fix. A5 mechanism corrected (DROP TABLE was a no-op against `backfillFtsIndex`). A4 corrected (route is GET `/v1/memories?q=...` via `serve()`/`ServerHandle`, not POST or `startServer`). A2/A3 assertions tightened. B1 second test (no-op) dropped. C-section moved AFTER review chain.

---

## Pre-flight

### Task 0: Baseline test run

Run: `git branch --show-current` → expect `feat/v1.7.1-review-tail`.
Run: `npx vitest run --reporter=dot` → expect 1371 tests passing (v1.7.0 ship state).
No commit.

---

## Track A — Test hardening (v1.7.1 INFO findings)

### Task A1: scorerWindow=1 lower-bound test (testing INFO #2)

**Files:** modify `tests/api-recall-scorer-window.test.ts` — append `it()` inside the existing `describe('RecallOpts.scorerWindow (F3, v1.7.0)')` block.

**Test:**

```typescript
  it('scorerWindow=1 (smallest legal value) is accepted and honoured (testing INFO #2)', () => {
    for (let i = 0; i < 5; i++) writeEntry(root, makeRaw(`tau ${i}`));
    const result = recall(ctxFor(root), {
      query: 'tau',
      limit: 5,
      scorerWindow: 1,
    });
    expect(result.windowSize).toBe(1);
    expect(result.total).toBe(1); // FTS LIMIT 1 against >1 matching rows = exactly 1
  });
```

Run: `npx vitest run tests/api-recall-scorer-window.test.ts` → PASS.
Commit: `git add tests/api-recall-scorer-window.test.ts && git commit -m "test: pin scorerWindow=1 lower bound (v1.7.1 INFO #2)"`

---

### Task A2: No-terms path ORDER BY assertion + roundtrip created stamp verification (testing INFO #3, P1[5])

**Files:** modify `tests/store-bm25-score.test.ts` — append `it()` inside the existing describe block.

**Test:**

```typescript
  it('No-terms path: returns rows ordered by created ASC then id ASC, with stamped created surviving writeEntry (testing INFO #3 + P1)', () => {
    // P1[5]: verify stamped `created` survives writeEntry → roundtrip read.
    // upsertEntryRow (store.ts:860) passes entry.created straight through;
    // a future normalizer would silently break this test. Anchor explicitly.
    const probe = makeRaw('roundtrip probe');
    probe.created = '2026-05-06T00:00:00.000Z';
    writeEntry(root, probe);
    const reread = loadSearchEntries(root, '', 1, 'default');
    expect(reread.length).toBe(1);
    expect(reread[0]!.created).toBe('2026-05-06T00:00:00.000Z');

    // Now the actual ORDER BY assertion.
    const created: string[] = [];
    for (let i = 0; i < 50; i++) {
      const e = makeRaw(`row-${i.toString().padStart(2, '0')}`);
      // Spaced 1s apart so byte-cmp ordering is unambiguous.
      e.created = new Date(2026, 4, 6, 1, 0, i).toISOString();
      created.push(e.created);
      writeEntry(root, e);
    }
    const results = loadSearchEntries(root, '', 10, 'default');
    expect(results.length).toBe(10);
    // Earliest 10 by created ASC (the original 'roundtrip probe' was at 00:00,
    // it's first; then rows 0-8 at 01:00:00 through 01:00:08).
    const expectedOrder = ['2026-05-06T00:00:00.000Z', ...created.slice(0, 9)];
    const got = results.map((e) => e.created);
    expect(got).toEqual(expectedOrder);
  });
```

Run: `npx vitest run tests/store-bm25-score.test.ts` → PASS.
Commit: `git add tests/store-bm25-score.test.ts && git commit -m "test: assert no-terms ORDER BY + verify created stamp roundtrip (v1.7.1 INFO #3)"`

---

### Task A3: Tenant-isolation under wide scorerWindow (testing INFO #5, P1[6])

**Files:** create `tests/api-recall-scorer-window-tenant-isolation.test.ts`.

**Test:** P1[6] — assert each result.id ∈ tenantA's id-set, plus `result.results.length === N`. Drop `result.total` (misleading metadata).

```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initStore, writeEntry } from '../src/store.js';
import { createMemory, Layer, MemoryKind, type MemoryEntry } from '../src/memory.js';
import { recall, type Context } from '../src/api.js';

function makeRoot(prefix: string): string {
  const root = mkdtempSync(join(tmpdir(), `hippo-${prefix}-`));
  mkdirSync(join(root, '.hippo'), { recursive: true });
  initStore(root);
  return root;
}
function safeRmSync(p: string): void {
  try { rmSync(p, { recursive: true, force: true }); } catch { /* best-effort */ }
}
function ctxFor(root: string, tenantId: string): Context {
  return { hippoRoot: root, tenantId, actor: 'test:tenant-iso' };
}
function makeRaw(text: string, tenantId: string): MemoryEntry {
  return createMemory(text, {
    layer: Layer.Buffer,
    confidence: 'observed',
    kind: 'raw' as MemoryKind,
    tenantId,
  });
}

describe('scorerWindow tenant isolation (v1.7.1 INFO #5)', () => {
  let root: string;
  beforeEach(() => { root = makeRoot('f3-tenant'); });
  afterEach(() => safeRmSync(root));

  it('wide scorerWindow under one tenant does not surface rows from another', () => {
    const N = 5;
    const tenantAIds = new Set<string>();
    for (let i = 0; i < N; i++) {
      const e = makeRaw(`kappa A-${i}`, 'tenant-a');
      tenantAIds.add(e.id);
      writeEntry(root, e);
    }
    for (let i = 0; i < N; i++) writeEntry(root, makeRaw(`kappa B-${i}`, 'tenant-b'));

    const result = recall(ctxFor(root, 'tenant-a'), {
      query: 'kappa',
      limit: 100,
      scorerWindow: N + 5,
    });
    expect(result.results.length).toBe(N);
    for (const r of result.results) {
      expect(tenantAIds.has(r.id)).toBe(true);
      expect(r.content).not.toContain('B-');
    }
  });
});
```

Run: `npx vitest run tests/api-recall-scorer-window-tenant-isolation.test.ts` → PASS.
Commit: `git add tests/api-recall-scorer-window-tenant-isolation.test.ts && git commit -m "test: pin tenant isolation under wide scorerWindow (v1.7.1 INFO #5)"`

---

### Task A4: HTTP `windowSize` serialization integration test (testing INFO #6, CRITICAL[1])

**Mandatory pre-step:** read an existing HTTP recall test for the canonical `serve()` / `ServerHandle` boilerplate. Do NOT invent the route shape.

```bash
grep -ln "from '../src/server.js'\|/v1/memories" tests/ | head -5
```

Pick the smallest matching test, read it, mirror the spawn/teardown pattern.

**Files:** create `tests/http-recall-window-size.test.ts`.

**Critical contract corrections from rev. 1 (CRITICAL[1]):**
- **Server export is `serve()` returning `ServerHandle`** (`src/server.ts:1387`), not `startServer`.
- **Query param is `q`**, not `query` (`src/server.ts:470` → `query.get('q')` else 400).
- **Route is GET `/v1/memories?q=...`**, not POST.

**Test (mirror the canonical HTTP boilerplate; do not copy-paste this skeleton blindly):**

```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initStore, writeEntry } from '../src/store.js';
import { createMemory, Layer, MemoryKind } from '../src/memory.js';
import { serve, type ServerHandle } from '../src/server.js';

describe('HTTP /v1/memories windowSize serialization (v1.7.1 INFO #6)', () => {
  let root: string;
  let handle: ServerHandle;

  beforeEach(async () => {
    root = mkdtempSync(join(tmpdir(), 'hippo-http-windowsize-'));
    mkdirSync(join(root, '.hippo'), { recursive: true });
    initStore(root);
    writeEntry(root, createMemory('alpha', {
      layer: Layer.Buffer,
      kind: 'raw' as MemoryKind,
      tenantId: 'default',
    }));
    handle = await serve({ hippoRoot: root, port: 0 });
  });
  afterEach(async () => {
    await handle.stop();
    try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
  });

  it('default GET /v1/memories?q=alpha returns body.windowSize === 200', async () => {
    const url = `${handle.url}/v1/memories?q=alpha`;
    const res = await fetch(url);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { windowSize?: number };
    expect(body.windowSize).toBe(200);
  });
});
```

If the canonical pattern uses different shapes (different field names on `ServerHandle`, different teardown), match the canonical pattern exactly — the skeleton above is illustrative.

Run: `npx vitest run tests/http-recall-window-size.test.ts` → PASS.
Commit: `git add tests/http-recall-window-size.test.ts && git commit -m "test: pin HTTP windowSize serialization on /v1/memories (v1.7.1 INFO #6)"`

---

### Task A5: Deterministic LIKE-fallback via `HIPPO_DISABLE_FTS` env-var hook (senior P2.3 + INFO #7, CRITICAL[2])

**Background.** Both eng-review and codex-adversarial agreed `DROP TABLE memories_fts` won't survive the next `loadSearchEntries` call (`ensureOptionalFts` runs `CREATE VIRTUAL TABLE IF NOT EXISTS`). I verified that `DELETE FROM memories_fts` is ALSO a no-op: `backfillFtsIndex` (`src/db.ts:1011-1023`) compares `memCount` vs `ftsCount` and re-populates whenever they diverge. The rev. 1 fallback `setMeta('fts5_available','0')` is also overwritten by `ensureOptionalFts` on every open (`db.ts:1008`).

**Real fix:** add a small env-var bypass in `isFtsAvailable` (`src/db.ts:1044`). One line. Test-only hook, but lives in production code (no test-conditional in `loadSearchRows`). Documented as a test/diagnostic flag, not a public knob.

**Step 1: Edit `src/db.ts:1044` `isFtsAvailable` — first line of the body, before the meta read.**

```typescript
export function isFtsAvailable(db: DatabaseSyncLike): boolean {
  // Test/diagnostic hook: when set to '1', force the LIKE-fallback path in
  // loadSearchRows. Used by tests/store-bm25-score.test.ts to deterministically
  // exercise the LIKE branch independent of FTS5 tokenizer behaviour. Lives
  // here (not in loadSearchRows) so all consumers of FTS-availability checks
  // see the same answer.
  if (process.env.HIPPO_DISABLE_FTS === '1') return false;
  const row = db.prepare(`SELECT value FROM meta WHERE key = 'fts5_available'`).get() as { value?: string } | undefined;
  return row?.value === '1';
}
```

**Step 2: Replace the existing `LIKE fallback path` test in `tests/store-bm25-score.test.ts`** (the substring tokenizer-miss `'alphab'` test). New test:

```typescript
  it('LIKE fallback path: HIPPO_DISABLE_FTS=1 routes through LIKE branch deterministically; bm25_score undefined; expected row anchored (senior P2.3 + INFO #7)', () => {
    // Senior P2.3: rev.1's substring "alphab" approach was tokenizer-fragile
    // (porter/trigram tokenizers would tokenize the substring and pass FTS).
    // Both rev.1 fallbacks (DROP TABLE, setMeta('fts5_available','0')) are
    // no-ops because ensureOptionalFts runs CREATE+backfill+meta-write on
    // every openHippoDb (db.ts:998-1029).
    //
    // The deterministic route is `HIPPO_DISABLE_FTS=1` (db.ts:1044). Set it
    // before loadSearchEntries; isFtsAvailable returns false; loadSearchRows
    // skips the FTS branch unconditionally and runs the LIKE query.
    //
    // INFO #7: anchor on the expected content so a partial hit cannot let
    // the test pass spuriously.
    const prevEnv = process.env.HIPPO_DISABLE_FTS;
    try {
      writeEntry(root, makeRaw('alphabet soup contents'));
      process.env.HIPPO_DISABLE_FTS = '1';
      const results = loadSearchEntries(root, 'alphabet', 200, 'default');
      expect(results.length).toBeGreaterThan(0);
      // Anchor: the expected row must come back via the LIKE branch.
      expect(results.some((e) => e.content === 'alphabet soup contents')).toBe(true);
      // No FTS path → no bm25_score on any returned row.
      for (const e of results) {
        expect(e.bm25_score).toBeUndefined();
      }
    } finally {
      if (prevEnv === undefined) delete process.env.HIPPO_DISABLE_FTS;
      else process.env.HIPPO_DISABLE_FTS = prevEnv;
    }
  });
```

Run: `npx vitest run tests/store-bm25-score.test.ts` → all 7 tests pass (the prior fragile test is replaced; +0 net).
Commit: `git add src/db.ts tests/store-bm25-score.test.ts && git commit -m "fix(test): HIPPO_DISABLE_FTS env hook for deterministic LIKE-fallback (v1.7.1 senior P2.3 + INFO #7)"`

---

## Track B — `unknown:legacy` default-deny at the producer layer (CRITICAL[3], option b)

**Architectural framing.** Per Root-Cause-Over-Patches (global CLAUDE.md): `loadSearchEntries` returns `unknown:legacy` rows; every consumer must re-filter or leak. `recall()` baseRanked at api.ts:393 was strike 1 (the codex finding). The continuity inline closure at api.ts:568+ is strike 2. Patching only the recall site relocates the symptom; the right fix is at the producer.

**Design:** add a new public helper `loadRecallSearchEntries` in `src/store.ts` that wraps `loadSearchRows` with a recall-mode scope predicate pushed into SQL. Background pipelines (`consolidate`, `embeddings`, `refine-llm`, `autolearn`, `capture`, `importers`, `shared`, `cli` non-recall paths) keep using the existing `loadSearchEntries` — they want everything including quarantine. Recall path migrates to the new helper.

Private-scope filtering (regex `^[a-z][a-z0-9_-]*:private:`) **stays in JS**: it's a regex, not a clean SQL match, and the existing `passesScopeFilterForRecall` helper already works correctly for it post-load.

### Task B1: Failing integration test

**Files:** create `tests/api-recall-unknown-legacy-filter.test.ts`.

**P1[4]:** drop the rev. 1 second `it()` (the `scope: 'unknown:legacy'` opt-in). The existing exact-match branch at api.ts:387 already passes that case green on master — it tests nothing about the bug or the fix.

```typescript
/**
 * v1.7.1 (codex finding from v1.6.5 review) — unknown:legacy rows must NOT
 * surface in `recall()` baseRanked when caller passes no scope.
 *
 * `passesScopeFilterForRecall` (src/api.ts:120) documents the contract.
 * Continuity, drillDown, assemble already honour it. The BM25 base path at
 * src/api.ts:393 only filtered isPrivateScope, missing 'unknown:legacy'.
 * v1.7.1 fixes this at the producer: SQL predicate in loadSearchRows via
 * a new loadRecallSearchEntries helper. Test fires red on master, green
 * after the producer-layer fix.
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initStore, writeEntry } from '../src/store.js';
import { createMemory, Layer, MemoryKind, type MemoryEntry } from '../src/memory.js';
import { recall, type Context } from '../src/api.js';

function makeRoot(prefix: string): string {
  const root = mkdtempSync(join(tmpdir(), `hippo-${prefix}-`));
  mkdirSync(join(root, '.hippo'), { recursive: true });
  initStore(root);
  return root;
}
function safeRmSync(p: string): void {
  try { rmSync(p, { recursive: true, force: true }); } catch { /* best-effort */ }
}
function ctxFor(root: string): Context {
  return { hippoRoot: root, tenantId: 'default', actor: 'test:legacy' };
}
function makeWithScope(text: string, scope: string | null): MemoryEntry {
  return createMemory(text, {
    layer: Layer.Buffer,
    confidence: 'observed',
    kind: 'raw' as MemoryKind,
    scope,
    tenantId: 'default',
  });
}

describe('recall: default-deny on unknown:legacy (v1.7.1)', () => {
  let root: string;
  beforeEach(() => { root = makeRoot('unknown-legacy'); });
  afterEach(() => safeRmSync(root));

  it('unscoped recall does NOT surface scope=unknown:legacy rows', () => {
    writeEntry(root, makeWithScope('public-row alpha', null));
    writeEntry(root, makeWithScope('legacy-row alpha', 'unknown:legacy'));

    const r = recall(ctxFor(root), { query: 'alpha' });
    const contents = r.results.map((it) => it.content);
    expect(contents).toContain('public-row alpha');
    expect(contents).not.toContain('legacy-row alpha');
  });
});
```

Run: `npx vitest run tests/api-recall-unknown-legacy-filter.test.ts` → expected FAIL (legacy row surfaces today).
Commit failing test: `git add tests/api-recall-unknown-legacy-filter.test.ts && git commit -m "test: failing — unknown:legacy leaks into recall baseRanked (v1.7.1)"`

### Task B2: Push scope predicate into `loadSearchRows` SQL; add `loadRecallSearchEntries` helper

**Files:** modify `src/store.ts`.

**Step 1: Extend `loadSearchRows` signature + WHERE-clause builder.**

```typescript
function loadSearchRows(
  db: ReturnType<typeof openHippoDb>,
  query: string,
  limit: number,
  tenantId: string | undefined,
  recallScope?: { value: string | null }, // v1.7.1: undefined = no filter; { value: null } = default-deny unknown:legacy; { value: 'foo' } = exact match
): MemoryRow[] {
  const tenantPredicate = tenantId !== undefined ? ` AND m.tenant_id = ?` : '';
  const tenantPredicateNoAlias = tenantId !== undefined ? ` AND tenant_id = ?` : '';
  const tenantOnlyPredicate = tenantId !== undefined ? ` WHERE tenant_id = ?` : '';
  const tenantParams = tenantId !== undefined ? [tenantId] : [];

  // v1.7.1 — scope predicate. Three forms:
  //   undefined       → no scope filter (legacy callers; background pipelines)
  //   { value: null } → recall-mode default-deny: exclude unknown:legacy
  //   { value: 'X' }  → recall-mode exact match: m.scope = 'X'
  let scopeClauseAlias = '';
  let scopeClauseNoAlias = '';
  let scopeClauseTenantOnly = '';
  const scopeParams: string[] = [];
  if (recallScope !== undefined) {
    if (recallScope.value === null) {
      scopeClauseAlias = ` AND (m.scope IS NULL OR m.scope != 'unknown:legacy')`;
      scopeClauseNoAlias = ` AND (scope IS NULL OR scope != 'unknown:legacy')`;
      scopeClauseTenantOnly = scopeClauseNoAlias;
    } else {
      scopeClauseAlias = ` AND m.scope = ?`;
      scopeClauseNoAlias = ` AND scope = ?`;
      scopeClauseTenantOnly = scopeClauseNoAlias;
      scopeParams.push(recallScope.value);
    }
  }

  const terms = Array.from(new Set(tokenize(query)));
  if (terms.length === 0) {
    const sql = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories${tenantOnlyPredicate}${scopeClauseTenantOnly} ORDER BY created ASC, id ASC LIMIT ?`;
    return db.prepare(sql).all(...tenantParams, ...scopeParams, limit) as MemoryRow[];
  }

  if (isFtsAvailable(db)) {
    try {
      const ftsQuery = terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
      const rows = db.prepare(`
        SELECT ${MEMORY_SEARCH_COLUMNS}
        FROM memories m
        JOIN memories_fts f ON f.id = m.id
        WHERE memories_fts MATCH ?${tenantPredicate}${scopeClauseAlias}
        ORDER BY bm25(memories_fts), m.updated_at DESC
        LIMIT ?
      `).all(ftsQuery, ...tenantParams, ...scopeParams, limit) as MemoryRow[];
      if (rows.length > 0) return rows;
    } catch {
      // Fall back to LIKE matching below.
    }
  }

  const escapeLike = (term: string): string => term.replace(/[%_\\]/g, '\\$&');
  const where = terms.map(() => `(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(tags_json) LIKE ? ESCAPE '\\')`).join(' OR ');
  const params = terms.flatMap((term) => {
    const like = `%${escapeLike(term)}%`;
    return [like, like];
  });

  const rows = db.prepare(`
    SELECT ${MEMORY_SELECT_COLUMNS}
    FROM memories
    WHERE (${where})${tenantPredicateNoAlias}${scopeClauseNoAlias}
    ORDER BY updated_at DESC, created DESC
    LIMIT ?
  `).all(...params, ...tenantParams, ...scopeParams, limit) as MemoryRow[];
  if (rows.length > 0) return rows;

  const fallback = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories${tenantOnlyPredicate}${scopeClauseTenantOnly} ORDER BY created ASC, id ASC LIMIT ?`;
  return db.prepare(fallback).all(...tenantParams, ...scopeParams, limit) as MemoryRow[];
}
```

**Step 2: Add the public helper next to `loadSearchEntries`.**

```typescript
/**
 * v1.7.1 — recall-mode loader. Pushes the recall-side scope predicate into
 * SQL so unknown:legacy cannot leak via any consumer that hasn't remembered
 * to re-filter.
 *
 * - requestedScope undefined / '': default-deny on unknown:legacy.
 * - requestedScope non-empty string: exact match on m.scope = requestedScope.
 *
 * Private-scope (`<source>:private:*`) filter remains a JS post-load step in
 * `recall()` — the regex doesn't translate cleanly to SQL and the current
 * helper covers all four recall consumers consistently.
 *
 * Consumers: api.ts::recall (v1.7.1+). Background pipelines should keep
 * using `loadSearchEntries` so consolidation / embedding can see quarantined
 * rows when needed.
 */
export function loadRecallSearchEntries(
  hippoRoot: string,
  query: string,
  limit: number = DEFAULT_SEARCH_CANDIDATE_LIMIT,
  tenantId: string,
  requestedScope?: string,
): MemoryEntry[] {
  initStore(hippoRoot);
  return openHippoDb(hippoRoot, (db) => {
    const scope = requestedScope && requestedScope !== '' ? requestedScope : null;
    return loadSearchRows(db, query, limit, tenantId, { value: scope }).map(rowToEntry);
  });
}
```

(Mirror `loadSearchEntries`'s actual openHippoDb pattern — the snippet above is illustrative; copy the canonical shape from the existing function.)

**Step 3: Run the failing test from B1.**

Run: `npx vitest run tests/api-recall-unknown-legacy-filter.test.ts`

Note: this still fails after Step 2 because `recall()` in `src/api.ts` still calls `loadSearchEntries` (the unfiltered loader). Fix in B3.

### Task B3: Migrate `recall()` to use `loadRecallSearchEntries`; remove redundant post-load filter

**Files:** modify `src/api.ts`.

**Step 1: Update import (api.ts:18 area):**

```typescript
import {
  // ...
  loadSearchEntries,
  loadRecallSearchEntries, // NEW
  // ...
} from './store.js';
```

**Step 2: Replace the `loadSearchEntries` call at api.ts:376-381:**

```typescript
const all = loadRecallSearchEntries(
  ctx.hippoRoot,
  opts.query,
  windowSize,
  ctx.tenantId,
  opts.scope,
);
```

(Note: rev.1 plan also flagged a latent code smell at api.ts:379 — `opts.scorerWindow` was passed instead of `windowSize`. Same defaulting collapses to 200 by accident. The fix migration above passes `windowSize` correctly.)

**Step 3: Remove the now-redundant exact-match branch at api.ts:386-388:**

```typescript
// BEFORE:
//   if (opts.scope !== undefined && opts.scope !== '') {
//     entries = all.filter((e) => e.scope === opts.scope);
//   } else {
//     entries = all.filter((e) => !isPrivateScope(e.scope ?? null));
//   }
// AFTER:
let entries: typeof all;
if (opts.scope !== undefined && opts.scope !== '') {
  // SQL already exact-matched via loadRecallSearchEntries.
  entries = all;
} else {
  // SQL excluded unknown:legacy; private:* filter stays in JS (regex).
  entries = all.filter((e) => !isPrivateScope(e.scope ?? null));
}
```

**Step 4: Run the failing test from B1 → expect PASS.**
**Step 5: Run the full suite to catch regressions.**

```bash
npx vitest run --reporter=dot
```

Expected: 1376 tests passing (1371 baseline + A1 + A2 + A3 + A4 + B1 = +5 new; A5 replaces 1, net 0).

**Step 6: Commit.**

```bash
git add src/store.ts src/api.ts
git commit -m "fix(recall): default-deny unknown:legacy at SQL producer layer (v1.7.1)"
```

### Task B4: Document the v1.7.2 follow-up — migrate continuity / drillDown / assemble inline closures

**Files:** modify `TODOS.md`. Add to a new `## v1.7.2` section:

```markdown
- [ ] **Migrate continuity / drillDown / assemble to `loadRecallSearchEntries`.** v1.7.1 fixed the recall baseRanked leak at the SQL producer; the inline scope closure in `recall()` continuity (api.ts:568+) and the post-load filters in `drillDown` / `assemble` still re-implement scope filtering JS-side. Move them onto the new helper so all four recall consumers share one source of truth. No user-visible change — those paths already filter correctly via `passesScopeFilterForRecall`; this is consolidation, not bug-fix.
- [ ] **`scorerWindow` transport exposure.** HTTP `/v1/memories` `scorer_window` query parse, MCP `hippo_recall` arg parse, `client.ts` thin-client serialize. Validation already lives in `recall()`.
```

Commit (not yet — hold for Track D bundling).

---

## Pre-publish — Mandatory review chain (P1[7]: runs BEFORE version bump)

### Task C1: Self-review

`git diff master...HEAD --stat`, then `git diff` per file. Check:
- No lying comments (v1.7.0 retro caught one in F5 preflight).
- Every test asserts something the v1.7.0 code did NOT already cover.
- B2 SQL changes apply scope-predicate to ALL FOUR paths in `loadSearchRows` (no-terms, FTS, LIKE, full-store fallback) — every path's WHERE-clause edited and bindings spliced in the right position.
- A5 env-var test correctly snapshot/restores `HIPPO_DISABLE_FTS` (no leak into other tests).
- No scope creep beyond plan items.

### Task C2: Codex cross-model diff-pass

Brief: "Review v1.7.1 patch on `master..feat/v1.7.1-review-tail`. Two changes: (1) test hardening for v1.7.0 contracts, (2) producer-layer fix for `unknown:legacy` leak via new `loadRecallSearchEntries` helper that pushes scope predicate into `loadSearchRows` SQL. **Specifically verify:** (a) the SQL `scopeClause` is applied to all four loadSearchRows paths with bindings in the right position; (b) the `HIPPO_DISABLE_FTS` env var actually routes through LIKE deterministically — `ensureOptionalFts` re-creates+re-backfills `memories_fts` on every `openHippoDb`, so any approach relying on table state is broken; (c) tests/api-recall-unknown-legacy-filter.test.ts fires red against rev.1 master and green against B3 fix — actually run the tests; (d) no consumer regression on background pipelines (`consolidate`, `embeddings`, `refine-llm`) which still call `loadSearchEntries` (unchanged signature)."

### Task C3: Senior `/review` (gstack 5-specialist parallel dispatch)

Specialists: testing, maintainability, security, performance, api-contract.

### Task C4: Address P0 + load-bearing P1 findings

Fix and re-run vitest. Defer INFOs to TODOS.md `## v1.7.2`.

---

## Release prep (after review chain)

### Task D1: Bump version to 1.7.1 in all manifests

```bash
grep -rn '"version".*1\.7\.0\|VERSION.*1\.7\.0' --include='*.json' --include='*.ts' . | grep -v node_modules | grep -v package-lock
```

Edit each: `package.json` (root), `openclaw.plugin.json` (root), `extensions/openclaw-plugin/package.json`, `extensions/openclaw-plugin/openclaw.plugin.json`, `src/version.ts`.

```bash
npm install
grep -rn '"version".*"1\.7\.0"' --include='*.json' . | grep -v node_modules | grep -v package-lock
# expect empty
git add package.json package-lock.json openclaw.plugin.json extensions/ src/version.ts
git commit -m "chore: bump to v1.7.1"
```

### Task D2: Update CHANGELOG.md

Insert at top, after `# Changelog`:

```markdown
## 1.7.1 (2026-05-06)

### Fixed
- **`unknown:legacy` scope leaked into `recall` baseRanked.** Pre-existing bug surfaced by codex during the v1.6.5 review. Fixed at the **producer layer** (root-cause): new `loadRecallSearchEntries` helper in `src/store.ts` pushes the recall-mode scope predicate into `loadSearchRows` SQL, so the leak cannot recur via a future consumer that forgets to re-filter. `recall()` migrated to the new helper; redundant post-load exact-match filter removed.

### Added
- `HIPPO_DISABLE_FTS=1` env var (`src/db.ts::isFtsAvailable`) — test/diagnostic hook to deterministically force the LIKE-fallback path. Lives in production code so all consumers of FTS-availability checks see the same answer.

### Tests
- `scorerWindow=1` lower-bound regression-pin (testing INFO #2).
- No-terms `ORDER BY created ASC, id ASC` assertion + `created` stamp roundtrip (testing INFO #3 + P1).
- Tenant-isolation under wide `scorerWindow`, asserted on id-set + length (testing INFO #5 + P1).
- HTTP `/v1/memories` `body.windowSize === 200` serialization (testing INFO #6).
- LIKE fallback now exercised via `HIPPO_DISABLE_FTS=1`; content-anchored (senior P2.3 + INFO #7).
- `unknown:legacy` default-deny coverage (codex finding from v1.6.5 review).

### Deferred to v1.7.2
- Migrate continuity / drillDown / assemble inline scope closures onto `loadRecallSearchEntries` (consolidation, not bug-fix).
- `scorerWindow` transport exposure (HTTP `scorer_window` + thin-client serialize).
```

```bash
git add CHANGELOG.md && git commit -m "docs: changelog for v1.7.1"
```

### Task D3: README "What's new" if pattern exists (P2[10] grep gate)

```bash
grep -c "What's new in v" README.md
# if > 0, proceed; else skip this task entirely
```

If > 0, insert above the most-recent entry:

```markdown
### What's new in v1.7.1

- Fixed `unknown:legacy` scope leak in BM25 base recall, at the producer layer (SQL predicate in `loadSearchRows` via new `loadRecallSearchEntries` helper) — leak cannot recur via a future consumer.
- Hardened test coverage on the v1.7.0 foundations (scorerWindow lower bound, no-terms ORDER BY, tenant isolation under wide scorerWindow, HTTP `windowSize` serialization, deterministic LIKE-fallback via `HIPPO_DISABLE_FTS` env var).
```

```bash
grep -n "What's new in v" README.md | head -3   # ascending lines, descending versions
git add README.md && git commit -m "docs: readme what's new for v1.7.1"
```

### Task D4: TODOS.md cleanup

- Delete the seven `- [ ]` lines under `## v1.7.1 — /review specialist deferrals from v1.7.0`.
- Move the codex-finding line under `## v1.7 — codex finding from v1.6.5 review` into the changelog (now closed).
- Ensure the v1.7.2 section added in B4 is in place.

```bash
git add TODOS.md && git commit -m "docs: close v1.7.1 deferrals; surface v1.7.2 follow-ups"
```

### Task D5: Ship-check

Invoke gstack `/ship-check` (or sub-agent equivalent). Final gate.

### Task D6: Merge to master + tag

```bash
git checkout master
git merge --no-ff feat/v1.7.1-review-tail -m "Merge v1.7.1 review-tail patch"
git tag v1.7.1
```

---

## Publish

### Task E1: `/publish-repo` end-to-end

Confirm branch (master), build, test, `npm publish`, push master + v1.7.1 tag, global install verify (`hippo --version` = 1.7.1), post-publish doc audit.

---

## Out of scope (deferred to v1.7.2)

- Migrate continuity / drillDown / assemble inline scope closures onto `loadRecallSearchEntries`.
- `scorerWindow` transport exposure (HTTP + MCP + thin-client).
- Original deferred-queue tracks T1/T2/T3 (CLI fresh-tail parity, HTTP path matcher slashes-in-`:id`, mean-of-children re-rank).

---

## Success criteria

- 6 INFOs from v1.7.0 `/review` closed by tests that fire red on master without the v1.7.0 logic, OR pin behaviour that would silently regress.
- 1 codex finding (`unknown:legacy` leak) closed at the **SQL producer layer**, not per-consumer.
- All 6 manifests at `1.7.1`; `npm view hippo-memory@1.7.1 version` = `1.7.1`; `hippo --version` = `1.7.1`.
- Test count = **1376** (1371 baseline + 5 new; 1 replaced).
- Zero behaviour change for in-spec callers (no contract drift on `RecallResult`, `RecallOpts`, HTTP `/v1/memories`, MCP `hippo_recall`).
- `HIPPO_DISABLE_FTS=1` documented in CHANGELOG as test/diagnostic hook (not a public knob).
