# Search Cache Warm Path

_Status: shipped_
_Started: 2026-06-04 - Decided: 2026-06-04_

## Hypothesis

`search_notes` already benefits from the mtime content cache on repeat queries, but
the warm path still scans every note body and redoes per-query string work. A small
search-side cache or normalization pass should cut the 1,000-note warm-search time
by at least 20% without changing result order, paths, snippets, or security checks.

## Fixture

Bench command:

```powershell
npm run build
node scripts\bench.mjs 100,1000 --json
```

The bench harness creates temporary vaults with 100 and 1,000 markdown notes. Each
note has a heading, one wikilink, one inline tag, and a line containing the search
terms `fox` and `quick`. It then calls `search_notes` twice through a real stdio
MCP client: first query `fox` for cold timing, then query `quick` for warm timing.

Baseline captured on 2026-06-04:

| notes | cold search | warm search |
|---:|---:|---:|
| 100 | 21.0ms | 13.9ms |
| 1,000 | 113.0ms | 91.5ms |

## Metric

Primary metric: 1,000-note warm `search_notes` wall time.

Ship bar: warm time at or below 73ms (20% faster than the 91.5ms baseline) with
1000-note cold time no worse than 125ms, and no changed search result text.

| | before | after |
|---|---:|---:|
| 1,000-note warm search | 91.5ms | 23.2ms |
| 1,000-note cold search guardrail | 113.0ms | 59.2ms |

## Safety review

Data accessed: synthetic temporary markdown vaults generated by `scripts/bench.mjs`.
No real vault content, secrets, network calls, or embedding providers are involved.

Writes performed: temporary fixture vault creation and deletion only. A future
prototype may add in-memory search metadata; any persistent cache change must keep
vault-boundary checks and cache invalidation behavior unchanged.

Logs emitted: normal stdio server startup logs with temporary vault paths. No note
body content is logged.

Rollback path: drop the prototype and leave this experiment as stopped if the metric
does not clear the bar.

## Kill criterion

Stop if the warm path improves by less than 20%, if 1,000-note cold search exceeds
125ms, if result ordering/snippets change, or if the implementation needs a new
persistent index that is not justified by the measured gain.

## Decision

Ship. The prototype kept the existing content cache and search result shape, but
removed duplicated filesystem work from the warm path:

- `readAllCached` now resolves the real vault root once per batch and passes it
  into each per-note vault-boundary check, preserving the per-file symlink
  realpath validation while avoiding repeated root `realpath` calls.
- Broad vault walks now use `Dirent.isSymbolicLink()` from `readdir(...,
  { withFileTypes: true })` to skip symlink entries directly, avoiding one
  `lstat` per ordinary note while direct note reads still use
  `resolveVaultPathSafe`.
- `searchInContents` now returns immediately for an empty query, which is
  already rejected by the MCP tool but keeps the exported pure helper bounded.

Measured after the prototype with:

```powershell
npm run build
node scripts\bench.mjs 100,1000 --json
node scripts\bench.mjs 100,1000 --json
```

The two repeated 1,000-note warm runs were 23.2ms and 22.7ms, both below the
73ms ship bar. The slower repeated cold run was 59.2ms, below the 125ms
guardrail. `npm run perf:update` refreshed `tests/perf/baseline.json` on the
same machine.
