# v1.7 Foundations Plan (mk2) — make recall surface honest

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Do NOT skip the per-task review chain (`/codex review --base v1.6.4` + `/review` + `/self-review` + `/ship-check`). Two prior versions of this plan were killed by review chains catching real architectural traps; this revision incorporates 14 codex findings + 9 eng-review findings.**

**Status:** Draft mk2 2026-05-06, with mk3 codex-revisions appended (see "Mk3 codex revisions" below). Supersedes mk1 (same filename, prior commit) and `2026-05-06-v1.7-deferred-queue.md`. Mk1 was rejected after `/codex review` + `/plan-eng-review` found 4 P0s. Mk2 drops F2 entirely, reverses F3's default, qualifies F1 SQL, fixes F5 placement, and adds MCP coverage. Mk3 (post-second-codex) drops `hardLimit`, drops MCP cap (no contract to fix), and fixes F3's scorerWindow default to preserve the existing 200-row candidate load.

## Mk3 codex revisions (2026-05-06, second pass)

Second `/codex review` round on mk2 found:

- **P0-1: F3 default broken.** `scorerWindow ?? limit ?? 10` would shrink the existing `loadSearchEntries(undefined, ...)` 200-row candidate load down to `limit`, killing overflow summaries (substitution depends on `entries.length > limit`). **Fix:** default `scorerWindow` to `undefined`, let store use its 200-row default.
- **P0-2: MCP cap non-bug.** `hippo_recall` has no `limit` arg in its input schema (only `budget`); there's no contract to fix. **Fix:** drop MCP cap entirely from v1.7.0. If we want one, that's a new feature with its own plan.
- **P1-1: API hard cap contradicts existing semantics.** Plan said to add `hardLimit`; current API behavior is "limit caps base, fresh-tail/summaries expand." **Fix:** drop `hardLimit` entirely. CLI's existing `cli.ts:1199` slice handles the CLI hard cap. API callers slice themselves if they want one.
- **P1-4: F1 and F3 are independent.** No sequencing dependency.
- **P2-2: LIKE test must force `isFtsAvailable=false`.** Don't rely on a miss to reach the LIKE branch.

After mk3 revisions, v1.7.0 ships only:
- **F1**: BM25 provenance plumbing on FTS path (unchanged from mk2).
- **F3 (revised)**: add `RecallOpts.scorerWindow` opt-in (defaults to `undefined` → store-default 200-row candidate load preserved); add `RecallResult.windowSize` for caller introspection. **No `hardLimit`. No MCP cap.**

Implementation below is mk3-revised throughout.

**Goal:** Build the minimum recall-surface foundation that the deferred-queue plan assumed already existed. After this lands, the deferred-queue items become trivial follow-ons. Until this lands, every "wire CLI parity / re-rank summaries / extend recall" plan repeats the same mistakes.

**Architecture (mk2):**
- **F1.** Plumb `bm25(memories_fts) AS bm25_score` onto `MemoryEntry` returned by `loadSearchEntries` — **FTS path only**. LIKE / no-terms / full-fallback paths return entries with `bm25_score === undefined`. Provenance metadata only; not a drop-in scorer input.
- **F3.** Keep CLI `--limit` as **hard cap** (back-compat). Add opt-in `RecallOpts.scorerWindow?: number` for callers who want >limit results from the scorer. Apply hardLimit AT BOTH `api.recall` AND MCP final-format step (codex C2).
- **F4.** `localeCompare` → byte cmp on assemble ISO sort. Add invariant: all timestamps are `toISOString()` UTC. Cherry-picked from deferred plan.
- **F5.** Tenant-wide fresh-tail policy: replace library `console.warn` with `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` env-gated typed `RecallContractError` thrown at `api.recall` boundary ONLY (NOT `api.assemble`, which is already session-scoped). HTTP/MCP/CLI render the typed error.

**Dropped from mk1 (do NOT execute):**
- ~~F1.4 backfill JS BM25 consumers~~ — codex C4: SQLite FTS5 BM25 and JS BM25 are different scorers (different tokenizers, different scales, different params). Defer to v1.7.x with explicit normalization design.
- ~~F2 unified `RankedMemory` shape~~ — codex C14 strategic: overengineering for v1.7. Defer to v1.8 alongside a public `recallRanked()` API design. Mk1's F2 also had a marker-placement bug (markers belong on envelope, not entry) and an undocumented public-shape contradiction.

**Tech Stack:** TypeScript, Node 24, `node:sqlite`, vitest (real DB per project rule). No new deps. No schema migration.

**Out of scope (v1.7):**
- F1.4 (JS BM25 unification) — defer.
- F2 (RankedMemory shape) — defer to v1.8.
- Track 1 CLI flag wiring, Track 3 summary re-rank, Track 4.3 audit handle reuse — all from deferred-queue plan; revisit after foundations land.
- B3 dlPFC follow-ups, soak harness, A4 RTBF, A7 observability, hybrid ON-by-default — v1.8+.

---

## Pre-flight

### Task 0: Worktree + baseline

**Step 1:** `git checkout -b feat/v1.7-foundations` off `v1.6.4` (or off `v1.6.5` if F4+F5 ship first via the cherry-pick plan; in that case adapt accordingly).
**Step 2:** `npm test 2>&1 | tail -5` → expect baseline test count from current `main`.
**Step 3:** `npm run build && node dist/cli.js --version`.
**Step 4:** Capture p99 baseline: `node benchmarks/a1/p99-recall.ts > benchmarks/a1/baselines/v1.6.4-baseline.txt` and commit.

---

## F1 — BM25 provenance on `MemoryEntry` (FTS path only)

**Why:** `loadSearchEntries` (`src/store.ts:1389`) returns `MemoryEntry[]` ordered by FTS rank but the rank is discarded. `loadSearchRows` has FOUR paths (`src/store.ts:579-639`):
1. **No-terms path** (line 595): empty query → no FTS, no score available.
2. **FTS path** (line 605-612): `bm25(memories_fts)` ordering — score available here.
3. **LIKE fallback** (line 627-633): no FTS, no score.
4. **Full-store fallback** (line 637): no FTS, no score.

F1 plumbs `bm25_score` ONLY on path 2. Paths 1/3/4 return entries with `bm25_score === undefined`. Documenting this is the contract.

**Codex C3 / C5 / C11 incorporated:** undefined on non-FTS paths (NOT 0, NOT NaN); FTS SQL must qualify `m.id AS id, m.content AS content, ...` so the test cannot silently fall back to LIKE; default `bm25()` parameters are fine.

### Task F1.1: Failing test — FTS-path entries have `bm25_score`

**Files:** `tests/store-bm25-score.test.ts` (create).

```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initStore, loadSearchEntries, upsertMemory } from '../src/store.js';
// (use whatever the project's existing helpers are — check tests/dag-recall.test.ts for pattern)

describe('loadSearchEntries bm25_score (F1)', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'hippo-f1-')); initStore(root); });
  afterEach(() => rmSync(root, { recursive: true, force: true }));

  it('FTS path: every returned entry has finite numeric bm25_score, sorted ascending (best first)', () => {
    // insert 3 raws under tenant 'default'
    //   A: "alpha beta gamma"
    //   B: "alpha"
    //   C: "delta epsilon"
    const results = loadSearchEntries(root, 'alpha beta', 200, 'default');
    expect(results.length).toBeGreaterThan(0);
    for (const e of results) {
      expect(typeof e.bm25_score).toBe('number');
      expect(Number.isFinite(e.bm25_score!)).toBe(true);
    }
    // FTS5 bm25() returns negative scores; lower (more negative) = better.
    // The two-term match (A) must score lower (better) than the one-term match (B).
    const a = results.find(r => r.content.includes('gamma'))!;
    const b = results.find(r => r.content === 'alpha')!;
    expect(a.bm25_score!).toBeLessThan(b.bm25_score!);
    // Sorted ascending.
    for (let i = 1; i < results.length; i++) {
      expect(results[i - 1].bm25_score!).toBeLessThanOrEqual(results[i].bm25_score!);
    }
  });

  it('No-terms path: bm25_score is undefined', () => {
    // insert 1 raw, then call loadSearchEntries with empty query
    const results = loadSearchEntries(root, '', 200, 'default');
    expect(results.length).toBeGreaterThan(0);
    for (const e of results) {
      expect(e.bm25_score).toBeUndefined();
    }
  });

  it('LIKE fallback: bm25_score is undefined', () => {
    // Force the LIKE fallback by inserting then disabling FTS.
    // EITHER: insert a raw, manually set meta('fts5_available','0') in the test DB,
    // OR: tokenize a query that produces FTS results = 0 so it falls through to LIKE.
    // Choose whichever the existing test suite has a helper for; assert undefined.
    // (Implementation detail; this test SHOULD exist before F1.3 ships.)
  });
});
```

