# Search-surface parity contract

Three operator/agent surfaces run the same `hybrid()` search engine
(`platform/lib/graph-search`). The contract: **the same query yields the same
node set on every surface** unless a surface declares an explicit, documented
divergence.

| Surface | Entry point | Labels default | Vector floor |
|---|---|---|---|
| Chat (agent recall) | `memory-search` MCP tool → `memorySearch()` | none set ⇒ all labels | `DEFAULT_VECTOR_THRESHOLD` (0.82) |
| `/graph` admin page | `GET /api/admin/graph-search` | no chips ⇒ all labels; chips narrow | `DEFAULT_VECTOR_THRESHOLD` (0.82), `?threshold` overrides |
| `/data` admin page | `GET /api/admin/graph-search?labels=FileArtifact` | `FileArtifact` only (files) | `DEFAULT_VECTOR_THRESHOLD` (0.82), `?threshold` overrides; show-all sends `?threshold=0` |

## The two parity rules (Task 635)

1. **All labels by default.** A caller that passes no labels (chat) or an empty
   chip set (`/graph`) searches every label — the route forwards `undefined` to
   `hybrid()`, the lib's no-label gate, which queries every vector index. The
   `*` wildcard is a separate explicit opt-in to the same all-labels path (no
   current caller sends it; `/data` sends a real `FileArtifact` label instead).
   `/graph` no longer rejects an empty chip set with a 400; chips, when present,
   narrow post-filter.

2. **One vector floor, defined once.** `DEFAULT_VECTOR_THRESHOLD = 0.82` lives
   in `platform/lib/graph-search/src/index.ts` and is the single source of
   truth. Each *surface* opts into it as its default; `hybrid()` itself keeps
   the `vectorThreshold === undefined ⇒ no floor` contract. Two literals of
   0.82 in two files would be a drift vector — there is one constant, imported
   by both the route (`src`) and `memory-search` (`dist`, so the lib must be
   rebuilt for the memory plugin to see a change).

## Declared divergences (allowed, by explicit caller or post-filter)

- **`/data` is files-only.** It sends `labels=FileArtifact` — a deliberate
  server-side caller filter to file nodes, not a parity break. (It also
  triggers the route's `FileArtifact` index-reconcile branch before searching.)
- **`graph-subgraph` pivot mask.** `GET /api/admin/graph-subgraph?q=…` runs
  `hybrid()` with `expandHops: 0` and **no** vector floor. Its output is not a
  result list — it is intersected with a clicked node's neighbourhood to
  narrow a pivot. "Same query, same results" does not apply to an intersection
  mask, so it intentionally stays unthresholded.
- **Post-search projections** — the chat public-twin property rewrite and the
  `/graph` Message→Conversation parent merge run after the engine returns and
  do not change which nodes the engine selected.

## Diagnostics

```
logs-read.sh --tail server 100 | grep -E '\[graph-search\]'
```

- `op=labels-default applied=all` — a no-chip `/graph` request took the
  all-labels path. Its absence on a no-labels request is the regression signal.
- `labels=all` (query line) — no chips (all labels); `labels=*` — explicit
  wildcard opt-in; `labels=FileArtifact` — `/data` files search;
  `labels=Person,…` — narrowed chip search.
- `threshold=0.82` on a default request; `threshold=off` only when an explicit
  `?threshold=0` was passed.
