Skip to content

Resolve multiple project and tag names in one call (#171) - #209

Merged
deverman merged 2 commits into
masterfrom
issue-171-batch-name-resolution
Aug 2, 2026
Merged

Resolve multiple project and tag names in one call (#171)#209
deverman merged 2 commits into
masterfrom
issue-171-batch-name-resolution

Conversation

@deverman

@deverman deverman commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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_projects and list_tags accept an optional searches array:

{ "searches": ["first name", "second name"], "matchLimitPerSearch": 10, "fields": ["id", "name", "path"] }
{
  "searchResults": [
    { "search": "first name", "items": [{"id": "", "name": ""}], "returnedCount": 1, "truncated": false }
  ],
  "returnedCount": 1
}
  • 1-20 names, trimmed, case-insensitively de-duplicated preserving first-requested order — matching is case-insensitive, so Work and work would otherwise produce two identical groups.
  • Groups follow request order. A name matching nothing returns an empty group rather than being omitted, so "nothing matched" is distinguishable from "not asked".
  • A name matching more than matchLimitPerSearch (1-25, default 10) is marked truncated: true rather than silently narrowed.
  • Matching is literal and case-insensitive, mirroring the scalar bridge search (trim, lower-case, substring on name), so batch and scalar agree for the same name.
  • searches is mutually exclusive with scalar search, page.cursor, and includeTaskCounts — each is a different question, so combining them fails before Bridge dispatch rather than being ignored.

includeTaskCounts exclusion 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.

CatalogCache gains 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:

arm n median stdev SE round trips
10 scalar searches 6 2374 ms 44 ms 18 ms 10
one batch call 6 429 ms 33 ms 13 ms 1
VERDICT: IMPROVEMENT — median −81.9% (−1945 ms), 86.7 standard errors
ROUND TRIPS: 10 → 1

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 for fields: ["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 requests id and name regardless of output fields, with a regression test. Batch and scalar now agree exactly on the same names.

Validation

Impact: query per focusrelay-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 makeTools out of run() 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

deverman and others added 2 commits August 2, 2026 09:12
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>
@deverman

deverman commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Architecture changed after review: matching moved into the plug-in

Review challenged whether Swift-side matching over a cached catalog was sound. It was not, and the specific failure is worth recording.

What was actually wrong

The 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 lowercased() and JavaScript toLocaleLowerCase() on non-ASCII input.

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 now

Matching runs in the plug-in, reusing the same normaliser and matcher the scalar search already uses, and returns only matches. Batch and scalar agree by construction rather than by a duplicated implementation. No cap, no staleness, no catalog copy in this process, no cache-correctness surface on this path.

Evidence the duplication was a live hazard rather than a theoretical one: before this change, a request for fields: ["id"] fetched nameless items and matched nothing — batch returned 0 where scalar returned 2. That class of bug is now impossible, because matching uses the plug-in's own access to the object rather than a projected copy.

Re-measured after the change

Interleaved A/B, 6 reps per arm, 10 names, 392-project database:

arm n median stdev SE round trips
10 scalar searches 6 2402 ms 36 ms 15 ms 10
one batch call 6 432 ms 4 ms 2 ms 1
VERDICT: IMPROVEMENT — median −82.0% (−1969ms), 134.0 SE
ROUND TRIPS: 10 → 1

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 fields: ["id"] alone, tags resolve correctly including an unmatched name returning an empty group, truncation flags correctly, and all exclusivity guards still reject before Bridge dispatch.

Consequence for deployment

This 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.

@deverman
deverman merged commit 91c884b into master Aug 2, 2026
4 checks passed
@deverman
deverman deleted the issue-171-batch-name-resolution branch August 2, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resolve multiple project or tag names in one bounded query

1 participant