# v1.6.5 Cherry-Pick Plan — F4 + F5 standalone

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Run the full review chain (`/codex review --base v1.6.4` + `/review` + `/self-review` + `/ship-check`) before publish.

**Status:** Draft 2026-05-06.

**Goal:** Ship F4 (`localeCompare` → byte cmp on assemble ISO sort) and F5 (tenant-wide fresh-tail policy with typed error, no library `console.warn`) as a standalone v1.6.5 patch release. Both are independent of the larger foundations work (F1 BM25 plumbing, F3 limit semantics) and have zero contract risk; shipping them now banks two clean wins while the foundations plan goes through another `/codex` round.

**Why split:** Codex C13 / eng-review E8 — F4 and F5 have no dependency on F1 or F3. Shipping them coupled to foundations adds 3-5 days of latency for ~30 minutes of code. Cherry-pick as v1.6.5; foundations land later as v1.7.0.

**Architecture:** Two scoped, additive changes. No breaking behavior under default env. Documented invariant for the F4 sort, opt-in env gate for F5.

**Tech Stack:** TypeScript, Node 24, `node:sqlite`, vitest (real DB).

---

## Pre-flight

### Task 0: Worktree + baseline

**Step 1:** `git checkout -b feat/v1.6.5-cherry-pick` off `v1.6.4`.
**Step 2:** `npm test 2>&1 | tail -5` → expect `1337 passed`.
**Step 3:** `npm run build && node dist/cli.js --version` → expect `1.6.4`.

---

## F4 — `localeCompare` → byte cmp on assemble ISO sort

**Why:** `src/api.ts:675-676` sorts `olderItems` and `tailItems` by `createdAt.localeCompare`. ISO 8601 timestamps in canonical `toISOString()` UTC form are lexicographically sortable; `localeCompare` does locale-aware Unicode collation (~50× slower) with no semantic benefit. Hot path on `assemble`.

**Codex C12 incorporated:** byte compare correct ONLY for canonical `toISOString()` UTC; document the invariant or normalize on write.

### Task F4.1: Audit timestamp write sites

**Step 1:** `grep -rn "toISOString\|new Date(.*).toISOString()" src/ | head -50`

For each timestamp field on `MemoryEntry` (`created`, `last_retrieved`, `valid_from`, `earliest_at`, `latest_at`) and on `SessionEvent` / `TaskSnapshot` / etc., confirm every write goes through `toISOString()`. If any write site uses a non-canonical format (locale string, custom format, parsed-and-reserialized without UTC normalization), STOP and fix it before F4.3.

**Step 2:** If all writes are canonical, document the invariant.

### Task F4.2: Add timestamp invariant to docs

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

Add a note:

> **Timestamp invariant.** All timestamp fields on `MemoryEntry` (`created`, `last_retrieved`, `valid_from`, `earliest_at`, `latest_at`) and on `SessionEvent`, `TaskSnapshot`, `SessionHandoff`, 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.3: Failing test → byte cmp matches localeCompare

**Files:** `tests/api-assemble-iso-sort.test.ts` (create).

```typescript
import { describe, it, expect } from 'vitest';

describe('assemble ISO sort (F4)', () => {
  it('byte compare matches localeCompare for canonical UTC ISO timestamps', () => {
    const isoSamples = [
      '2026-05-06T00:00:00.000Z',
      '2026-05-06T00:00:00.001Z',
      '2026-05-06T00:00:01.000Z',
      '2026-05-06T00:01:00.000Z',
      '2026-05-06T01:00:00.000Z',
      '2026-05-07T00:00:00.000Z',
      '2026-06-06T00:00:00.000Z',
      '2027-05-06T00:00:00.000Z',
      '1970-01-01T00:00:00.000Z',
    ];
    const cmpIso = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
    const byteSorted = [...isoSamples].sort(cmpIso);
    const localeSorted = [...isoSamples].sort((a, b) => a.localeCompare(b));
    expect(byteSorted).toEqual(localeSorted);
  });

  it('assemble returns chronologically ordered items after byte-cmp sort', () => {
    // Build an assemble fixture with 5 raws in shuffled insert order;
    // call api.assemble; assert items are sorted by createdAt ascending.
    // Use the existing tests/dag-assemble*.test.ts pattern as template.
  });
});
```

Run: `npx vitest run tests/api-assemble-iso-sort.test.ts` → first test PASSES (proves the equivalence holds), second test PASSES against current `localeCompare` impl. Commit.

### Task F4.4: 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 tests/api-assemble-iso-sort` → all pass.
Run full suite → expect `1339 passed` (+2).

Commit. Message: `perf(assemble): byte compare canonical UTC ISO timestamps instead of localeCompare`.

---

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

