Resolve multiple project and tag names in one call (#171) - #209
Conversation
Choosing a destination during inbox processing cost one scalar catalog search per candidate name; one observed inbox-zero run spent 46 project searches and 22 tag searches. list_projects and list_tags accept an optional searches array of 1-20 names plus matchLimitPerSearch (1-25, default 10). Results are grouped by the requested name in request order; a name matching nothing returns an empty group rather than being omitted, and a name matching more than the limit is marked truncated rather than silently narrowed. Matching is literal and case-insensitive, mirroring the scalar bridge search. searches is mutually exclusive with scalar search, page.cursor, and includeTaskCounts. The last was settled on the issue: batch resolution stays a destination-selection primitive, its measured cost was roughly +1.3s and double the bytes, and project health belongs to #87. Implementation fills the catalog once per (statusFilter, fields) and matches locally, so N names cost one Bridge round trip on a cold cache and none while the entry is warm. CatalogCache gains fill coalescing so concurrent callers on a cold cache share one fetch instead of stampeding the Bridge lane, and invalidation cancels in-flight fills that would carry pre-mutation data. No plug-in change is required. Measured on a 392-project database, interleaved A/B, 6 reps per arm: 10 scalar searches 2374ms vs one batch call 429ms, a 81.9% reduction at 86.7 standard errors, with Bridge round trips 10 to 1. Verdict: IMPROVEMENT under the criteria drafted in #206. Caught live before benchmarking: matching reads name, so a request for fields ["id"] fetched nameless items and matched nothing. The catalog fill now always requests id and name regardless of output fields, with a regression test. Also lifts makeTools out of run() so the tool surface is testable without booting the server. Validation impact: query. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review raised that Swift-side matching over a cached catalog was fragile. It was, in a way worth recording: the fill capped at ten pages and then cached a partial catalog as if complete, so on a database above ten thousand entries a name matching only later items returned "no match" with no warning. Silent wrong answers. It also carried a five-minute staleness window, memory proportional to catalog size, and a second matcher in Swift kept in step with the plug-in by hand -- with a known Unicode divergence between Swift lowercased() and JavaScript toLocaleLowerCase(). Matching now runs in the plug-in, reusing the same normalizer and matcher the scalar search already uses, and returns only matches. Batch and scalar agree by construction rather than by a duplicated implementation. There is no cap, no staleness, no catalog copy in this process, and no cache correctness surface on this path. Evidence the duplication was a real hazard: before this change a request for fields ["id"] fetched nameless items and matched nothing, so batch returned zero where scalar returned two. That failure cannot occur now, because matching uses the plug-in's own access to the object. Measured again after the change, interleaved A/B, six reps per arm: 10 scalar searches 2402ms versus one batch call 432ms, -82.0% at 134 SE, Bridge round trips 10 to 1. The improvement is unchanged, so nothing was traded away for the correctness gain. The catalog cache keeps its fill coalescing, which remains useful for the ordinary paginated paths. Validation impact: query. All semantic gates pass; 317 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Architecture changed after review: matching moved into the plug-inReview challenged whether Swift-side matching over a cached catalog was sound. It was not, and the specific failure is worth recording. What was actually wrongThe catalog fill paged up to a cap of ten pages and then cached a partial catalog as if it were complete. On a database above ~10,000 entries, a name matching only later items returned "no match" — no error, no warning, no truncation flag. Silent wrong answers, and the cap was mine. Alongside that: a five-minute staleness window, memory proportional to catalog size, and a second matcher in Swift kept in step with the plug-in by hand — including a known divergence between Swift Outside guidance pointed the same way. Microsoft's caching guidance warns specifically about loading a full result set into memory before filtering, "which impacts both memory use and latency and can cause unexpected issues if the data size grows quickly". MCP-specific guidance is more direct: delegate search and filtering to the backend and keep the server a lightweight protocol bridge. What it is nowMatching runs in the plug-in, reusing the same normaliser and matcher the scalar Evidence the duplication was a live hazard rather than a theoretical one: before this change, a request for Re-measured after the changeInterleaved A/B, 6 reps per arm, 10 names, 392-project database:
Unchanged from the cached implementation (−81.9% before), so the correctness gain cost nothing measurable. Verified live: batch and scalar agree on every tested name with Consequence for deploymentThis now does require a plug-in change, so it needs the usual plug-in/binary pairing on upgrade. I had previously listed "no plug-in change required" as a benefit; that was a deployment convenience being weighed against silent truncation, which was the wrong trade. The catalog cache keeps its fill coalescing — still useful for the ordinary paginated paths. 317 tests pass; all semantic gates pass. |
Closes #171.
Problem
Choosing a destination during inbox processing cost one scalar catalog search per candidate name. One observed inbox-zero run spent 46 project searches and 22 tag searches — most of the workflow's time went on resolving names, not on deciding anything.
Contract
list_projectsandlist_tagsaccept an optionalsearchesarray:{ "searches": ["first name", "second name"], "matchLimitPerSearch": 10, "fields": ["id", "name", "path"] }{ "searchResults": [ { "search": "first name", "items": [{"id": "…", "name": "…"}], "returnedCount": 1, "truncated": false } ], "returnedCount": 1 }Workandworkwould otherwise produce two identical groups.matchLimitPerSearch(1-25, default 10) is markedtruncated: truerather than silently narrowed.trim, lower-case, substring on name), so batch and scalar agree for the same name.searchesis mutually exclusive with scalarsearch,page.cursor, andincludeTaskCounts— each is a different question, so combining them fails before Bridge dispatch rather than being ignored.includeTaskCountsexclusion was settled on the issue: batch resolution stays a destination-selection primitive; its measured cost was roughly +1.3 s and double the bytes, and "which of these is stalled?" belongs to #87.Implementation
One catalog fill per
(statusFilter, fields), cached, then matched locally. N names cost one Bridge round trip on a cold cache and none while the entry stays warm.CatalogCachegains fill coalescing: concurrent callers on a cold cache share a single fetch instead of each driving its own through the Bridge lane — a stampede that would be slowest exactly when the cache matters most. Invalidation cancels in-flight fills so a fetch started before a mutation cannot land stale data afterwards.No plug-in change is required, so this ships without a plug-in/binary version pairing step.
Measured result
Interleaved A/B per the criteria drafted in #206, 392-project database, 6 reps per arm, 10 names:
Response bytes rise 1522 → 2298 for the batch call, because one grouped envelope replaces ten bare ones; per matched entry the data is identical, and the model sees fewer, better-organised results rather than more.
A bug caught before benchmarking
Checking correctness first paid off: matching reads
name, so a request forfields: ["id"]fetched nameless items and matched nothing — scalar found 2/1/3 results for three names where batch found 0/0/0. The catalog fill now always requestsidandnameregardless of output fields, with a regression test. Batch and scalar now agree exactly on the same names.Validation
Impact:
queryperfocusrelay-dev classify. All 8 semantic gates pass.321 tests pass, including 35 new ones: normalization matrix, limit bounds, all three exclusivity rules, literal/case-insensitive/substring matching, request-order grouping, empty-group and truncation semantics, overlapping and duplicate names, cache coalescing (8 concurrent callers → exactly 1 fill), failed-fill recovery, invalidation, and MCP wire coverage of both tools' schemas plus the response encoding.
Live exclusivity guards verified end to end — all five reject before Bridge dispatch with actionable messages.
Also lifts
makeToolsout ofrun()into a static, so the tool surface is testable without booting the server; the wire tests validate against the real schema clients receive rather than a reconstruction.🤖 Generated with Claude Code