Skip to content

fix(objectql): unscoped GET /api/v1/search stops 400ing when a federated object is registered and pinyin recall is on (#9469) - #9816

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-9469-unscoped-search-pinyin-federated
Aug 19, 2026
Merged

fix(objectql): unscoped GET /api/v1/search stops 400ing when a federated object is registered and pinyin recall is on (#9469)#9816
os-elon merged 2 commits into
mainfrom
claude/issue-9469-unscoped-search-pinyin-federated

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#9469

An unscopedGET /api/v1/search — no objects= parameter — answered 400
on the stock showcase configuration. Reproduced on a real boot before
anything was changed, with the scoped query as the control:

GET /api/v1/search?q=acme -> 400 INVALID_FILTER
GET /api/v1/search?q=acme&objects=showcase_account -> 200, 1 hit
{"error":"Filter on '__search' names a column that object 'showcase_ext_customer' has no column for, so the predicate never ran. ...","code":"INVALID_FILTER"}

What the measurement found, and where it differs from the card's hypothesis

The card and the dispatch both guessed that the federated object cannot
satisfy something the recall path requires
. The measurement inverts that: the
platform stamps a column onto the federated object that its datasource can
never materialize. The defect is in the producer, not in the recall path.

The __search companion is not metadata — it is a real column the platform
promises to build. The SchemaRegistry declares it at object compile time and the
driver's syncSchema materializes it as an additive migration (ADR-0045). On a
federated object (ADR-0015) that promise cannot be kept: the remote database
owns the schema, DDL is forbidden, and the schema-sync seam skips those objects
outright — packages/objectql/src/plugin.ts, verbatim:

Federated (external) objects (ADR-0015): their schema is owned by the remote
database, so DDL (syncSchema/initObjects) is forbidden and would throw.

The declaration went on anyway. Confirmed live against /api/v1/meta/object/showcase_ext_customer
before the fix — the federated object served a full __search field definition.
So the object carried a field with no column, and expandSearchToFilter — which
keys the companion clause on the declared field — ORed
{ __search: { $contains: term } } into every $search against it. The backend
then refused a statement it could not compile, correctly (#8790: the predicate
really could not have run). From the server log, verbatim:

select * from `customers` where ((lower(`name`) GLOB lower('*acme*')) or (lower(`email`) GLOB lower('*acme*'))
or (lower(`region`) GLOB lower('*acme*')) or (`__search` GLOB '*acme*')) limit 5 - no such column: __search

Every source-column clause was fine; only the companion named a column that does
not exist.

The defect is wider than the card describes

The unscoped call is the one that sweeps every registered object, so it is the
only global-search call that included a federated object — which is why scoping
hid it and why no console user was affected. But the same refusal was measured on
a call that never involved global search at all:

GET /api/v1/data/showcase_ext_customer?search=acme -> 400 INVALID_FILTER
GET /api/v1/search?q=acme&objects=showcase_ext_customer -> 400 INVALID_FILTER

Any $search touching a federated object refused. That is what decides where the
fix belongs.

Which branch of the acceptance bar this delivers, and why

The bar is a disjunction: the recall path skips objects it cannot serve, or
the refusal explains itself with a route to a working call.

This PR delivers the skip branch, applied at the provisioning seam rather
than at the sweep. Three reasons, in order of weight:

  1. A refusal branch would be wrong here. There is no user error to explain.
    The caller's request is valid, the object is searchable, and its source
    columns can answer — the only broken thing is a column the platform declared
    about itself. A refusal naming "a route to a working call" would be the
    platform asking the caller to route around a defect in the platform.
  2. Skipping inside searchAll would fix one face of three. The list
    endpoint's ?search= and the scoped-to-federated call both refuse, and
    neither goes through the global-search sweep. Only removing the undeliverable
    declaration fixes all three at once.
  3. Prime Directive Add comprehensive test suite for Zod schema validation #12 (contract-first). Tolerating the mismatch in a
    consumer — a catch in the sweep, a lenient filter builder — fossilizes a
    second de-facto contract in which a declared field may not exist. The producer
    is where the wrong declaration is made.

The fix

One gate at the provisioning seam: an object carrying an external binding gets
no companion declaration.

The predicate is external != null, deliberately the same expression the
schema-sync seam already tests
, not a second question about the same fact. The
two ends then agree by construction: every object the sync seam declines to build
a column for is exactly an object the provisioning seam declines to declare one
on. Asking the datasource's schemaMode here instead would be a second
implementation of one rule — and the SchemaRegistry holds no datasource
definitions at all
(measured: zero occurrences of the word in registry.ts), so
that drift would be structural rather than merely possible. The analytics
federation gate (ADR-0062 D6) already reads the same presence test.

A federated object whose remote table genuinely has a __search column keeps
its recall: the author declares it as an ordinary field, and provisioning returns
early on an already-present entry before reaching the new gate.

plugin-pinyin-search needs no change — its before-save hook and its boot
backfill both early-out on the declared field, so they become inert on federated
objects rather than newly wrong.

Verification

Live, on a stock showcase boot (pnpm dev -- --fresh), which registers two
federated objects and turns recall on through its zh-CN locale. Before / after
on the same configuration:

callbeforeafter
GET /search?q=acme (unscoped)400INVALID_FILTER200, 3 hits, 64 objects swept
GET /search?q=aurora (unscoped)400INVALID_FILTER200, 1 hit: showcase_ext_customer:Aurora Labs
GET /search?q=acme&objects=showcase_account (control)200, 1 hit200, 1 hit
GET /data/showcase_ext_customer?search=aurora400INVALID_FILTER200, Aurora Labs
GET /search?q=hnkj (pinyin initials)400INVALID_FILTER200, 1 hit: 华宁科技
/meta/object/showcase_ext_customerdeclares __searchno __search
/meta/object/showcase_accountdeclares __searchdeclares __search

Anti-vacuity is the q=aurora row. "Not a 400" would be worth nothing if the
sweep could not have returned anything: that query returns the federated
object's own row
, so the unscoped path is measured working, not merely
not-failing. The hnkj row is the other direction — pinyin recall still reaches
the CJK account, so the capability is bounded by this fix, not disabled.

Pinpackages/objectql/src/global-search-federated-object-recall.test.ts,
8 tests. It boots through the real registry seam with the real
OS_SEARCH_PINYIN_ENABLED variable rather than hand-stamping the companion,
against a driver whose tables have a fixed column set and which refuses an
unresolvable WHERE column with the ADR-0112 pair (code: INVALID_FILTER,
status: 400) — the envelope driver-sql raises. The first test pins that
refusal directly, so no later "did not refuse" assertion can pass against a
harness that refuses nothing.

Reverse verification, run from the committed fix and then restored from the
commit: reverting only search-companion.ts turns the suite 4 red / 4 green,
and the direction is the predicted one —

  • red: the mechanism assertion, both unscoped assertions, and the federated
    $search, the failures carrying Serialized Error: { code: 'INVALID_FILTER', status: 400 }
    — the same pair the live boot produced;
  • green: the scoped control and both recall-off cases, which is what makes the
    pin evidence about the unscoped path specifically rather than about search
    generally.

Recall off (OS_SEARCH_PINYIN_ENABLED=false) is pinned too, and it is the
card's isolation: no companion is declared on any object, the unscoped sweep
returns both objects' rows, and pinyin initials recall nothing. Both recall-off
tests stayed green under the reverse verification — i.e. they pass identically on
the defective tree, which is exactly why flipping the flag changed the outcome.

Gates

Re-derived with node scripts/pm/dispatch-gates.mjs (no paths passed — the
script takes the change set from the merge base itself). All readings below are
from 5e00fb9, the final commit, after origin/main was merged.

Green: check:changeset-gate-self-tests, check:cross-package-test-inputs,
check:durability-log-level, check:objectui-changeset, check:error-code-casing,
check:nul-bytes, check-adr-0087-registration, check-changeset-no-major,
check-empty-changeset, check-cross-package-test-inputs,
check-engine-split-ratio, docs-audit/check-affected-docs, check-adr-anchors.

Convention-triggered by the new test file, all green:
check:query-options-erasure, check:type-check-coverage,
check:engine-double-contract, check:where-matcher, and
check:type-check-debt --re-measuremeasured, not skipped, on the built
closure: 33 ledger entries re-measured in 319.1s, 1926 raw tsc errors total, none
above its recorded number; surplus: none.
No baseline moved.

Beyond the dispatch lead: check:changeset-gate-self-tests,
check:objectui-changeset, check-adr-0087-registration,
check-changeset-no-major, check-empty-changeset, plus the four
convention-triggered gates above. check:error-code-casing was named in the lead
but not derived; run anyway, green.

Suites: @objectstack/objectql 219 files / 3873 tests pass (typecheck clean);
consumer direction — plugin-pinyin-search 14, rest's companion agreement 6,
metadata-protocol 1684 — all pass.

Clause-②: no

Declared here rather than inherited. The change narrows what the platform
declares about itself (a federated object stops advertising a __search field it
could never serve) and accepts no request shape the API rejects today. A request
that returned 400 now returns results; no accept set widens, no contract grows.
This restores a published surface.

ADR anchor

scripts/adr-anchors/packages__objectql__src__search-companion.ts.json — the new
gate would read as arbitrary to someone in search-companion.ts alone ("why does
a search module care about federation?"). ADR-0015 is the decision it stands on.


Generated by Claude Code

…bjects (#9469)
An unscoped `GET /api/v1/search` answered 400 on the stock showcase config
whenever pinyin recall was on and a federated object was registered.
The `__search` companion is a real column the platform promises to build: the
SchemaRegistry declares it at object compile time and the driver's `syncSchema`
materializes it as an additive migration (ADR-0045). On a federated object
(ADR-0015) that promise cannot be kept — the remote database owns the schema,
DDL is forbidden, and the schema-sync seam skips those objects. The declaration
went on anyway, so the object carried a field with no column, and
`expandSearchToFilter` — which keys the companion clause on the DECLARED field —
ORed `{ __search: { $contains: term } }` into every `$search` against it. The
backend refused the statement it could not compile (`no such column: __search`
=> INVALID_FILTER / 400, #8790).
The unscoped call is the only global-search call that sweeps every registered
object, which is why scoping hid it and why no console user was affected.
The gate uses `external != null` — deliberately the same expression the
schema-sync seam tests — so the seam that declares the column and the seam that
would build it cannot disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 4 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via /api/v1/search (route))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5cc8a3b083e05bbd7e9064b301080bd4354c901dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5ffc22e423887a2edd0be898ecac2f66bfe1cac3 — the merge of head 5e00fb9ff32a3b4322cc395b705c685e57ec37ac into base 5cc8a3b083e05bbd7e9064b301080bd4354c901d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5ffc22e423887a2edd0be898ecac2f66bfe1cac3 && git checkout 5ffc22e423887a2edd0be898ecac2f66bfe1cac3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5cc8a3b083e05bbd7e9064b301080bd4354c901d 5e00fb9ff32a3b4322cc395b705c685e57ec37ac && git checkout -B drift-repro 5cc8a3b083e05bbd7e9064b301080bd4354c901d && git merge --no-ff 5e00fb9ff32a3b4322cc395b705c685e57ec37ac
node scripts/docs-audit/affected-docs.mjs --json 5cc8a3b083e05bbd7e9064b301080bd4354c901d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5cc8a3b083e05bbd7e9064b301080bd4354c901d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@os-elon
os-elon marked this pull request as ready for review August 19, 2026 02:19
@os-elon
os-elon added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 91c4ff5Aug 19, 2026
26 checks passed
@os-elon
os-elon deleted the claude/issue-9469-unscoped-search-pinyin-federated branch August 19, 2026 02:38
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unscoped GET /api/v1/search returns 400 on the stock config when pinyin recall is on and a federated object is registered

2 participants

@os-elon@claude