**Why:** `loadFreshRawMemories` without `sessionId` returns tenant-wide rows. Surfaces newest rows across ALL sessions of the tenant — wrong shape for "what did I just see in this conversation". Mk1 of foundations plan tried a library-level `console.warn` (codex C9: bad — pollutes every API/HTTP/MCP caller's stderr) AND an `api.assemble` guard (codex C9: no-op — assemble doesn't call this helper). Mk2 / this cherry-pick: enforce only at `api.recall` boundary, throw a typed error, gated by env.

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

**Files:** `src/api.ts` (or new `src/errors.ts` if conventions there).

```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: Failing tests for the four cases

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

```typescript
describe('recall fresh-tail policy (F5)', () => {
  it('env unset, freshTailCount > 0, no freshTailSessionId → tenant-wide rows (back-compat)', () => {
    // assert no throw, tenant-wide rows returned with isFreshTail=true
  });
  it('env=1, freshTailCount > 0, no freshTailSessionId → throws RecallContractError', () => {
    // process.env.HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL = '1'
    // expect(() => recall(ctx, opts)).toThrow(RecallContractError)
    // expect error.code === 'fresh_tail_requires_session_id'
  });
  it('env=1, freshTailSessionId set → no throw, session-scoped rows', () => { ... });
  it('env=1, freshTailCount=0 → no throw (guard only fires when fresh-tail requested)', () => { ... });
});
```

All four FAIL initially. Commit.

### Task F5.3: Implement guard 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.
NO `console.warn` in `loadFreshRawMemories`. Codex C9.

Run F5.2 tests → all four PASS.

### Task F5.4: Render path — HTTP / MCP / CLI

**Files:** `src/server.ts:~167` (existing error-mapping block), `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 typed errors (`grep -n "isError: true" src/mcp/server.ts | head`).
- **CLI:** wrap `cmdRecall` body in try/catch; on `RecallContractError`, write `Error: ${err.message}` to stderr, `process.exit(1)`.

Add render-path tests:

- `tests/http-fresh-tail-policy.test.ts`: env=1, POST /v1/recall without `fresh_tail_session_id` → 400 with `error: 'fresh_tail_requires_session_id'`.
- `tests/mcp-fresh-tail-policy.test.ts`: env=1, MCP `hippo_recall` without `fresh_tail_session_id` → typed error response.
- `tests/cli-fresh-tail-policy.test.ts`: env=1, `hippo recall ... --fresh-tail 5` (no `--fresh-tail-session`, no `HIPPO_SESSION_ID`) → exit 1, stderr contains the error message.

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

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

```typescript
/**
 * Load the most recent N kind='raw' rows for `tenantId`.
 *
 * @deprecated Calling without `sessionId` returns tenant-wide rows. This is
 * rarely the right shape for "what did I just see in this conversation".
 * `api.recall` enforces 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.

---

## Pre-publish review chain

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.

---

## Bump + ship

```bash
npm version 1.6.5
git push origin feat/v1.6.5-cherry-pick --tags
```

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

```markdown
## 1.6.5 (TBD)

Two cherry-picked items from the in-progress v1.7 foundations work that have zero
contract risk and ship cleanly on their own.

### Added
- **`RecallContractError` exported class with `.code` field.** Thrown by `api.recall`
  when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set AND `freshTailCount > 0`
  AND `freshTailSessionId` is unset. HTTP returns 400; MCP returns a typed error
  result; CLI exits 1 with the message on stderr. Default env unset preserves
  tenant-wide fresh-tail back-compat.
- **Timestamp invariant documented** in `MEMORY_ENVELOPE.md` and `src/memory.ts`:
  all `MemoryEntry` and session timestamps are canonical `toISOString()` UTC.

### Changed
- **`assemble` ISO sort uses byte compare instead of `localeCompare`.** ~50×
  faster on canonical UTC ISO; no semantic change given the timestamp invariant.

### Deferred to v1.7.0 foundations release
- BM25 provenance plumbing (`MemoryEntry.bm25_score`).
- `RecallOpts.scorerWindow` opt-in wider scorer window.
- MCP final-cap fix on `hippo_recall`.
```

---

## Success criteria

1. `assemble` ISO sort uses byte cmp; equivalence test green; timestamp invariant documented.
2. `api.recall` throws `RecallContractError` when env=1 and fresh-tail-without-session is requested; default behavior unchanged when env unset.
3. HTTP / MCP / CLI all render the typed error correctly.
4. `loadFreshRawMemories` JSDoc-deprecated for tenant-wide use; NO `console.warn` introduced.
5. Test count grows by ~7-9 (F4: 2 tests, F5: 4 unit + 3 render paths), all passing.
6. Full review chain ran, all P0/P1 addressed.
7. v1.6.5 published to npm.
