Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/docbank/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions cmd/docbank/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ 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", "--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")
Expand Down
8 changes: 6 additions & 2 deletions cmd/docbank/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ var (
)

var searchCmd = &cobra.Command{
Use: "search <query>...",
Use: "search [<query>...]",
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))
Expand All @@ -44,6 +43,11 @@ var searchCmd = &cobra.Command{
if err != nil {
return usageError(err)
}
if searchTag == "" && 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)
Expand Down
20 changes: 19 additions & 1 deletion docs/agents/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" \
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions docs/architecture/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ 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. 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,
and `/` plus `/assets/` (the static web application, when `[web] enabled`). A hidden `POST
Expand Down
9 changes: 8 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ returns the complete restored node with its resulting path and revision.
## docbank search

```
docbank search <query>... [--tag <name-or-id>] [--mime-type <type/subtype>] [--under <path-or-id>] [--modified-since <timestamp>] [--modified-before <timestamp>] [--limit <n>] [--json]
docbank search [<query>...] [--tag <name-or-id>] [--mime-type <type/subtype>] [--under <path-or-id>] [--modified-since <timestamp>] [--modified-before <timestamp>] [--limit <n>] [--json]
```

Full-text search over live node names and verified extracted text (FTS5).
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/usage/searching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
13 changes: 13 additions & 0 deletions internal/api/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 3 additions & 1 deletion internal/api/routes_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
18 changes: 18 additions & 0 deletions internal/api/routes_read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion internal/client/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 43 additions & 1 deletion internal/store/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Loading