# v1.7.2 Consolidation Implementation Plan (rev. 2 — consolidated)

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

**Goal:** Close the 4 hygiene items deferred from v1.7.1. Patch release. No behaviour change for in-spec callers NOT setting `scorer_window`; new opt-in transport surfaces for `scorer_window` (additive on patch is permitted under semver — backward-compatible).

**Architecture (track ordering reflects execution order — eng + codex P0-3 fix):**
- **T1 — Internal type refactor.** Replace `loadSearchRows::recallScope?: { value: string | null }` boxed-nullable with discriminated union exported as `RecallScopeFilter = { mode: 'default-deny' } | { mode: 'exact'; value: string }`. Internal-only — public `loadRecallSearchEntries(requestedScope?: string)` shape unchanged. Lands FIRST so T2 can dispatch on `recallScope.mode` cleanly.
- **T2 — Anti-drift constant.** Extract literal recall-mode deny list into a shared `RECALL_DEFAULT_DENY_SCOPES` constant exported from `src/store.ts`. SQL `loadSearchRows` reads from it via `NOT IN (?, ?, ...)`; JS `passesScopeFilterForRecall` reads from it via `.includes()`. One source of truth for literal denies. Regex-based `<source>:private:*` filter stays separate (different mechanism).
- **T3 — CLI/shared asymmetry doc-only.** `searchBoth` / `searchBothHybrid` (`src/shared.ts:96, 172`, used by `cli.ts:783, 1429`) keep full visibility on `unknown:legacy` rows by design — operators investigating quarantine want to see them. Library `recall()` from `index.ts` has default-deny via `loadRecallSearchEntries`. Document the asymmetry in CHANGELOG explicitly.
- **T4 — `scorerWindow` transport exposure (parity sweep).** HTTP `/v1/memories?scorer_window=N`, MCP `hippo_recall.scorer_window`, thin-client `src/client.ts` serialize. Codex CRITICAL[1] caught that the thin-client is ALREADY missing `freshTailCount`, `freshTailSessionId`, `summarizeOverflow` — broaden T4 to a full RecallOpts parity sweep so v1.7.3 doesn't reopen this. Validation lives in `recall()` via RecallContractError; both transports `Number()`-coerce and forward (codex P0-1: MCP currently has NO error wrap — must add explicit try/catch with code propagation).

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

**Branch:** `feat/v1.7.2-consolidation` off master (already created).

**Revision history:**
- rev. 1 (committed `f706d76`): initial draft, 4 tracks, T1+T4 sequencing was contradictory.
- rev. 2 (this file): incorporates eng-review + codex-adversarial revisions. Track order swapped (T4 → T1; T1 → T2). MCP `RecallContractError` handling added (codex P0-1). HTTP/MCP `scorer_window` validation aligned (codex CRITICAL[2]). Thin-client broadened to full RecallOpts parity sweep (codex CRITICAL[1]). `.includes()` cast fixed. `RecallScopeFilter` exported. Module-load assertion on empty deny list. MCP test asserts behaviour, not text. CHANGELOG framing rephrased. CLI/shared documented as asymmetry.

---

## Pre-flight

### Task 0: Baseline test run

Run: `git branch --show-current` → expect `feat/v1.7.2-consolidation`.
Run: `npx vitest run --reporter=dot` → count baseline. (Plan was drafted assuming 1380; confirm before relying on it for test-count math. v1.7.1 ship state was 1380 passing.)
No commit.

---

## Track T1 — Internal `RecallScopeFilter` discriminated union

### T1.1: Export the type and migrate `loadSearchRows` parameter

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

**Step 1: Export the type next to `MEMORY_SEARCH_COLUMNS`:**

```typescript
/**
 * v1.7.2 — internal recall-mode scope filter shape, exported so callers
 * (`loadRecallSearchEntries`) and tests can refer to it symbolically without
 * `Parameters<typeof loadSearchRows>[N]` indirection (codex P1-3).
 *
 * Two modes:
 *   - 'default-deny' — exclude scopes in `RECALL_DEFAULT_DENY_SCOPES` (T2).
 *   - 'exact' — exact match on `m.scope = value`.
 *
 * Background pipelines (`consolidate`, `embeddings`, `refine-llm`, ...)
 * call `loadSearchEntries` (no recallScope arg) and see all rows including
 * quarantine.
 */
export type RecallScopeFilter =
  | { mode: 'default-deny' }
  | { mode: 'exact'; value: string };
```

**Step 2: Refactor `loadSearchRows` signature and dispatch:**

```typescript
function loadSearchRows(
  db: ReturnType<typeof openHippoDb>,
  query: string,
  limit: number,
  tenantId: string | undefined,
  recallScope?: RecallScopeFilter,
): MemoryRow[] {
  // ... tenant predicate unchanged ...
  let scopeClauseAlias = '';
  let scopeClauseNoAlias = '';
  let scopeClauseTenantOnly = '';
  const scopeParams: string[] = [];
  if (recallScope !== undefined) {
    if (recallScope.mode === 'default-deny') {
      // T1: pre-T2 still uses literal 'unknown:legacy'. T2 swaps in the constant.
      scopeClauseAlias = ` AND (m.scope IS NULL OR m.scope != 'unknown:legacy')`;
      scopeClauseNoAlias = ` AND (scope IS NULL OR scope != 'unknown:legacy')`;
      scopeClauseTenantOnly = scopeClauseNoAlias;
    } else {
      // mode === 'exact'
      scopeClauseAlias = ` AND m.scope = ?`;
      scopeClauseNoAlias = ` AND scope = ?`;
      scopeClauseTenantOnly = scopeClauseNoAlias;
      scopeParams.push(recallScope.value);
    }
  }
  // ... rest of function unchanged ...
}
```

**Step 3: Update `loadRecallSearchEntries` to construct the discriminated form:**

```typescript
export function loadRecallSearchEntries(
  hippoRoot: string,
  query: string,
  limit: number = DEFAULT_SEARCH_CANDIDATE_LIMIT,
  tenantId: string,
  requestedScope?: string,
): MemoryEntry[] {
  initStore(hippoRoot);
  const db = openHippoDb(hippoRoot);
  try {
    const recallScope: RecallScopeFilter =
      requestedScope && requestedScope !== ''
        ? { mode: 'exact', value: requestedScope }
        : { mode: 'default-deny' };
    return loadSearchRows(db, query, limit, tenantId, recallScope).map(rowToEntry);
  } finally {
    closeHippoDb(db);
  }
}
```

**Step 4:** Run full vitest → expect zero regressions (internal shape only; behaviour unchanged).
**Step 5: Commit.**

```bash
git add src/store.ts
git commit -m "refactor: export RecallScopeFilter discriminated union (v1.7.2 T1)"
```

### T1.2: Test pinning the new exported type

**Files:** create `tests/store-recall-scope-filter-type.test.ts`.

```typescript
import { describe, it, expect } from 'vitest';
import type { RecallScopeFilter } from '../src/store.js';

describe('RecallScopeFilter discriminated union (v1.7.2 T1)', () => {
  it('default-deny construction is type-safe', () => {
    const f: RecallScopeFilter = { mode: 'default-deny' };
    expect(f.mode).toBe('default-deny');
  });

  it('exact construction requires value', () => {
    const f: RecallScopeFilter = { mode: 'exact', value: 'x' };
    expect(f.mode).toBe('exact');
    if (f.mode === 'exact') expect(f.value).toBe('x');
  });
});
```

Commit: `git commit -m "test: pin RecallScopeFilter type contract (v1.7.2 T1)"`

---

## Track T2 — `RECALL_DEFAULT_DENY_SCOPES` shared constant

Lands AFTER T1 so dispatch on `recallScope.mode` is already in place.

### T2.1: Failing test — single source of truth

**Files:** create `tests/api-recall-deny-scopes-source-of-truth.test.ts`.

