From aa1a15d4f396e58bcfd11dff1ebacdc67fab0998 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 3 Sep 2026 16:32:29 -0400 Subject: [PATCH 1/3] Add queryless time-window search Allow bounded tag and time filtering without a text query while preserving deterministic search ordering and existing clients. --- cmd/docbank/cli_test.go | 9 ++++++ cmd/docbank/exit_test.go | 4 +++ cmd/docbank/search.go | 8 ++++-- docs/agents/integration.md | 20 ++++++++++++- docs/architecture/http-api.md | 8 ++++++ docs/cli-reference.md | 9 +++++- docs/usage/searching.md | 8 ++++++ internal/api/errors.go | 1 + internal/api/openapi_test.go | 13 +++++++++ internal/api/routes_read.go | 4 ++- internal/api/routes_read_test.go | 18 ++++++++++++ internal/api/types.go | 2 +- internal/client/client.go | 9 +++++- internal/client/client_test.go | 7 +++++ internal/client/runtime.go | 2 +- internal/store/search.go | 44 ++++++++++++++++++++++++++++- internal/store/search_test.go | 48 ++++++++++++++++++++++++++++++++ 17 files changed, 205 insertions(+), 9 deletions(-) diff --git a/cmd/docbank/cli_test.go b/cmd/docbank/cli_test.go index 7bf44ec1..e9669155 100644 --- a/cmd/docbank/cli_test.go +++ b/cmd/docbank/cli_test.go @@ -1170,6 +1170,11 @@ func TestSearchCLI(t *testing.T) { assert.Equal(t, tag.ID, report.TagID) require.Len(t, report.Hits, 1) assert.Equal(t, "/inbox/insurance-2026.txt", report.Hits[0].Path) + out, err = runCLI(t, "search", "--tag", "renewal", "--json") + require.NoError(t, err, out) + require.NoError(t, json.Unmarshal([]byte(out), &report)) + require.Len(t, report.Hits, 1) + assert.Equal(t, "filter", report.Hits[0].Match) out, err = runCLI(t, "search", "insurance", "--mime-type", "TEXT/PLAIN", "--json") require.NoError(t, err, out) @@ -1219,6 +1224,10 @@ func TestSearchCLIReportsTruncation(t *testing.T) { _, err = runCLI(t, "search", "report", "--limit", "0") require.Error(t, err) assert.Contains(t, err.Error(), "between 1 and 1000") + + _, err = runCLI(t, "search") + require.Error(t, err) + assert.Contains(t, err.Error(), "search query is required") } func TestTrashEmpty(t *testing.T) { diff --git a/cmd/docbank/exit_test.go b/cmd/docbank/exit_test.go index 6307fbd0..88e97717 100644 --- a/cmd/docbank/exit_test.go +++ b/cmd/docbank/exit_test.go @@ -59,6 +59,10 @@ func TestRunProcessDistinguishesUsageAndMissingNodes(t *testing.T) { assert.Equal(t, exitUsage, code) assert.Contains(t, stderr.String(), "--limit must be between") + code = run("search") + assert.Equal(t, exitUsage, code) + assert.Contains(t, stderr.String(), "search query is required") + code = run("search", "term", "--mime-type", "text/plain; charset=utf-8") assert.Equal(t, exitUsage, code) assert.Contains(t, stderr.String(), "must not include parameters") diff --git a/cmd/docbank/search.go b/cmd/docbank/search.go index 4d600ee8..8081f786 100644 --- a/cmd/docbank/search.go +++ b/cmd/docbank/search.go @@ -27,9 +27,8 @@ var ( ) var searchCmd = &cobra.Command{ - Use: "search ...", + Use: "search [...]", Short: "Search document names and extracted text", - Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if searchLimit < 1 || searchLimit > maxSearchLimit { return usageError(fmt.Errorf("--limit must be between 1 and %d", maxSearchLimit)) @@ -44,6 +43,11 @@ var searchCmd = &cobra.Command{ if err != nil { return usageError(err) } + if searchTag == "" && searchUnder == "" && store.SearchNeedsQuery(strings.Join(args, " "), store.SearchOptions{ + MIMEType: mimeType, ModifiedSince: modifiedSince, ModifiedBefore: modifiedBefore, + }) { + return usageError(store.ErrSearchQueryRequired) + } var underSelector nodeSelector if searchUnder != "" { underSelector, err = parseNodeSelector(searchUnder) diff --git a/docs/agents/integration.md b/docs/agents/integration.md index 7b222f32..3f97dff4 100644 --- a/docs/agents/integration.md +++ b/docs/agents/integration.md @@ -179,7 +179,7 @@ curl --fail-with-body \ Search is bounded separately. Always inspect `truncated`; increase the limit or refine the query rather than assuming the returned array is complete. -Each result's `match` is `name` or `content`. Name matches keep their established +Each result's `match` is `name`, `content`, or `filter`. Name matches keep their established ranking and always precede content-only matches, so adding content indexing does not reorder an agent's filename-based workflow. Content search covers only the current version of verified UTF-8 plain text, Markdown, JSON, and JSONL documents @@ -204,6 +204,13 @@ RFC3339 offsets; the response echoes canonical UTC values. These fields refer to the live node's `modified_at`, not source-file provenance or historical content-version time. +The `q` parameter may be omitted when `tag_id`, `modified_since`, or +`modified_before` is present. This returns a bounded filter page ordered by +`modified_at` descending, with `match: "filter"` on every hit. A MIME or +subtree filter can narrow that page but cannot anchor an empty query by itself; +an empty query without a tag or time bound is a validation error. Always inspect +`truncated` and increase `limit` or add a narrower filter when needed. + ```bash curl --fail-with-body --get \ -H "X-Api-Key: $DOCBANK_API_KEY" \ @@ -217,6 +224,17 @@ curl --fail-with-body --get \ "$DOCBANK_URL/api/v1/search" ``` +To list every live node changed in a window, leave out `q`: + +```bash +curl --fail-with-body --get \ + -H "X-Api-Key: $DOCBANK_API_KEY" \ + --data-urlencode 'modified_since=2026-01-01T00:00:00Z' \ + --data-urlencode 'modified_before=2026-04-01T00:00:00Z' \ + --data 'limit=100' \ + "$DOCBANK_URL/api/v1/search" +``` + For shell automation, prefer `get` when the bytes must become a local file. It keeps incomplete bytes private and emits a structured proof receipt only after the complete stream verifies and is atomically published: diff --git a/docs/architecture/http-api.md b/docs/architecture/http-api.md index 90d70368..c798af4f 100644 --- a/docs/architecture/http-api.md +++ b/docs/architecture/http-api.md @@ -58,6 +58,14 @@ Endpoints are filesystem-shaped, under `/api/v1`: | `GET /watches` | inspect effective watched-inbox configuration and runner state | Implemented | | `POST /backup/init` · `POST /backup/snapshots` · `POST /backup/snapshots/stream` · `GET /backup/snapshots` | initialize a repository / create with JSON or streamed progress / list snapshots | Implemented | +Search accepts an optional `q` query. Omitting it is valid only when `tag_id`, +`modified_since`, or `modified_before` bounds the page. The resulting +filter-only hits are ordered by current `modified_at` descending and identify +their source with `match: "filter"`; `mime_type` and `under_node_id` can +narrow an anchored page but cannot anchor an empty query alone. An empty +unanchored query returns `422 validation`. The normal `limit` and `truncated` +contract remains in force, without a cursor. + Root-level, outside `/api/v1` and auth-exempt: `GET /health`, `GET /api/ping` (daemon discovery), `GET /docs` and the OpenAPI documents, and `/` plus `/assets/` (the static web application, when `[web] enabled`). A hidden `POST diff --git a/docs/cli-reference.md b/docs/cli-reference.md index af12c05d..9673af7e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -641,7 +641,7 @@ returns the complete restored node with its resulting path and revision. ## docbank search ``` -docbank search ... [--tag ] [--mime-type ] [--under ] [--modified-since ] [--modified-before ] [--limit ] [--json] +docbank search [...] [--tag ] [--mime-type ] [--under ] [--modified-since ] [--modified-before ] [--limit ] [--json] ``` Full-text search over live node names and verified extracted text (FTS5). @@ -670,6 +670,13 @@ inclusive and the upper bound is exclusive. Either may be used alone; when both are present, the lower bound must be earlier. Inputs are normalized to canonical UTC before the request. +The query can be omitted for a bounded filter page when `--tag`, +`--modified-since`, or `--modified-before` is supplied. These results are +ordered newest-first by current modification time and show `filter` in the +`MATCH` column. `--mime-type` and `--under` narrow a query or an anchored +filter page, but neither is an anchor by itself, so a blank search with only +one of those options is rejected. + `--json` emits the typed search report with `hits`, the applied `limit`, and an explicit `truncated` boolean. A filtered report also echoes the stable `tag_id`, normalized `mime_type`, stable `under_node_id`, and canonical diff --git a/docs/usage/searching.md b/docs/usage/searching.md index 479a17f5..03674043 100644 --- a/docs/usage/searching.md +++ b/docs/usage/searching.md @@ -13,6 +13,8 @@ docbank search report --mime-type application/pdf docbank search receipt --under /taxes/2026 docbank search report --modified-since 2026-01-01T00:00:00Z docbank search report --modified-before 2026-04-01T00:00:00Z +docbank search --modified-since 2026-01-01T00:00:00Z --modified-before 2026-04-01T00:00:00Z +docbank search --tag taxes docbank search report --limit 200 docbank search report --json ``` @@ -35,6 +37,12 @@ id:198 content /taxes/2026/car-insurance-notes.md first. Content-only matches follow in their own BM25 order; both groups use deterministic name/ID tie-breaks. The default limit is 50; `--limit` accepts 1–1000, and truncation is always reported. +- **Bounded filter pages.** The query may be omitted when `--tag`, + `--modified-since`, or `--modified-before` supplies an anchor. Filter-only + results are ordered by `modified_at` descending and each hit reports + `filter` in the `MATCH` column. The default limit and truncation behavior + are unchanged. A blank query with only `--mime-type` or `--under` is rejected; + those options narrow an anchored search but don't anchor one. - **Live nodes only.** Trashed documents don't appear; restore returns them to the index. Renames update the index immediately. - **Current content only.** Retained prior versions stay available through diff --git a/internal/api/errors.go b/internal/api/errors.go index 8a926fd1..38382f56 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -61,6 +61,7 @@ var storeErrCodes = []struct { status int code string }{ + {store.ErrSearchQueryRequired, http.StatusUnprocessableEntity, "validation"}, {store.ErrNotFound, http.StatusNotFound, "not_found"}, {store.ErrExists, http.StatusConflict, "exists"}, {store.ErrCycle, http.StatusConflict, "cycle"}, diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index d91d7a78..16c0ea19 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -47,6 +47,19 @@ func TestOpenAPIDeclaresSecurity(t *testing.T) { assert.Contains(t, doc, "security:", "document-level security requirement missing") } +func TestOpenAPISearchQueryIsOptional(t *testing.T) { + doc := api.NewOfflineServer().API().OpenAPI() + op := doc.Paths["/api/v1/search"].Get + require.NotNil(t, op) + for _, param := range op.Parameters { + if param.Name == "q" { + assert.False(t, param.Required) + return + } + } + t.Fatal("search q parameter missing") +} + func TestLongRunningBackupRoutesClearBodyReadDeadline(t *testing.T) { doc := api.NewOfflineServer().API().OpenAPI() for _, operation := range []*huma.Operation{ diff --git a/internal/api/routes_read.go b/internal/api/routes_read.go index 42b21f2e..9a4502a8 100644 --- a/internal/api/routes_read.go +++ b/internal/api/routes_read.go @@ -364,12 +364,14 @@ func registerReadRoutes(api huma.API, d Deps) { Summary: "Search live document names and extracted text", Description: "Name matches retain their established BM25 order and appear first; " + "content-only matches follow in their own BM25 order. Every hit names its match source. " + + "The query may be empty when a tag or modification-time bound makes the page bounded; " + + "such filter-only results are ordered by modification time descending. " + "An optional stable tag ID requires that assignment for every result. An optional " + "parameter-free MIME type matches the current file version with or without parameters. " + "An optional live directory node ID restricts results to its descendants. Optional " + "absolute modification timestamps form an inclusive-since, exclusive-before interval.", }, func(ctx context.Context, in *struct { - Q string `query:"q" required:"true"` + Q string `query:"q"` Limit int `query:"limit" default:"50" minimum:"1" maximum:"1000"` TagID string `query:"tag_id" pattern:"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"` MIMEType string `query:"mime_type" maxLength:"255"` diff --git a/internal/api/routes_read_test.go b/internal/api/routes_read_test.go index 4e3d8af3..49c51493 100644 --- a/internal/api/routes_read_test.go +++ b/internal/api/routes_read_test.go @@ -413,6 +413,19 @@ func TestSearch(t *testing.T) { "modified_since=2100-01-01T00:00:00Z&modified_before=2000-01-01T00:00:00Z", nil) assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, body) assert.Contains(t, body, `"code":"validation"`) + resp, body = get(t, ts, "/api/v1/search?limit=10&modified_since=2000-01-01T00:00:00Z", nil) + require.Equal(t, http.StatusOK, resp.StatusCode, body) + require.NoError(t, json.Unmarshal([]byte(body), &rep)) + require.Len(t, rep.Hits, 2) + for _, hit := range rep.Hits { + assert.Equal(t, "filter", hit.Match) + } + resp, body = get(t, ts, "/api/v1/search?limit=10", nil) + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, body) + assert.Contains(t, body, `"code":"validation"`) + resp, body = get(t, ts, "/api/v1/search?limit=10&mime_type=application%2Fpdf", nil) + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, body) + assert.Contains(t, body, `"code":"validation"`) tag, err := s.CreateTag(t.Context(), "renewal") require.NoError(t, err) @@ -426,6 +439,11 @@ func TestSearch(t *testing.T) { require.Len(t, rep.Hits, 1) assert.Equal(t, insuranceNodes[1].ID, rep.Hits[0].Node.ID) assert.Equal(t, tag.ID, rep.TagID) + resp, body = get(t, ts, "/api/v1/search?limit=10&tag_id="+tag.ID, nil) + require.Equal(t, http.StatusOK, resp.StatusCode, body) + require.NoError(t, json.Unmarshal([]byte(body), &rep)) + require.Len(t, rep.Hits, 1) + assert.Equal(t, "filter", rep.Hits[0].Match) resp, body = get(t, ts, "/api/v1/search?q=insurance&limit=10&tag_id=11111111-1111-4111-8111-111111111111", nil) diff --git a/internal/api/types.go b/internal/api/types.go index bb540f9c..ecc89718 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -485,7 +485,7 @@ type ContentReversionReceipt struct { type SearchHit struct { Node Node `json:"node"` Path string `json:"path"` - Match string `json:"match" enum:"name,content"` + Match string `json:"match" enum:"name,content,filter"` } // SearchReport is one bounded search result page. diff --git a/internal/client/client.go b/internal/client/client.go index 9896fef8..5213afcd 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -827,7 +827,7 @@ func (c *Client) Search(ctx context.Context, query string, limit int) (api.Searc return c.SearchWithOptions(ctx, query, limit, SearchOptions{}) } -// SearchWithOptions returns one bounded ranked result set. +// SearchWithOptions returns one bounded ranked or filter-only result set. func (c *Client) SearchWithOptions( ctx context.Context, query string, limit int, opts SearchOptions, ) (api.SearchReport, error) { @@ -848,6 +848,13 @@ func (c *Client) SearchWithOptions( if err != nil { return out, err } + normalizedOpts := store.SearchOptions{ + TagID: opts.TagID, MIMEType: mimeType, UnderNodeID: opts.UnderNodeID, + ModifiedSince: modifiedSince, ModifiedBefore: modifiedBefore, + } + if store.SearchNeedsQuery(query, normalizedOpts) { + return out, store.ErrSearchQueryRequired + } queryValues := url.Values{} queryValues.Set("q", query) queryValues.Set("limit", strconv.Itoa(limit)) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index f780cc05..a0781932 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -209,6 +209,13 @@ func TestSearchWithOptionsUsesStableTagIdentity(t *testing.T) { require.Len(t, report.Hits, 1) assert.Equal(t, tagged.ID, report.Hits[0].Node.ID) + filterReport, err := c.SearchWithOptions(ctx, "", 10, client.SearchOptions{TagID: tag.ID}) + require.NoError(t, err) + require.Len(t, filterReport.Hits, 1) + assert.Equal(t, "filter", filterReport.Hits[0].Match) + _, err = c.SearchWithOptions(ctx, "", 10, client.SearchOptions{}) + require.ErrorIs(t, err, store.ErrSearchQueryRequired) + _, err = c.SearchWithOptions(ctx, "insurance", 10, client.SearchOptions{TagID: "bad"}) require.ErrorContains(t, err, "canonical UUIDv4") _, err = c.SearchWithOptions(ctx, "insurance", 10, client.SearchOptions{ diff --git a/internal/client/runtime.go b/internal/client/runtime.go index 2c2aa20d..b9e7544f 100644 --- a/internal/client/runtime.go +++ b/internal/client/runtime.go @@ -24,7 +24,7 @@ const ( metaWebAddress = "web_address" // Bump whenever a newer CLI cannot safely use an older daemon's HTTP or // runtime-record contract, even when both binaries report the same version. - daemonProtocolVersion = "62" + daemonProtocolVersion = "63" ) // EnsureResult reports what EnsureDaemon found or did. diff --git a/internal/store/search.go b/internal/store/search.go index e360b6de..7745f80c 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -40,9 +40,24 @@ type SearchOptions struct { ModifiedBefore string } +// SearchNeedsQuery reports whether the normalized options leave an empty FTS +// query without the tag or time anchor required for a bounded filter page. +func SearchNeedsQuery(query string, opts SearchOptions) bool { + return ftsQuery(query) == "" && opts.TagID == "" && + opts.ModifiedSince == "" && opts.ModifiedBefore == "" +} + const ( SearchMatchName = "name" SearchMatchContent = "content" + SearchMatchFilter = "filter" +) + +// ErrSearchQueryRequired reports an unanchored search without query text. +// Empty queries are useful only when a tag or modification-time bound keeps +// the result page bounded. +var ErrSearchQueryRequired = errors.New( + "search query is required unless a tag or modification-time bound is supplied", ) // LexicalGeneration identifies one complete, immutable FTS projection. Rows @@ -1137,8 +1152,35 @@ func (s *Store) SearchPageWithOptions( opts.ModifiedSince = modifiedSince opts.ModifiedBefore = modifiedBefore fq := ftsQuery(query) + if SearchNeedsQuery(query, opts) { + return nil, false, ErrSearchQueryRequired + } if fq == "" { - return nil, false, nil + filterSQL, filterArgs := searchFilterSQL(opts) + filterArgs = append(filterArgs, limit+1) + rows, err := s.db.QueryContext(ctx, ` + SELECT `+nodeCols+` + FROM `+nodeFrom+` + WHERE n.trashed_at IS NULL + AND n.parent_id IS NOT NULL + `+filterSQL+` + ORDER BY n.modified_at DESC, n.name, n.id + LIMIT ?`, filterArgs...) + if err != nil { + return nil, false, fmt.Errorf("searching filters: %w", err) + } + hits, err := scanSearchRows(rows, SearchMatchFilter, "") + if err != nil { + return nil, false, err + } + truncated := len(hits) > limit + if truncated { + hits = hits[:limit] + } + if err := s.addSearchPaths(ctx, hits); err != nil { + return nil, false, err + } + return hits, truncated, nil } filterSQL, filterArgs := searchFilterSQL(opts) nameArgs := []any{fq} diff --git a/internal/store/search_test.go b/internal/store/search_test.go index 25995c52..c57bbb3e 100644 --- a/internal/store/search_test.go +++ b/internal/store/search_test.go @@ -367,6 +367,54 @@ func TestSearchPageFiltersByModificationTime(t *testing.T) { require.ErrorContains(t, err, "must be earlier") } +func TestSearchPageAllowsOnlyBoundedQuerylessFilters(t *testing.T) { + s := newTestStore(t) + ctx := t.Context() + tag, err := s.CreateTag(ctx, "briefing") + require.NoError(t, err) + old, err := s.CreateFile(ctx, s.RootID(), "old.txt", fakeHash("queryless-old"), 1, "text/plain") + require.NoError(t, err) + newer, err := s.CreateFile(ctx, s.RootID(), "new.txt", fakeHash("queryless-new"), 1, "text/plain") + require.NoError(t, err) + _, err = s.CreateFile(ctx, s.RootID(), "outside.txt", fakeHash("queryless-outside"), 1, "text/plain") + require.NoError(t, err) + _, err = s.AssignTag(ctx, tag.ID, newer.ID, newer.Revision) + require.NoError(t, err) + for id, stamp := range map[int64]string{ + old.ID: "2026-01-01T00:00:00.000000000Z", + newer.ID: "2026-01-03T00:00:00.000000000Z", + } { + _, err = s.db.ExecContext(ctx, `UPDATE nodes SET modified_at=? WHERE id=?`, stamp, id) + require.NoError(t, err) + } + hits, truncated, err := s.SearchPageWithOptions(ctx, "", 10, SearchOptions{TagID: tag.ID}) + require.NoError(t, err) + require.False(t, truncated) + require.Len(t, hits, 1) + assert.Equal(t, newer.ID, hits[0].Node.ID) + assert.Equal(t, SearchMatchFilter, hits[0].Match) + + hits, truncated, err = s.SearchPageWithOptions(ctx, " ", 1, SearchOptions{ + ModifiedSince: "2026-01-01T00:00:00Z", ModifiedBefore: "2026-01-04T00:00:00Z", + }) + require.NoError(t, err) + assert.True(t, truncated) + require.Len(t, hits, 1) + assert.Equal(t, newer.ID, hits[0].Node.ID) + assert.Equal(t, SearchMatchFilter, hits[0].Match) + + for _, opts := range []SearchOptions{ + {}, {MIMEType: "text/plain"}, {UnderNodeID: s.RootID()}, + } { + _, _, err = s.SearchPageWithOptions(ctx, "", 10, opts) + require.ErrorIs(t, err, ErrSearchQueryRequired) + } + _, _, err = s.SearchPageWithOptions(ctx, "", 10, SearchOptions{MIMEType: "not a media type"}) + require.ErrorContains(t, err, "is invalid") + _, _, err = s.SearchPageWithOptions(ctx, "", 10, SearchOptions{ModifiedSince: "yesterday"}) + require.ErrorContains(t, err, "absolute RFC3339 timestamp") +} + func TestSearchContentFollowsStableNameMatches(t *testing.T) { s := newTestStore(t) ctx := t.Context() From 83257020ebf8f62595d1f97693e577eb32c9098d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 3 Sep 2026 16:42:01 -0400 Subject: [PATCH 2/3] Add queryless time-window search Allow bounded tag and time filtering without a text query while preserving deterministic search ordering and existing clients. --- cmd/docbank/exit_test.go | 8 ++++++++ cmd/docbank/search.go | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/docbank/exit_test.go b/cmd/docbank/exit_test.go index 88e97717..403bd51f 100644 --- a/cmd/docbank/exit_test.go +++ b/cmd/docbank/exit_test.go @@ -63,6 +63,14 @@ func TestRunProcessDistinguishesUsageAndMissingNodes(t *testing.T) { assert.Equal(t, exitUsage, code) assert.Contains(t, stderr.String(), "search query is required") + code = run("search", "--under", "/") + assert.Equal(t, exitUsage, code) + assert.Contains(t, stderr.String(), "search query is required") + + code = run("search", "--mime-type", "text/plain") + assert.Equal(t, exitUsage, code) + assert.Contains(t, stderr.String(), "search query is required") + code = run("search", "term", "--mime-type", "text/plain; charset=utf-8") assert.Equal(t, exitUsage, code) assert.Contains(t, stderr.String(), "must not include parameters") diff --git a/cmd/docbank/search.go b/cmd/docbank/search.go index 8081f786..b6c0fd75 100644 --- a/cmd/docbank/search.go +++ b/cmd/docbank/search.go @@ -43,7 +43,7 @@ var searchCmd = &cobra.Command{ if err != nil { return usageError(err) } - if searchTag == "" && searchUnder == "" && store.SearchNeedsQuery(strings.Join(args, " "), store.SearchOptions{ + if searchTag == "" && store.SearchNeedsQuery(strings.Join(args, " "), store.SearchOptions{ MIMEType: mimeType, ModifiedSince: modifiedSince, ModifiedBefore: modifiedBefore, }) { return usageError(store.ErrSearchQueryRequired) From f281fbb82e50b32f57f0af2f52fdbe807c2da545 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 3 Sep 2026 16:42:36 -0400 Subject: [PATCH 3/3] Add queryless time-window search Allow bounded tag and time filtering without a text query while preserving deterministic search ordering and existing clients. --- docs/architecture/http-api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/http-api.md b/docs/architecture/http-api.md index c798af4f..5ddc9219 100644 --- a/docs/architecture/http-api.md +++ b/docs/architecture/http-api.md @@ -64,7 +64,9 @@ filter-only hits are ordered by current `modified_at` descending and identify their source with `match: "filter"`; `mime_type` and `under_node_id` can narrow an anchored page but cannot anchor an empty query alone. An empty unanchored query returns `422 validation`. The normal `limit` and `truncated` -contract remains in force, without a cursor. +contract remains in force, without a cursor. Filter-only selection may scan +all live nodes before applying `limit`, so large-vault callers should use a +narrow time or tag bound. Root-level, outside `/api/v1` and auth-exempt: `GET /health`, `GET /api/ping` (daemon discovery), `GET /docs` and the OpenAPI documents,