Run: `npx vitest run tests/store-bm25-score.test.ts` → all three FAIL initially. Commit failing tests.

### Task F1.2: Add `bm25_score?: number` to `MemoryEntry`

**Files:** `src/memory.ts`.

```typescript
export interface MemoryEntry {
  // ... existing fields ...
  /**
   * Raw SQLite FTS5 bm25() score from the FTS path of `loadSearchEntries`.
   *
   * Populated ONLY when:
   *   - `loadSearchEntries` was called with a non-empty query, AND
   *   - FTS5 is available (meta `fts5_available = 1`), AND
   *   - the FTS join returned at least one row (path 2 of `loadSearchRows`).
   *
   * `undefined` in all other cases: empty query, FTS unavailable, LIKE fallback,
   * full-store fallback, `readEntry`, `loadAllEntries`, manual upsert.
   *
   * SCALE: FTS5 bm25() is negative; lower = better match (ascending order).
   * NOT a drop-in for the JS-side BM25 in `src/search.ts` — that is a different
   * scorer (different tokenizer, different params, positive scale). Treat
   * `bm25_score` as provenance/rank metadata only.
   */
  bm25_score?: number;
}
```

### Task F1.3: Plumb `bm25(memories_fts)` through `loadSearchRows` FTS path

**Files:** `src/store.ts:189`, `src/store.ts:579-639`, `rowToEntry` mapping.

**Step 1:** Add a parallel `MEMORY_SEARCH_COLUMNS` for the FTS path that qualifies columns from the `m` alias and adds the bm25 expression:

```typescript
const MEMORY_SEARCH_COLUMNS = `
  m.id AS id,
  m.created AS created,
  m.last_retrieved AS last_retrieved,
  m.retrieval_count AS retrieval_count,
  m.strength AS strength,
  m.half_life_days AS half_life_days,
  m.layer AS layer,
  m.tags_json AS tags_json,
  m.emotional_valence AS emotional_valence,
  m.schema_fit AS schema_fit,
  m.source AS source,
  m.outcome_score AS outcome_score,
  m.outcome_positive AS outcome_positive,
  m.outcome_negative AS outcome_negative,
  m.conflicts_with_json AS conflicts_with_json,
  m.pinned AS pinned,
  m.confidence AS confidence,
  m.content AS content,
  m.parents_json AS parents_json,
  m.starred AS starred,
  m.trace_outcome AS trace_outcome,
  m.source_session_id AS source_session_id,
  m.valid_from AS valid_from,
  m.superseded_by AS superseded_by,
  m.extracted_from AS extracted_from,
  m.dag_level AS dag_level,
  m.dag_parent_id AS dag_parent_id,
  m.kind AS kind,
  m.scope AS scope,
  m.owner AS owner,
  m.artifact_ref AS artifact_ref,
  m.tenant_id AS tenant_id,
  m.descendant_count AS descendant_count,
  m.earliest_at AS earliest_at,
  m.latest_at AS latest_at,
  bm25(memories_fts) AS bm25_score
`;
```

DO NOT touch `MEMORY_SELECT_COLUMNS` itself. Non-FTS callers must not pay for `bm25()` evaluation or get a column that fails to bind.