```typescript
/**
 * v1.7.2 T2 — RECALL_DEFAULT_DENY_SCOPES is the single source of truth.
 *
 * Pre-T2: SQL clause hardcodes `m.scope != 'unknown:legacy'`; JS
 * passesScopeFilterForRecall independently checks scope === 'unknown:legacy'.
 * Two places to update if the deny list grows.
 *
 * T2: extract `RECALL_DEFAULT_DENY_SCOPES` as a const. SQL reads it via
 * NOT IN (?, ...) bindings; JS reads via .includes(). Adding a literal scope
 * to the constant is a one-place change.
 */

import { describe, it, expect } from 'vitest';
import { RECALL_DEFAULT_DENY_SCOPES } from '../src/store.js';
import { passesScopeFilterForRecall } from '../src/api.js';

describe('RECALL_DEFAULT_DENY_SCOPES — single source of truth (v1.7.2 T2)', () => {
  it('every literal in the constant is excluded by passesScopeFilterForRecall when no scope is requested', () => {
    for (const scope of RECALL_DEFAULT_DENY_SCOPES) {
      expect(passesScopeFilterForRecall(scope, undefined)).toBe(false);
    }
  });

  it('every literal in the constant is admitted when explicitly requested', () => {
    for (const scope of RECALL_DEFAULT_DENY_SCOPES) {
      expect(passesScopeFilterForRecall(scope, scope)).toBe(true);
    }
  });

  it('contains unknown:legacy at v1.7.2 ship time (regression-pin)', () => {
    expect(RECALL_DEFAULT_DENY_SCOPES).toContain('unknown:legacy');
  });

  it('is non-empty (codex P1-5: empty list silently allows quarantine)', () => {
    // Module-load invariant: blanking the array silently allows
    // unknown:legacy through. Pin loudly so a maintainer sees the test fail.
    expect(RECALL_DEFAULT_DENY_SCOPES.length).toBeGreaterThan(0);
  });
});
```

Run: expect FAIL (constant not exported yet). Commit failing test.

### T2.2: Extract constant + module-load assertion

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

```typescript
/**
 * v1.7.2 — literal scopes excluded from recall by default-deny when the
 * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
 * helper `passesScopeFilterForRecall` (src/api.ts) both read from this
 * constant. Adding a deny scope is a one-place change.
 *
 * Regex-based denies (e.g. `<source>:private:*`) stay in
 * `passesScopeFilterForRecall` as a separate JS step — they don't translate
 * cleanly to SQL.
 *
 * Invariant: never empty. Empty array would silently allow quarantine
 * scopes through both paths (SQL clause omitted, JS check vacuous).
 */
export const RECALL_DEFAULT_DENY_SCOPES = ['unknown:legacy'] as const;

// Module-load loud-failure on misconfiguration (codex P1-5).
if (RECALL_DEFAULT_DENY_SCOPES.length === 0) {
  throw new Error(
    'RECALL_DEFAULT_DENY_SCOPES cannot be empty — would silently allow quarantine scopes',
  );
}
```

### T2.3: SQL `NOT IN` bindings + JS `.includes()` consumer

**Files:** modify `src/store.ts` `loadSearchRows` default-deny branch and `src/api.ts` `passesScopeFilterForRecall`.

**Step 1: SQL — replace hardcoded literal with bound parameters:**

```typescript
if (recallScope.mode === 'default-deny') {
  // T2: bind from RECALL_DEFAULT_DENY_SCOPES so SQL and JS share one source
  // of truth. Module-load assertion above guarantees length > 0, so
  // NOT IN () (SQL parse error) is impossible.
  const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
  scopeClauseAlias = ` AND (m.scope IS NULL OR m.scope NOT IN (${placeholders}))`;
  scopeClauseNoAlias = ` AND (scope IS NULL OR scope NOT IN (${placeholders}))`;
  scopeClauseTenantOnly = scopeClauseNoAlias;
  scopeParams.push(...RECALL_DEFAULT_DENY_SCOPES);
}
```

NULL handling: `m.scope NOT IN (?, ?)` returns NULL on `m.scope = NULL` (three-valued logic). The `m.scope IS NULL OR ...` disjunct admits NULL rows. Verified.

**Step 2: JS — replace hardcoded `=== 'unknown:legacy'` with `.includes()`:**

```typescript
import { RECALL_DEFAULT_DENY_SCOPES } from './store.js';

// inside passesScopeFilterForRecall, when requested is unset:
if (scope === null) return true;
if (isPrivateScope(scope)) return false;
// Cast to readonly string[] — codex P0-2: casting `scope as <const-elem-type>`
// would defeat the safety. Cast the array, not the input.
if ((RECALL_DEFAULT_DENY_SCOPES as readonly string[]).includes(scope)) return false;
return true;
```

Run T2.1 failing test → expect PASS. Run full vitest → expect no regressions.
Commit: `git commit -m "refactor: extract RECALL_DEFAULT_DENY_SCOPES (v1.7.2 T2)"`

---

## Track T3 — Document CLI/shared asymmetry (no migration)

### T3.1: Decision recorded in CHANGELOG only

**Files:** none directly. The CHANGELOG entry in Phase 5 D2 will read:

```markdown
### Documented (no migration — by design)

- **CLI recall via `searchBoth` / `searchBothHybrid` (`src/shared.ts:96, 172`, used by `cli.ts:783, 1429`) does NOT default-deny on `unknown:legacy`.** This is intentional: CLI is an operator-local surface where investigating the quarantine bucket is a feature. The library `recall()` from `index.ts` (used by frontend / programmatic callers) goes through `loadRecallSearchEntries` and DOES default-deny. Asymmetry is honest: import the library API for default-deny semantics; use the CLI for full operator visibility.
- **No `--include-quarantine` CLI flag in v1.7.2.** Considered and deferred: would only be load-bearing if CLI semantics changed to default-deny first. Revisit if the asymmetry causes friction.
```

No code change. Decision committed via the CHANGELOG entry.

---

## Track T4 — `scorerWindow` + thin-client RecallOpts parity sweep

### T4.1: Failing test — HTTP `/v1/memories?scorer_window=N`

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

Mirror canonical HTTP test boilerplate (`serve()` / `ServerHandle`):

```typescript
/**
 * v1.7.2 T4 — HTTP /v1/memories accepts `scorer_window` query param.
 *
 * Validation lives in api.recall() (RecallContractError on invalid).
 * Transport calls Number(...) and forwards. RecallContractError → 400 with
 * `{error, code: 'invalid_scorer_window'}`.
 */

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';

function makeRoot(): string {
  const home = mkdtempSync(join(tmpdir(), 'hippo-http-sw-'));
  mkdirSync(join(home, '.hippo'), { recursive: true });
  initStore(home);
  return home;
}

let home: string;
let handle: ServerHandle;

beforeEach(async () => {
  home = makeRoot();
  for (let i = 0; i < 10; i++) {
    writeEntry(home, createMemory(`alpha ${i}`, {
      layer: Layer.Buffer,
      kind: 'raw' as MemoryKind,
      tenantId: 'default',
    }));
  }
  handle = await serve({ hippoRoot: home, port: 0 });
});
afterEach(async () => {
  await handle.stop();
  rmSync(home, { recursive: true, force: true });
});

describe('HTTP /v1/memories scorer_window (v1.7.2 T4)', () => {
  it('scorer_window=5 narrows the candidate pool: response.windowSize=5', async () => {
    const res = await fetch(`${handle.url}/v1/memories?q=alpha&scorer_window=5`);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { windowSize?: number; total?: number };
    expect(body.windowSize).toBe(5);
    expect(body.total).toBe(5); // RecallResult.total = entries.length (api.ts:617)
  });

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

  it('scorer_window=0 returns 400 with code=invalid_scorer_window', async () => {
    const res = await fetch(`${handle.url}/v1/memories?q=alpha&scorer_window=0`);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error?: string; code?: string };
    expect(body.code).toBe('invalid_scorer_window');
  });

  it('scorer_window=abc returns 400 (NaN forwarded; recall() rejects)', async () => {
    const res = await fetch(`${handle.url}/v1/memories?q=alpha&scorer_window=abc`);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error?: string; code?: string };
    expect(body.code).toBe('invalid_scorer_window');
  });
});
```

