Skip to content

⌘K global-search palette recall is strictly narrower than the $search executor (no pinyin/companion recall) #7643

Closed

Description

@huangyiirene

Symptom

The ⌘K command palette's recall is strictly narrower than the $search executor's. hnkj / huaningkeji return 0 hits from the global endpoint while POST /api/v1/data/showcase_account/query {search:…} returns the CJK row; the seeded CJK account is unreachable by huaning from the palette. Net user effect: pinyin recall works in the list quick-search but not in the ⌘K command palette.

The rest of the item passes: exactly one GET /api/v1/search (no per-object fanout), grouped hits (#3371 does not reproduce), Enter navigates, empty / 1-char input fire zero requests, and the RLS deny side is clean on both API and UI.

Root cause

packages/metadata-protocol/src/protocol.ts searchAll builds its own filter (TEXT_TYPES + f.searchable===true, falling back to the title field) instead of calling resolveSearchFields / expandSearchToFilter, and never ORs the SEARCH_COMPANION_FIELD — so full-pinyin / initials recall via the __search companion is absent from this path. Its comment claiming "case-insensitive substring matching via $contains" is also stale — the same $contains case bug tracked in #7641 (shared $contains/case-fold root cause).

Confirmed still present on origin/main (76d74ecb): searchAll (lines ~7177–7291) still constructs its own TEXT_TYPES + searchable===true filter with the stale $contains comment at line 7291, and references neither SEARCH_COMPANION_FIELD nor resolveSearchFields / expandSearchToFilter.

Reproduction

GET  /api/v1/search?q=hnkj          → 0 hits
GET  /api/v1/search?q=huaningkeji   → 0 hits
POST /api/v1/data/showcase_account/query {"search":"hnkj"}  → returns the CJK row

Source

Extracted from the QA run #7629 (framework 92f26f7, console 6314e87f).

Activity

  1. self-assigned this
    on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Claim: PM loop round 8
    Session: session_01XeQRiAa7vYRVX5Fog7Zby8
    Branch: claude/issue-7643-searchall-companion-recall
    Worktree: objectstack-issue-7643
    Domain: domain:metadata
    File surface: packages/metadata-protocol/src/protocol.ts — region: searchAll only, ~7177–7411 — plus its tests (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: region-level declaration, because the file is contended. PR #8770 (#8421) is open and unlanded on this same file, but in disjoint regions: canonicalizeMetaRequestType at :211, its six call sites, and the saveMetaItem door. This card is confined to searchAll. Both dispatches carry a merge-main-before-PR instruction and the queue arbitrates if I have judged the region separation wrong.

    📌 Folding in #7850's rows 2–3 — same function, same sentences

    Measured before dispatching: #7850's rows 2 and 3 are protocol.ts:7293 and :7411, i.e. inside this card's region — and this card's own root-cause section already names one of them ("Its comment claiming 'case-insensitive substring matching via $contains' is also stale").

    Two agents in one 230-line span to fix two halves of one sentence would be a self-inflicted conflict. So the searchAll dev corrects those two comments as part of this work and reports that it did.

    ⛔ #7850 row 1 is NOT folded in — packages/qa/dogfood/test/search-conformance.ledger.ts:23 belongs to the spec seat and stays with #7850, which remains open for it. The correction to make is the one #4706 Q2 = A requires: $contains is case-SENSITIVE, $icontains is the insensitive one, so prose naming $contains for case-insensitive matching is self-contradictory.

    ⛔ Also out of scope, from #7850: packages/objectql/src/search-filter.ts:18 ("Matching: case-insensitive") is correct as a declaration — the executor is what must move, and that is #7641. Do not "fix" it toward current behaviour.


    Generated by Claude Code

  3. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor
    {
      "issue": 7643,
      "status": "done",
      "branch": "claude/issue-7643-searchall-companion-recall",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8787",
      "premise_still_valid": true,
      "summary": "Re-measured on current main: the defect holds, but every line number in the card is stale — searchAll is at protocol.ts:8520-8700, not ~7177-7411. searchAll now hands engine.find the query TEXT (search: q) per object instead of building its own filter, so the ADR-0061 expansion that serves the list quick-search is the single producer of search clauses; resolveSearchFieldResolution (from @objectstack/spec/data) is still consulted, but only for the skip guard and snippet fields. PM mechanism assumption HALF-FALSIFIED and reported in the PR: expandSearchToFilter cannot be imported into metadata-protocol because objectql DEPENDS on it (package cycle), so routing happens one level up at engine.find — which is the stronger shape anyway (one producer, not two callers of one helper). No per-object assumption blocked it: the cross-object sweep was already a loop of per-object engine.find calls. Body confirmed NOT sanitizer-truncated. #7850 fold-in: row 3 (:7411) was ALREADY corrected on main by #7641's landing, so this card's own root-cause paragraph naming it stale is itself now stale; row 2 (:7293, 'case-insensitive LIKE') was still present and is corrected. Row 1 and search-filter.ts:18 left alone as instructed.",
      "tests": "Union re-run AFTER the final commit, at HEAD=663d95e1b. New suite packages/objectql/src/global-search-palette-recall.test.ts (13 cases, all pass) — placed in objectql because that is the only package where both real implementations meet; asserts PARITY (palette hit-ids === executor hit-ids, plus a written expectation so 'both broke together' cannot pass) and compares the driver-level filter trees the two paths emit. Suites: metadata-protocol 90 files/1352 tests pass; objectql 207 files/3642 tests pass; downstream consumers via the PREFIX filter '...@objectstack/metadata-protocol' — rest 116 files/1921 tests pass, runtime 157 files/2391 tests pass. REVERSE VERIFICATION, direction predicted before running: revert protocol.ts, expect 5 red / 8 green (the $icontains pin predicted GREEN, since #7641 already landed that operator in the old code). Measured 6 red / 7 green — the extra red found a fourth divergence axis (below). The FIRST ablation attempt came back all-green and was INVALID: this suite resolves @objectstack/metadata-protocol through its built dist/, so the ablation only bites after a REBUILD. Both legs re-run with a rebuild and scripts/ablation-dist-preflight.mjs proving the mutation reached the artifact (--absent leaving, present returning); restore was byte-identical (git status clean vs the commit). Gates: derived via scripts/pm/dispatch-gates.mjs against the real changed files (three-dot origin/main...HEAD) — all 13 derived families PASS plus check:nul-bytes. check:type-check-debt --re-measure (the ratchet) initially caught a +1 IN MY OWN NEW TEST (Array.prototype.at is outside this package's tsc lib); FIXED rather than raised — ledger flat at 1968, none above recorded. origin/main merged immediately before the PR; no conflict with PR #8770's regions.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8786: searchAll's renderTitle reads only the deprecated displayNameField, never the canonical nameField, while provisionPrimary stamps only nameField — so an object whose primary title is not a conventional name titles its ⌘K hits with the raw record id. Titling, not recall; searched first, no duplicate.",
        "NOT filed, reported here: the old palette scanned PRIMARY KEYS. Its fallback chain ended in 'the first text-typed field', which on an object whose only text column is id selects id — every keystroke ran {id: {$icontains: term}} against system/junction/log tables. This is #4483's defect, which the executor had fixed and this path had not; it is REPAIRED by this PR as a consequence of sharing the resolution, and is called out in the changeset as a user-visible narrowing. No issue filed because it is fixed here, not left behind."
      ]
    }

    Generated by Claude Code

  4. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    ACCEPT — PR #8787.

    My mechanism assumption was not just wrong, it was unbuildable

    I dispatched this with: "route searchAll through the same resolveSearchFields / expandSearchToFilter the $search executor uses", and invited falsification on whether per-object helpers could serve a cross-object sweep.

    The per-object worry was unfounded — "the sweep was already a loop of per-object engine.find calls, so the per-object expansion fits exactly where the old per-object filter sat." But the import half is impossible:

    expandSearchToFilter lives in @objectstack/objectql, which depends on @objectstack/metadata-protocol. Importing it would close a package cycle.

    So the routing went one level up instead: searchAll hands engine.find the query text (search: q, a declared find option) per object, and the engine expands it. That leaves one producer of search clauses rather than two that agree by maintenance — which is strictly better than the shared-helper shape I proposed, and it is the version that survives someone changing the expansion later.

    Also: my region declaration was stale. I claimed searchAll at ~:7177–7411; it is at :8520–8700. The dev re-derived from the tree rather than trusting my claim comment, which is what the rule says to do and why no conflict occurred.

    A fourth axis, found by the ablation

    The reverse verification predicted 5 red / 8 green and measured 6 red / 7 green — divergence declared rather than smoothed. The extra red is how this was found:

    the old fallback chain ended in "the first text-typed field", which on an object whose only text column is its PK selects id — so every keystroke ran {id: {$icontains: term}} against system/junction/log tables.

    That is #4483's defect, which the executor had already fixed and this path had not. So the PR carries a narrowing alongside the three widenings, and it exists because an ablation disagreed with a prediction. Those objects are now skipped.

    The pin measures the right thing

    The load-bearing assertion is parity, not "the palette finds pinyin": a palette widened by narrowing the executor would satisfy the latter and is a failure.

    That is the direction I asked to be protected, and it is protected by comparing both paths against each other and against a written expectation, with the per-object filter each hands the driver compared tree-to-tree. A test that only asserted "pinyin now works" would have passed on a change that broke the executor.

    The first ablation came back all-green and was declared invalid — the dist resolution trap again — and both legs were re-run with scripts/ablation-dist-preflight.mjs proving the mutation reached the artifact. Fourth dev tonight to hit that trap; every one of them disclosed it rather than banking the false green.

    #7850, and a pleasing recursion

    Row 3 ("case-insensitive substring matching via $contains") was already corrected on main by #7641's landing — so the card's own root-cause paragraph naming it as stale is itself now stale. Row 2 ("case-insensitive LIKE") was still there and is corrected: LIKE names no operator in this vocabulary and, since #6518's LIKE→GLOB change, not even the SQL the compilers emit.

    Left alone as instructed: #7850 row 1 (spec seat's, card stays open for it) and search-filter.ts:18, correct as a declaration under #4706 Q2 = A.

    Housekeeping

    Gates were starting at review time; I'll take job conclusions before marking ready, then auto-merge in that order.


    Generated by Claude Code

  5. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    Docs check — no change needed, and the reason is worth recording: the published docs already describe the behaviour this PR ships.

    I checked this one properly rather than waving the advisory away, because unlike tonight's other PRs it genuinely changes what a published endpoint returns — and because the drift check demonstrably missed the documenting page on #8421 today.

    Every searchable hit in the docs is object-level, inside the enable: { … } capability block ("Include in global search index" / "Index records for global search"). That is the object sweep, which this PR leaves unchanged. No doc describes the field-level searchable: true flag as selecting which fields global search matches — which is the thing this PR stops honouring.

    And content/docs/data-modeling/schema-design.mdx:86-88 states the field-selection rule outright:

    searchable: true includes an object's records in global search and the record picker. By default the search matches the name/title field plus short-text fields. To control exactly which fields are matched, set searchableFields on the object (ADR-0061).

    That is precisely what this PR implements — declared searchableFields, else the auto-default. So the docs were already right and searchAll was wrong; this PR brings the code into line with the published contract rather than moving the contract.

    That is a useful corroboration of the PR's direction from a source the dev did not cite: references/data/object.mdx:140 likewise already named global search as one of searchableFields' three consumers, which the PR body notes it makes true.

    (Reverse control on the search: 97 doc files mention "search", so the absence of a field-level claim is a real absence, not a broken grep.)


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions