Skip to content

Scoped docs search starvation — root cause, first fix, and verifier (2026-03-21)

Status: implemented and live-verified

Why this note exists

docsColdLane scoped retrieval was behaving inconsistently: - docs ingest had already succeeded - SQLite rows for steamer-card-engine docs existed - scopeMap.steamer already included steamer-card-engine - matchesScope() correctly accepted repo === prefix - yet some memory_docs_search(..., scope="steamer") queries still missed card-engine docs

This note records the real root cause, the honest first fix, and the live verifier receipt.

Root cause

The miss was not a scope-map failure.

The miss was a retrieval-pipeline bug in docsColdLane.js: 1. call openclaw-mem docs search with --limit = boundedLimit 2. receive a small global top-N candidate set 3. apply matchesScope() afterward in the plugin 4. slice again to the requested limit

That means scope filtering happened after candidate truncation. If scoped docs did not make the global top-N, they were already gone before the scope filter ran.

Verdict: - this was filter-after-limit starvation - symptom class: false scoped 0-hit / query-sensitive scoped miss - real boundary: scope existed in policy, but not strongly enough in candidate selection

First honest fix (Slice 1)

Implemented in openclaw-mem commit: - ef614f4mem-engine: overfetch scoped docs candidates

Behavior change: - when a strict docs scope is present, docsSearchWithCli() now over-fetches a bounded candidate pool before applying scope filtering - the widened pool is applied consistently to: - --limit - --fts-k - --vec-k

This keeps the fix bounded: - no schema migration - no CLI contract change yet - no scope semantics moved into the search backend yet - rollback is a simple revert of one repo commit

Observability added

The fix also added bounded counters so scoped misses stop looking like black magic: - rawCandidates - scopedCandidates - existing filteredByScope

Interpretation: - rawCandidates == 0 → query/index issue - rawCandidates > 0 && scopedCandidates == 0 → scope starvation or wrong scope mapping - filteredByScope high → global candidates still dominated by out-of-scope rows

Verifier receipts

Function-level verifier

Direct function-level proof against live SQLite after the repo patch showed scoped searches widening the candidate pool: - rawCandidates=25 - scopedCandidates=15/19/23 - returned hits included steamer-card-engine

Live runtime verifier

A no-op gateway reload was executed via config.patch using a fresh config hash. Expected receipt was observed: - restart.ok=true - signal=SIGUSR1 - reason=config.patch

After reload, active runtime verification passed: - memory_docs_search(query="This repo now owns the Steamer card-engine product surfaces", scope="steamer") - returned hits from: - steamer-card-engine/docs/PLAYBOOK_OWNERSHIP_ABSORPTION_V1.md

Interpretation: - the fix was not only repo-local - the active OpenClaw runtime successfully served scoped card-engine docs after reload

Tradeoff

This first fix is correct enough and low-risk, but it is still a compensating layer. The plugin is still over-fetching globally, then filtering by scope.

That means: - quality is much better - scope behavior is more stable - but the clean architectural boundary still has not moved into the CLI/search engine itself

Next honest step

The proper next slice is scope pushdown: - resolve scope to a repo allowlist in the plugin - pass that allowlist into openclaw-mem docs search - filter at SQL/query time for FTS/vector candidate generation - keep plugin-side matchesScope() as defense-in-depth until the pushed-down path is proven

See: - docs/specs/docs-cold-lane-scope-pushdown-v1.md

Topology / WAL note

  • Retrieval behavior changed: yes
  • Runtime/system shape changed: no
  • This was a retrieval-contract hardening pass, not a topology/system-shape change.