Run → expect FAIL (server doesn't parse the param yet). Commit failing test.

### T4.2: Wire `scorer_window` in `src/server.ts` GET /v1/memories handler

**Files:** modify `src/server.ts` near the `/v1/memories` handler (~line 470).

```typescript
const scorerWindowRaw = query.get('scorer_window');
let scorerWindow: number | undefined = undefined;
if (scorerWindowRaw !== null) {
  // Forward as Number(...) — NaN, 0, negative all reach recall() which
  // throws RecallContractError with the typed code.
  scorerWindow = Number(scorerWindowRaw);
}
// pass scorerWindow into the recall() opts construct alongside other fields
```

`RecallContractError` is already mapped to 400 with `body.code` (v1.7.0 fix for api-contract specialist CRITICAL). Verify the mapping is in place; no change needed.

Run failing T4.1 tests → expect PASS. Commit.

### T4.3: Failing test — MCP `hippo_recall.scorer_window` (asserts behaviour, not text)

**Files:** create `tests/mcp-recall-scorer-window.test.ts`.

`formatMemories` in `src/mcp/server.ts` does NOT include `windowSize` in the rendered text (codex P2-3 verified). Assert observable behaviour: narrower `scorer_window` returns fewer/different memory rows than default. AND assert that `scorer_window=0` propagates the `RecallContractError.code` over JSON-RPC.

```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 { handleMcpRequest } from '../src/mcp/server.js';
import { RecallContractError } from '../src/api.js';

function makeRoot(): string {
  const home = mkdtempSync(join(tmpdir(), 'hippo-mcp-sw-'));
  mkdirSync(join(home, '.hippo'), { recursive: true });
  initStore(home);
  return home;
}

function callTool(name: string, args: Record<string, unknown>, ctx: { hippoRoot: string; tenantId: string; actor: string }) {
  return handleMcpRequest(
    { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } },
    ctx,
  );
}

describe('MCP hippo_recall scorer_window (v1.7.2 T4)', () => {
  let home: string;

  beforeEach(() => {
    home = makeRoot();
    for (let i = 0; i < 10; i++) {
      writeEntry(home, createMemory(`alpha ${i}`, {
        layer: Layer.Buffer, kind: 'raw' as MemoryKind, tenantId: 'default',
      }));
    }
  });

  afterEach(() => rmSync(home, { recursive: true, force: true }));

  it('scorer_window narrows recall — smaller pool returns fewer/different rows than default', async () => {
    // Behavioural assertion: under scorer_window=2, recall sees fewer rows
    // than under default (200). Compare row counts in the rendered text.
    const narrow = await callTool(
      'hippo_recall',
      { query: 'alpha', budget: 5000, scorer_window: 2 },
      { hippoRoot: home, tenantId: 'default', actor: 'mcp' },
    ) as { result?: { content: Array<{ text: string }> } };
    const wide = await callTool(
      'hippo_recall',
      { query: 'alpha', budget: 5000 },
      { hippoRoot: home, tenantId: 'default', actor: 'mcp' },
    ) as { result?: { content: Array<{ text: string }> } };
    // Under default, recall sees all 10. Under scorer_window=2, sees 2.
    // formatMemories renders one item per match; line count is a proxy.
    const narrowText = narrow.result?.content?.[0]?.text ?? '';
    const wideText = wide.result?.content?.[0]?.text ?? '';
    expect(narrowText.length).toBeLessThan(wideText.length);
  });

  it('scorer_window=0 throws RecallContractError with code propagated', async () => {
    let thrown: unknown = null;
    try {
      await callTool(
        'hippo_recall',
        { query: 'alpha', scorer_window: 0 },
        { hippoRoot: home, tenantId: 'default', actor: 'mcp' },
      );
    } catch (err) {
      thrown = err;
    }
    expect(thrown).toBeInstanceOf(RecallContractError);
    expect((thrown as RecallContractError).code).toBe('invalid_scorer_window');
  });

  it('scorer_window="abc" (string, transport-coerced) rejects with invalid_scorer_window', async () => {
    // Codex CRITICAL[2]: MCP must Number()-coerce so non-numeric input
    // produces the same typed rejection as HTTP, not a silent default-200.
    let thrown: unknown = null;
    try {
      await callTool(
        'hippo_recall',
        { query: 'alpha', scorer_window: 'abc' },
        { hippoRoot: home, tenantId: 'default', actor: 'mcp' },
      );
    } catch (err) {
      thrown = err;
    }
    expect(thrown).toBeInstanceOf(RecallContractError);
    expect((thrown as RecallContractError).code).toBe('invalid_scorer_window');
  });
});
```

Run → expect FAIL on the throw-propagation tests. Commit failing tests.

### T4.4: Wire `scorer_window` in `src/mcp/server.ts` (with explicit `RecallContractError` handling — codex P0-1)

**Files:** modify `src/mcp/server.ts`.

**Step 1: Add `scorer_window` to `hippo_recall` `inputSchema.properties`:**

```typescript
scorer_window: {
  type: 'number',
  description: 'Candidate pool size the scorer evaluates. Decoupled from "limit" (results returned). Default 200. Set higher to widen the pool when summarize_overflow is true.',
},
```

**Step 2: Parse args — `Number()`-coerce so non-numeric values reach `recall()` for typed rejection (codex CRITICAL[2] — match HTTP behaviour):**

```typescript
const scorerWindowRaw = (args as Record<string, unknown>).scorer_window;
let scorerWindow: number | undefined = undefined;
if (scorerWindowRaw !== undefined) {
  scorerWindow = Number(scorerWindowRaw);  // 'abc' → NaN → recall() rejects
}
```

**Step 3: Wrap the `apiRecall` call in try/catch on the MCP `hippo_recall` case (codex P0-1):**

The current `case 'hippo_recall'` block has no try/catch around `apiRecall`. A `RecallContractError` would propagate as a generic JSON-RPC `internal_error`, losing the typed `code`. Per the `mcp-recall-fresh-tail-policy.test.ts` contract (v1.6.5), throws are the caller's responsibility — but the calling tests catch and inspect `.code`, so the throw must reach them with the class and code intact.

Verify by reading the existing dispatch first. If it already lets exceptions propagate raw to the caller, the tests pass without explicit wrapping. If the dispatch wraps everything as `internal_error`, add a typed-error escape:

```typescript
case 'hippo_recall': {
  // ... arg parsing ...
  try {
    const result = apiRecall(ctx, opts);
    // ... format and return ...
  } catch (err) {
    // RecallContractError must reach the caller with class + code intact
    // so transport-side handlers (HTTP, stdio, http-mcp) can surface the
    // typed rejection. Re-throw unwrapped.
    if (err instanceof RecallContractError) throw err;
    throw err;  // any other error keeps existing semantics
  }
}
```

(Read existing code first; the right shape may be "do nothing" if exceptions already propagate. Don't add the try/catch if it's a no-op.)

Run failing T4.3 tests → expect PASS. Commit.

### T4.5: Thin-client RecallOpts parity sweep — `src/client.ts`

Codex CRITICAL[1]: `client.ts` already missing `freshTailCount`, `freshTailSessionId`, `summarizeOverflow`. Add ALL FOUR (those three + `scorerWindow`) in one commit.

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

**Step 1: Add the four fields to the client's `RecallOpts` shape and the URL serializer:**

```typescript
export interface RemoteRecallOpts {
  query: string;
  limit?: number;
  mode?: 'bm25' | 'hybrid' | 'physics';
  scope?: string;
  includeContinuity?: boolean;
  freshTailCount?: number;        // NEW
  freshTailSessionId?: string;    // NEW
  summarizeOverflow?: boolean;    // NEW
  scorerWindow?: number;          // NEW
}

// In the URL builder:
if (opts.freshTailCount !== undefined) params.set('fresh_tail_count', String(opts.freshTailCount));
if (opts.freshTailSessionId !== undefined) params.set('fresh_tail_session_id', opts.freshTailSessionId);
if (opts.summarizeOverflow !== undefined) params.set('summarize_overflow', opts.summarizeOverflow ? '1' : '0');
if (opts.scorerWindow !== undefined) params.set('scorer_window', String(opts.scorerWindow));
```

**Step 2: Test — spy on `fetch`, assert wire format (codex P2-2):**

```typescript
import { describe, it, expect, vi } from 'vitest';
import { recallRemote } from '../src/client.js';

describe('client RecallOpts parity sweep over the wire (v1.7.2 T4)', () => {
  it('serializes all four v1.7.2 RecallOpts fields as query params', async () => {
    const fetchSpy = vi.fn(() => Promise.resolve(new Response('{}', { status: 200 })));
    globalThis.fetch = fetchSpy as unknown as typeof fetch;

    await recallRemote('http://localhost:9999', {
      query: 'alpha',
      freshTailCount: 5,
      freshTailSessionId: 'sess-A',
      summarizeOverflow: true,
      scorerWindow: 50,
    });

    expect(fetchSpy).toHaveBeenCalledOnce();
    const url = String(fetchSpy.mock.calls[0]![0]);
    expect(url).toContain('fresh_tail_count=5');
    expect(url).toContain('fresh_tail_session_id=sess-A');
    expect(url).toContain('summarize_overflow=1');
    expect(url).toContain('scorer_window=50');
  });
});
```

(Adjust import / function name to whatever the thin-client actually exports — read the file first.)

Commit: `git commit -m "feat(client): RecallOpts parity sweep — fresh_tail/summarize/scorer_window (v1.7.2 T4)"`

---

## Pre-publish — Mandatory review chain

### C1: Self-review

`git diff master...HEAD` — check:
- T1 → T2 → T3 → T4 commit ordering is clean (each commit independently green; `git bisect` works).
- `RECALL_DEFAULT_DENY_SCOPES` is the single literal source for both SQL and JS. Module-load assertion fires on empty array.
- `(RECALL_DEFAULT_DENY_SCOPES as readonly string[]).includes(scope)` — no cast on `scope`.
- T3 documented in CHANGELOG, no code change.
- T4 transports Number()-coerce consistently. MCP error wrap propagates `.code`.
- Thin-client serializes ALL FOUR RecallOpts fields (not just `scorer_window`).
- No lying comments, no scope creep beyond plan.

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

Brief: "Review v1.7.2 patch on `master..feat/v1.7.2-consolidation`. Four tracks: T1 RecallScopeFilter discriminated union (lands first), T2 RECALL_DEFAULT_DENY_SCOPES constant + module-load assertion, T3 doc-only on CLI/shared asymmetry, T4 `scorer_window` parity sweep across HTTP + MCP + thin-client (all four RecallOpts fields). Specifically verify: (a) HTTP and MCP both Number()-coerce `scorer_window` and let `recall()` reject (no transport-side validation drift); (b) MCP `hippo_recall` dispatch propagates `RecallContractError.code` to JSON-RPC layer correctly; (c) thin-client URL contains all four params; (d) module-load assertion on empty `RECALL_DEFAULT_DENY_SCOPES` actually fires; (e) T1+T2 commit ordering is `git bisect`-clean."

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

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

### C4: Address P0 + load-bearing P1 findings; defer INFOs to v1.7.3.

---

## Release prep (after review chain)

### D1: Bump version to 1.7.2 in 6 manifests

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

Edit each, run `npm install`, verify zero stragglers, commit.

### D2: CHANGELOG.md

Insert at top, after `# Changelog`:

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

Hygiene release closing the four consolidation items deferred from v1.7.1. **No behaviour change for in-spec callers not setting `scorer_window`; new opt-in transport surfaces for `scorer_window` and three pre-existing RecallOpts fields the thin-client was missing.** Additive on patch is permitted under semver since backward-compatible.

### Added

- **`scorerWindow` over the wire.** HTTP `/v1/memories?scorer_window=N`, MCP `hippo_recall.scorer_window`, `client.ts` thin-client serializes `scorerWindow`. Validation lives in `api.recall()` — both transports `Number()`-coerce and forward (string `"abc"`, `0`, negative, non-finite all reach `recall()` for typed rejection with `code: 'invalid_scorer_window'`). MCP `hippo_recall` dispatch verified to propagate `RecallContractError` with class and code intact.
- **Thin-client RecallOpts parity sweep.** `client.ts::recallRemote` previously missing `freshTailCount`, `freshTailSessionId`, `summarizeOverflow` — adding `scorerWindow` alone would have perpetuated drift. All four serialized in this release.
- **`RECALL_DEFAULT_DENY_SCOPES` constant** in `src/store.ts`. SQL clause in `loadSearchRows` and JS `passesScopeFilterForRecall` (src/api.ts) both read from it. Adding a literal deny scope is now a one-place change. Regex-based denies (`<source>:private:*`) stay in JS as a separate step. Module-load assertion fails loudly on empty array.
- **`RecallScopeFilter`** discriminated union exported from `src/store.ts` — `{ mode: 'default-deny' } | { mode: 'exact'; value: string }`. Internal-only consumer is `loadRecallSearchEntries`; public signature unchanged.

### Refactored (no behaviour change)

- `loadSearchRows::recallScope` parameter shape replaced from boxed-nullable `{ value: string | null }` with the discriminated union above. Eliminates the `{ value: undefined }` foot-gun the v1.7.1 review flagged.

### Documented (no migration — by design)

- **CLI recall via `searchBoth` / `searchBothHybrid` (`src/shared.ts:96, 172`, used by `cli.ts:783, 1429`) does NOT default-deny on `unknown:legacy`.** Intentional asymmetry: CLI is an operator-local surface where investigating the quarantine bucket is a feature. Library `recall()` from `index.ts` (used by frontend / programmatic callers) goes through `loadRecallSearchEntries` and DOES default-deny. Import the library API for default-deny semantics; use the CLI for full operator visibility. No `--include-quarantine` flag added; revisit if friction surfaces.

### Deferred to v1.7.3

(populated by C2/C3 review chain; will be filled before tagging)
```

### D3: README "What's new" if pattern exists

```bash
grep -c "What's new in v" README.md
```

If > 0, insert above v1.7.1:

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

- **`scorerWindow` over the wire.** HTTP `/v1/memories?scorer_window=N`, MCP `hippo_recall.scorer_window`, thin-client serializes `scorerWindow`. Validation unchanged (`recall()` rejects 0/negative/non-finite/non-numeric with `RecallContractError code: invalid_scorer_window`).
- **Thin-client parity sweep.** `client.ts` now serializes all four RecallOpts (`fresh_tail_count`, `fresh_tail_session_id`, `summarize_overflow`, `scorer_window`); previously only the first three over HTTP, missing in client.
- **Single source of truth for default-deny recall scopes.** Adding a literal deny scope is a one-place change.
- **Internal type cleanup.** `loadSearchRows::recallScope` is a discriminated union; can't construct an invalid intermediate.
```

### D4: TODOS.md

Delete the four `## v1.7.2 — consolidation follow-ups from v1.7.1` items (all addressed). Surface any new deferrals from the review chain into a `## v1.7.3` section.

### D5: Ship-check (final pre-publish gate).

### D6: Merge + tag.

```bash
git checkout master
git merge --no-ff feat/v1.7.2-consolidation -m "Merge v1.7.2 consolidation patch"
git tag v1.7.2
```

---

## Publish

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

Confirm branch (master), build, test, `npm publish`, push master + v1.7.2 tag, global install verify (`hippo --version` = 1.7.2), post-publish doc audit. **Pause for confirmation before npm publish + push** (irreversible / external state).

---

## Out of scope (deferred to v1.7.3+ or later)

- Original deferred-queue tracks (CLI fresh-tail / summarize-overflow parity, HTTP path matcher slashes-in-`:id`, mean-of-children re-rank). Each is its own track with its own /codex pass.
- v1.8 candidate work: recall ranked-memory unified shape (`recallRanked()` API), JS-side BM25 unification.
- v0.26 UI redesign (warm parchment + 3D Three.js).
- `--include-quarantine` CLI flag (T3 deferral — only load-bearing if CLI semantics changed to default-deny first).

---

## Success criteria

- 4 v1.7.2 deferred items closed (T1+T2+T4 code; T3 documented).
- All 6 manifests at `1.7.2`; `npm view hippo-memory@1.7.2 version` = `1.7.2`; `hippo --version` = `1.7.2`.
- Test count baseline (1380) + ~12 new (T1.2: 2; T2.1: 4; T4.1 HTTP: 4; T4.3 MCP: 3; T4.5 client: 1). Realistic ≈1392. Confirm baseline first via `npx vitest run` before relying on the math (rev. 2 fix).
- Zero behaviour change for in-spec callers not setting `scorer_window`. New opt-in transport surface across HTTP / MCP / client.
- HTTP and MCP transports consistently reject invalid `scorer_window` with `code: 'invalid_scorer_window'` (no transport-side validation drift).
- MCP `hippo_recall` dispatch verified to propagate `RecallContractError` class and code over JSON-RPC.
- Thin-client `client.ts::recallRemote` serializes all four RecallOpts (`fresh_tail_count`, `fresh_tail_session_id`, `summarize_overflow`, `scorer_window`).
