Skip to content

Expand a bounded set of tasks by stable ID in one read (#172) - #203

Merged
deverman merged 1 commit into
masterfrom
issue-172-bounded-id-lookup
Aug 1, 2026
Merged

Expand a bounded set of tasks by stable ID in one read (#172)#203
deverman merged 1 commit into
masterfrom
issue-172-bounded-id-lookup

Conversation

@deverman

@deverman deverman commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #172.

User outcome

An inbox workflow can read a compact page, pick the few ambiguous captures, and expand their notes in one bounded read instead of one get_task call each. The observed cost this addresses: one inbox-zero UAT made 28 scalar detail calls for a 32-item inbox.

Contract

list_tasks.filter gains an optional ids array:

{
  "filter": { "ids": ["id-1", "id-2"], "inboxOnly": true, "inboxView": "remaining" },
  "fields": ["id", "name", "note"],
  "page": { "limit": 20 }
}
  • 1-20 stable IDs, trimmed and de-duplicated preserving first-requested order.
  • Other filters apply as an intersection — an ID outside the supplied scope is reported in unresolvedIDs, never returned, and never replaced by an unrelated task.
  • Results follow requested-ID order.
  • One complete response: page.cursor is rejected, page.limit must cover the requested IDs, and no nextCursor is issued.
  • get_task_counts does not offer ids. It returns scalars, so selecting specific tasks there has no meaning; omitting it from that schema lets closed-property validation reject it with a clear message rather than silently ignoring it.

Plugin-side selection reuses the existing scoped pool and the shared filter predicate, so parent, project, and status semantics stay native and documented. Ordering and unresolved reporting happen client-side, keeping the plug-in change small.

Measured result

10 selected detail reads on a live database, one server process, same fields:

round trips elapsed items field keys
get_task × 10 10 1042 ms 10 id, name, note
one bounded query 1 406 ms 10 id, name, note

10× fewer Bridge round trips, 61% lower elapsed time, identical items and identical field keys — so the reduction does not come with broader data exposure. The bounded response is larger in raw bytes only because it is one indented envelope rather than ten bare objects.

Validation

Impact: query, per focusrelay-dev classify.

  • All semantic gates pass; canary rotation passes (8/8).
  • 272 tests pass, including 26 new ones: normalization (blank, whitespace, duplicate, over-bound, duplicates-counted-after-collapsing), paging rules, ordering determinism, unresolved reporting, and the MCP schema/wire surface.
  • Direct MCP stdio probes against a live database confirmed: reversed-order request returns requested order; a bogus ID lands in unresolvedIDs with no substitution; blank ID, cursor, and insufficient limit are each rejected before Bridge dispatch; get_task_counts.filter.ids is unsupported; and ordinary paginated list_tasks still issues a query-bound cursor.
  • Scope behavior verified live: the same IDs resolve 5/5 under inboxOnly + remaining, and 0/5 with 5 unresolved under completed=true, with no unrelated tasks returned.

One incidental change: closingObjectSchemas became internal so wire tests can validate against the same closed schema clients receive, rather than a raw shape production never serves.

Non-goals

Unchanged: name-based lookup, fuzzy matching, task caching, mutations, and get_task itself.

🤖 Generated with Claude Code

A progressive-expansion workflow -- read a compact page, then fetch notes
for the few ambiguous captures -- previously issued one get_task call per
item. One observed inbox-zero run made 28 scalar detail calls for a
32-item inbox.

list_tasks.filter gains an optional ids array of 1-20 stable task IDs.
Other filters still apply as an intersection, so an ID outside the
supplied scope is reported in unresolvedIDs rather than returned, and an
invalid ID never falls back to an unrelated page. Results follow the
requested ID order.

A bounded selection is one complete response: page.cursor is rejected,
page.limit must cover the requested IDs, and no nextCursor is issued.
IDs are trimmed, de-duplicated preserving first-requested order, and
bounded at 20 distinct entries so the selection cannot degenerate into an
unpaginated database read.

get_task_counts does not offer ids -- it returns scalars, so selecting
specific tasks there has no meaning, and omitting it from that schema
lets closed-property validation reject it with a clear message.

Plugin-side selection reuses the existing scoped pool and the shared
filter predicate, so parent, project, and status semantics stay native;
ordering and unresolved reporting happen client-side.

Measured on a live database, 10 selected detail reads: 10 Bridge round
trips to 1, elapsed 1042ms to 406ms (61% lower), identical items and
identical field keys, so no broader data exposure.

Validation impact: query, per focusrelay-dev classify. All semantic
gates and the canary rotation pass; 272 tests pass including 26 new ones;
direct MCP stdio probes cover ordering, unresolvedIDs, blank-ID, cursor,
limit, and get_task_counts rejection, and confirm ordinary pagination
still issues a query-bound cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@deverman
deverman merged commit 9af6994 into master Aug 1, 2026
4 checks passed
@deverman
deverman deleted the issue-172-bounded-id-lookup branch August 1, 2026 13:51
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.

Fetch a bounded set of task details by stable ID in one query

1 participant