**Step 2:** In `loadSearchRows`, change ONLY the FTS path's `SELECT ${MEMORY_SELECT_COLUMNS}` to `SELECT ${MEMORY_SEARCH_COLUMNS}`. LIKE and full-fallback paths keep `MEMORY_SELECT_COLUMNS`.

**Step 3:** Extend `rowToEntry` to map `row.bm25_score` (when present) to `entry.bm25_score`. When the column is absent (rows from `MEMORY_SELECT_COLUMNS`-only paths), set `entry.bm25_score = undefined`. Use `'bm25_score' in row ? row.bm25_score : undefined` to distinguish absence from a real `0` (defensive — FTS5 won't return 0, but be explicit).

**Step 4:** Run F1.1 tests → all three should PASS.

**Step 5:** Run full suite → expect existing test count + 3, no other failures.

**Step 6:** Commit. One commit, message: `feat(store): plumb FTS5 bm25() as MemoryEntry.bm25_score (FTS path only, undefined otherwise)`.

### Task F1.4 (REMOVED in mk2)

The original F1.4 ("backfill JS BM25 consumers in `src/search.ts`, `src/multihop.ts`, `src/physics.ts` to use `entry.bm25_score`") is **dropped**. Codex C4: SQLite FTS5 and the JS scorer are different systems with different tokenizers, scales, and parameters. Replacing them blindly is a scoring regression risk. `bm25_score` ships in v1.7.0 as **provenance-only metadata**. Re-rank work that consumes it (deferred-queue Track 3) lands in a later release with explicit normalization design.

### Task F1.5: Codex round on F1

```
/codex review --base v1.6.4 src/store.ts src/memory.ts tests/store-bm25-score.test.ts
```

Address P0/P1. Re-run codex on the fix.

---

## F3 — `limit` semantics (back-compat preserving)

**Why:** Codex C1 / C2. `--limit` is a hard cap today (`src/cli.ts:1199-1200`). Mk1 wanted to flip it to "scorer window," which would have broken every existing CLI caller silently. Also: MCP at `src/mcp/server.ts:419-466` calls `api.recall` only for fresh-tail/summaries and runs its own `physicsSearch`/`hybridSearch` for the visible block — `hardLimit` on `api.recall` does not cap MCP's primary results unless MCP applies it after final formatting. Mk1 missed this entirely.

**Decision:**
- **CLI `--limit` stays a HARD CAP** (back-compat). No semantic change for any existing caller.
- **Add `RecallOpts.scorerWindow?: number`** = the number of candidates the scorer should consider before fresh-tail/summary post-processing. Defaults to `limit` (no behavior change).
- **`api.recall` may return up to `scorerWindow + freshTailCount + summaryCount` items.** A new `RecallResult.windowSize` field documents the actual scorer window used, for callers who want to inspect.
- **CLI's existing slice at `cli.ts:1199` becomes the canonical hard-cap enforcement** for CLI users. No change there.
- **MCP fix:** in `src/mcp/server.ts:419-466`, after the formatted-results assembly, apply a `Math.min(limit, results.length)` slice to the FINAL formatted block before returning. Add a regression test asserting MCP returns `<= limit` items even when its physics path overshoots.

### Task F3.1: Failing test — MCP cap regression

**Files:** `tests/mcp-recall-limit-cap.test.ts` (create).

```typescript
it('MCP hippo_recall returns <= limit items even when physics scorer overshoots', async () => {
  // insert 30 raws under one tenant
  // call hippo_recall via MCP with limit=5
  // assert response items <= 5
});
```

Confirm it FAILS (or document if it currently passes by accident — in which case the regression test still belongs in the codebase to prevent future drift). Commit.

### Task F3.2: Add `RecallOpts.scorerWindow`

**Files:** `src/api.ts:~145` (`RecallOpts` interface).

```typescript
export interface RecallOpts {
  query: string;
  limit?: number;
  /**
   * Number of candidates the scorer should evaluate before fresh-tail
   * prepend and summary substitution. Defaults to `limit`. When set
   * higher than `limit`, callers receive up to `scorerWindow +
   * freshTailCount + summaryCount` items and must do their own final cap.
   * The CLI's `--limit` continues to be a hard cap; this opt is for
   * library callers that want a wider scorer window without truncating
   * fresh-tail or summary post-processing.
   */
  scorerWindow?: number;
  // ... existing fields ...
}
```

### Task F3.3: `api.recall` honors `scorerWindow`

**Files:** `src/api.ts:255` (`recall`).

`const window = opts.scorerWindow ?? opts.limit ?? 10;` — use `window` for the loadSearchEntries / scorer pass; keep `limit` as the slice point on the BM25-ranked candidates before fresh-tail/summary append. Default behavior unchanged when `scorerWindow` is undefined.

Add `windowSize` to `RecallResult`:

```typescript
export interface RecallResult {
  results: RecallResultItem[];
  total: number;
  tokens: number;
  /** The scorer window actually used (== opts.scorerWindow ?? opts.limit ?? 10). */
  windowSize: number;
  // ... existing fields ...
}
```

### Task F3.4: MCP final-cap fix

**Files:** `src/mcp/server.ts:419-466` (the `hippo_recall` handler).

After the formatted block is assembled (combining physics/hybrid primary + api.recall fresh-tail/summary), apply:

```typescript
const finalLimit = typeof args.limit === 'number' ? Math.max(1, Math.floor(args.limit)) : 10;
const cappedItems = formattedItems.slice(0, finalLimit);
```

Add JSDoc explaining: "MCP `limit` is a hard cap on the final formatted result, mirroring CLI semantics. The scorer may consider more candidates internally."

Run F3.1 → PASS.

### Task F3.5: Documentation + Codex round

Update `MEMORY_ENVELOPE.md` and `RecallOpts` JSDoc with the `--limit` hard-cap contract, the `scorerWindow` opt, and the MCP final-cap rule.

```
/codex review --base v1.6.4 src/api.ts src/mcp/server.ts tests/mcp-recall-limit-cap.test.ts
```

Address P0/P1.

---

## F4 — `localeCompare` → byte cmp on assemble (cherry-pick)

**Why:** `localeCompare` is ~50× slower than `<`/`>` on canonical UTC ISO timestamps and has no semantic benefit. Hot path on `assemble`.

**Codex C12 incorporated:** byte compare is correct ONLY for canonical `toISOString()` UTC. Add an invariant.

### Task F4.1: Add invariant statement

**Files:** `src/memory.ts` (above `MemoryEntry`), `MEMORY_ENVELOPE.md`.

> **Timestamp invariant.** All timestamp fields on `MemoryEntry` (`created`, `last_retrieved`, `valid_from`, `earliest_at`, `latest_at`) and on `SessionEvent`, `TaskSnapshot`, etc. are stored as canonical `Date.prototype.toISOString()` output: 24 characters, UTC, milliseconds precision, trailing `Z`. Imports preserving local-time offsets must normalize on write. Byte-comparison sort (`<` / `>`) is chronological by virtue of this invariant.

### Task F4.2: Audit existing write sites

`grep -rn "toISOString\|new Date(.*).toISOString()" src/` — confirm every timestamp write goes through `toISOString()`. If any write site uses a non-canonical format, fix it before F4.3.

### Task F4.3: Replace `localeCompare` with byte cmp

**Files:** `src/api.ts:675-676`.

```typescript
const cmpIso = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
olderItems.sort((x, y) => cmpIso(x.createdAt, y.createdAt));
tailItems.sort((x, y) => cmpIso(x.createdAt, y.createdAt));
```

Run `npx vitest run tests/dag-assemble` → all pass.

### Task F4.4: Commit + Codex round

```
/codex review --base v1.6.4 src/api.ts MEMORY_ENVELOPE.md
```

---

## F5 — Tenant-wide fresh-tail policy (recall-only, typed error)

**Why:** Codex C9 / C10. `loadFreshRawMemories` without `sessionId` returns tenant-wide rows. The mk1 plan put a library-level `console.warn` (bad — pollutes every API/HTTP/MCP caller's stderr) AND tried to guard it inside `api.assemble` (no-op — `assemble` is already session-scoped via `loadSessionRawMemories` and never calls tenant-wide `loadFreshRawMemories`). Mk2: enforce only in `api.recall`, throw a typed error.

### Task F5.1: Define `RecallContractError`

**Files:** `src/api.ts` (or a new `src/errors.ts` if there's no other home — check `grep -rn "class.*Error" src/`).

```typescript
export class RecallContractError extends Error {
  constructor(
    public readonly code: 'fresh_tail_requires_session_id',
    message: string,
  ) {
    super(message);
    this.name = 'RecallContractError';
  }
}
```

### Task F5.2: API-boundary check in `api.recall`

**Files:** `src/api.ts:~358` (where `freshTailCount > 0` branch starts).

```typescript
if (freshTailCount > 0) {
  if (!opts.freshTailSessionId
      && process.env.HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL === '1') {
    throw new RecallContractError(
      'fresh_tail_requires_session_id',
      'fresh-tail requires a session id when HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1; '
      + 'pass opts.freshTailSessionId or unset the env to allow tenant-wide fresh-tail.',
    );
  }
  // ... existing tenant-wide path remains the back-compat default ...
}
```

NO check in `api.assemble`. Codex C9: assemble is already session-scoped.

NO `console.warn` in `loadFreshRawMemories`. Codex C9: library-level warns are bad behavior.

### Task F5.3: HTTP / MCP / CLI rendering of `RecallContractError`

**Files:** `src/server.ts:167` (existing error mapping path), `src/mcp/server.ts` (recall handler), `src/cli.ts:cmdRecall`.

- HTTP: map `RecallContractError` to HTTP 400 with `{ error: err.code, message: err.message }`.
- MCP: surface as a typed error result in the MCP response (follow the existing pattern for other typed errors in that file).
- CLI: catch, write to stderr with `Error: ${err.message}`, exit 1.

### Task F5.4: Tests

**Files:** `tests/recall-fresh-tail-policy.test.ts` (create).

- env unset, `freshTailCount=5`, `freshTailSessionId` unset → tenant-wide rows returned (back-compat preserved).
- env=`1`, same → throws `RecallContractError` with `code: 'fresh_tail_requires_session_id'`.
- env=`1`, `freshTailSessionId='sess-A'` → no throw, session-scoped rows returned.
- env=`1`, `freshTailCount=0` → no throw (guard only fires when fresh-tail is requested).

HTTP / MCP / CLI render-path tests added in their respective test files.

### Task F5.5: JSDoc deprecation note on `loadFreshRawMemories`

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

```typescript
/**
 * @deprecated Calling without `sessionId` returns tenant-wide rows. This
 * is rarely the right shape for "what did I just see in this conversation".
 * API boundary helpers (`api.recall`) enforce session scoping when
 * `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set. Tenant-wide remains
 * the back-compat default. Direct callers of this helper bypass that
 * enforcement; pass `sessionId` to be explicit.
 */
```

NO runtime warn. Codex C9.

### Task F5.6: Codex round

```
/codex review --base v1.6.4 src/api.ts src/store.ts src/server.ts src/mcp/server.ts src/cli.ts tests/recall-fresh-tail-policy.test.ts
```

---

## Pre-publish review chain (MANDATORY)

The deferred-queue plan and mk1 of this plan were both killed by review chains. Run all four for v1.7.0:

1. `/self-review` — read every diff hunk yourself.
2. `/codex review --base v1.6.4` with `model=gpt-5.5 reasoning_effort=high`. Fall back to gpt-5.4 if 5.5 unavailable, say so plainly.
3. `/review` — senior in-house adversarial.
4. `/ship-check` — pre-push sanity.

Address all P0 + load-bearing P1. Defer only with TODOS.md entry + one-line justification.

---

## Bump + ship

```bash
npm version 1.7.0
git push origin feat/v1.7-foundations --tags
```

Open PR. After merge: `npm publish`. CHANGELOG entry:

```markdown
## 1.7.0 (TBD)

Foundation release. Plumbs FTS5 bm25() as `MemoryEntry.bm25_score` provenance metadata
(FTS path only), introduces `RecallOpts.scorerWindow` for callers who want a wider
scorer window without breaking the `--limit` hard cap, fixes MCP recall to honor its
declared limit on the final formatted result, switches assemble's ISO sort from
`localeCompare` to byte compare (~50× faster), and replaces a planned library-level
`console.warn` with a typed `RecallContractError` thrown at `api.recall` boundary
when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1`.

### Added
- `MemoryEntry.bm25_score?: number` — raw FTS5 bm25() score, populated only on the
  FTS path of `loadSearchEntries`. Provenance metadata, NOT a drop-in scorer input.
- `RecallOpts.scorerWindow?: number` — opt-in wider scorer window. CLI `--limit`
  remains a hard cap (no semantic change).
- `RecallResult.windowSize: number` — actual scorer window used.
- `RecallContractError` exported class with `.code` field. Thrown by `api.recall`
  when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` and `freshTailSessionId` is unset.

### Fixed
- MCP `hippo_recall` now applies `limit` to the final formatted result. Previously
  the physics path could overshoot the declared limit.
- `localeCompare` on canonical UTC ISO sort (~50× perf, no semantic change). Documents
  the timestamp invariant in MEMORY_ENVELOPE.md.

### Behaviour changes (potentially breaking)
- When `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set, `api.recall` with
  `freshTailCount > 0` and no `freshTailSessionId` now throws `RecallContractError`
  instead of returning tenant-wide rows. Default is unset (back-compat preserved).

### Deferred
- F1.4 JS BM25 consumer unification — different scorer, different scale; needs
  explicit normalization design. Tracked.
- F2 unified `RankedMemory` shape — defer to v1.8 alongside `recallRanked()` public
  API design.
- All deferred-queue items (Track 1 CLI parity, Track 3 mean-of-children re-rank,
  Track 2 HTTP slashes, Track 4.3 audit handle reuse) — revisit after foundations.
```

---

## Success criteria

1. `loadSearchEntries` returns entries with finite numeric `bm25_score` on FTS path; `undefined` on all other paths. All four paths covered by tests.
2. FTS SQL uses qualified columns (`m.id AS id, ...`); test validates the column actually populates.
3. `api.recall` accepts `scorerWindow`; CLI `--limit` semantics unchanged; MCP final-cap regression test green.
4. `assemble` ISO sort uses byte compare; timestamp invariant documented.
5. `RecallContractError` thrown from `api.recall` only (not `assemble`); HTTP/MCP/CLI render paths tested; no library `console.warn` introduced.
6. Test count grows by ~12-15, all passing on Windows + Linux CI.
7. p99 recall does not regress vs `v1.6.4-baseline.txt` (or whatever the pre-foundations baseline is).
8. Full review chain ran, all P0/P1 addressed or explicitly deferred.

---

## Reopen path for the deferred queue (post-v1.7.0)

After v1.7.0 (foundations) ships:
- **Old Track 1:** rewrite as "wire CLI/MCP/HTTP `--fresh-tail / --summarize-overflow / --scorer-window` flags" with the new `RecallOpts.scorerWindow` foundation. Sized as ~1 day.
- **Old Track 2:** drop. Document `ID_SEGMENT_RE` charset in OpenAPI + README.
- **Old Track 3:** wire summary scoring using `bm25_score` provenance + a new normalization helper. Sized as ~half day after F1 lands.
- **Old Track 4.3:** separate perf branch with `WithDb` helper variants.
- **F1.4 + F2:** v1.8 with `recallRanked()` public API design.

Target sequence: v1.7.0 (foundations) → v1.7.1 (Track 1 wiring) → v1.7.2 (Track 3 re-rank) → v1.8 (F2 + F1.4).
