Skip to content

Discovery advertises capabilities.search = {enabled:false} while GET /api/v1/search serves 200 with real hits #7541

Closed

Description

@huangyiirene

Symptom

On a live showcase host, /discovery reports:

capabilities.search = { "enabled": false }
services.search     = { "enabled": false, "status": "unavailable", ... }

while the endpoint those bits describe is fully live:

GET /api/v1/search?q=audit  →  200, 5 real hits

Expected: the declared capability matches served behaviour (Prime Directive #10, declared === enforced) — either the bit is enabled: true, or the endpoint refuses.

Impact: a client that trusts the discovery document — which is exactly what the document is for — skips a working surface. This is the failure mode discovery exists to prevent, inverted: not an advertised endpoint that 404s, but a real endpoint no conforming client will ever call.

Root cause

The capability bit derives from a registered search service slot — search: registeredServices.has('search') in the well-known capability builder (packages/metadata-protocol/src/protocol.ts) — while the route (registerSearchEndpoints, packages/rest/src/rest-server.ts) gates on something else entirely: it only answers 501 NOT_IMPLEMENTED when protocol.searchAll is missing, and the protocol implements searchAll regardless of whether any search service slot is registered. The two producers answer the same question with two unrelated predicates.

Note the neighbouring keys in that same builder are already serveability-gated with a comment stating the rule ("the predicate is deliberately the SAME one that decides whether the route is advertised — what we advertise and what we claim cannot disagree"; chunkedUpload was moved onto it in #5672). search is one of the keys still on bare slot presence, and its route does not consult the slot at all — so the fix must pick ONE predicate for both ends: either derive the bit from typeof protocol.searchAll === 'function', or make the route honour the slot. Primary lane is the capability builder in packages/metadata-protocol; the seam reaches packages/rest.

Reproduction

  1. Boot showcase on a fresh isolated file DB (SqlDriver / better-sqlite3); authenticate as admin@objectos.ai.
  2. GET /api/v1/discovery → capabilities.search.enabled === false, services.search.status === "unavailable".
  3. GET /api/v1/search?q=audit → 200 with 5 real hits.

Source

Extracted from the QA run #7463 (framework a86db17).

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Claimed — domain:metadata seat.

    • Session: session_01GHTC2SvMXprxV9eEHqfbCP
    • Branch: claude/issue-7541-search-capability-predicate
    • Worktree: ../objectstack-7541 (dedicated)
    • File surface: packages/metadata-protocol/src/protocol.ts (the well-known capability builder — primary lane); the seam reaches packages/rest/src/rest-server.ts.

    Direction is not being re-litigated — the in-repo precedent settles it. The neighbouring keys in that builder are already serveability-gated, with a comment stating the rule outright ("the predicate is deliberately the SAME one that decides whether the route is advertised"), and chunkedUpload was moved onto it in #5672. search comes onto the same footing: one predicate feeding both the advertised bit and the route's refusal. ⛔ The dispatch explicitly forbids adding a fallback in the route to paper over a wrong bit.

    Two things the dev must determine rather than assume, and report either way:

    1. Whether the search service slot carries independent meaning — if a deployment can register a real search service for better search than searchAll's default, the slot is not noise, and the bit may need to express serveability while services.search keeps expressing which implementation. Grep for registrants and consumers before collapsing the two.
    2. What services.search.status should say once the capability bit flips true. The card measures only capabilities.search; the fix must not leave the two halves of one document disagreeing.

    The pin required is one that asserts agreement between the two producers, driving the real builder and the real route in one test — a test asserting only enabled === true would pass again the day someone hardcodes it.


    Generated by Claude Code

  3. added a commit that references this issue on Aug 11, 2026
  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #7577, marked ready and armed into the merge queue. 26/26 green.

    The patch round is resolved the right way. TypeScript Type Check was red because the new test file drifted @objectstack/rest's TEST_DEBT from 155 to 157, and that ledger is a ratchet that may only shrink. The fix did not raise the entry — scripts/check-type-check-coverage.mjs is absent from the diff — it gave the test a properly typed IHttpRequest instead. That is better than merely type-clean: enforceAuth runs before either predicate under test and reads method and path, so the previous partial literal was exercising the gate with undefined on both. The type error was pointing at a real fidelity gap in the harness, and closing it made the measured statuses the statuses a real caller gets.

    Both determinations the dispatch required were answered by measurement, not assumption:

    1. The search slot does carry independent meaning, and was correctly not collapsed. Registrants: zero, across both repositories. Consumers: one, and it is not the search surface (http-dispatcher.ts resolves the slot only to fill its own services.search entry). But CoreServiceName declares it "Search Engine (Elastic/Meili)" and ISearchService is a real index/query contract — so a deployment registering Meilisearch for better search than searchAll's LIKE-scan is exactly what the slot is for. The two keys answer different questions and the schema already said so.
    2. services.search.status stays unavailable, and the message changed instead. Flipping it to available was considered and rejected on the right grounds — no engine is registered, and claiming otherwise is the original defect pointed the other way. The REMEDY_DETAIL sentence keeps the pinned "No implementation ships" opening and adds which question the entry answers, following ui's treatment of the identical shape (fix(runtime): /ui discovery gates on the protocol service that serves it, not the vestigial ui slot (#4093) #4146). Because it lives in the one shared helper, the two producers cannot drift on it.

    The pin is shaped as the dispatch required — it asserts declared === served with both sides measured off the real producers, across three hosts that genuinely differ (200 / 404 / 501), plus an anti-vacuity case pinning [true,false,false] and [200,404,501]. A test asserting enabled === true would have passed again the day someone hardcodes the bit; this one will not.

    On the third producer — we reached the same conclusion independently, which is worth recording. packages/runtime's HttpDispatcher also emits capabilities.search, still from slot presence (!!searchSvc). I checked the route ledger and the dispatcher: that host mounts no /search route at all, so its false is honest for that host and discovery-schema-conformance.test.ts:245 correctly stays green — which is why leaving packages/runtime untouched was right, not an omission. The PR also names the latent half of it: that predicate would over-promise if a slot were ever registered on a dispatcher host. That is a real declared-≠-enforced gap in a third producer and it should not evaporate with this card, so I am filing it as its own finding rather than leaving it in a PR body.

    Verified independently of the report: no docs/adr/** and no content/docs/releases/ in the six-file surface, changeset present covering all three affected packages, Fixes #7541 as the first line, and the gate jobs' own conclusions (ESLint success, TypeScript Type Check success) rather than the aggregate.

    One process note, not a blocker: the <!-- os-dev-report --> comment never landed on this issue — the PR body carries the full report substance instead, which is the documented fallback, and the record now exists here.


    Generated by Claude Code

  5. claude commented on Aug 11, 2026

    @claude
    Contributor
    {
      "issue": 7541,
      "status": "done",
      "branch": "claude/issue-7541-search-capability-predicate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7577",
      "premise_still_valid": true,
      "summary": "`capabilities.search` derived from a registered `search` service slot while the route refused on `typeof protocol.searchAll !== \"function\"` — two producers, two unrelated predicates. Nothing in either repository registers that slot and the protocol implements `searchAll` unconditionally, so every REST host advertised `enabled:false` while `GET /api/v1/search` served 200s. The bit is now `typeof this.searchAll === \"function\"` (the route’s own 501 predicate), and the REST `/discovery` producer ANDs it with `api.enableSearch`, the flag deciding whether this server mounts the route — the same two-layer conjunction `transactionalBatch` already uses with `api.enableBatch`. No fallback was added to the route. Determination 1: the slot DOES carry independent meaning (`CoreServiceName` declares it \"Search Engine (Elastic/Meili)\"; `ISearchService` is an index/query contract), so `services.search` is left untouched and still reports which engine occupies it. Determination 2: `services.search.status` stays `unavailable` — nothing occupies the slot, and flipping it would be the same defect reversed — and the shared `REMEDY_DETAIL` message changed instead, the treatment `ui` already carries for the identical shape (#4146), so the two halves of the document stop reading as contradictory.",
      "tests": "REVERSE VERIFICATION — predictions written BEFORE any run (4 predicted RED, 2 GUARD, 6 expected-green).\nAll confirmed; no missed prediction. H1 = the metadata-protocol predicate, H2 = the rest mount-gate AND.\n\n  $ (revert H1) cd packages/rest && npx vitest run src/discovery-search-capability-agreement.test.ts\n      x agrees on the ordinary host ...  AssertionError: expected false to be true\n      x anti-vacuity ...                 AssertionError: expected [ false, false, false ] to deeply\n                                         equal [ true, false, false ]\n      Tests  2 failed | 2 passed (4)\n\n  $ (revert H2, H1 present) same command\n      x agrees on a host that does not mount the route (api.enableSearch: false)\n            AssertionError: expected true to be false\n      x anti-vacuity ...  expected [ true, true, false ] to deeply equal [ true, false, false ]\n      Tests  2 failed | 2 passed (4)\n\n  $ (full baseline, both reverted)  ->  Tests 2 failed | 2 passed (4)   [defect reproduced]\n  $ (patched)                       ->  Test Files 1 passed (1) / Tests 4 passed (4)\n\n  GUARD, green in both directions: the \"protocol without searchAll\" case (route 501, declared false),\n  and the enableSearch:false case at full baseline. Pre-existing and green both directions:\n  runtime/discovery-schema-conformance.test.ts:245, spec/core-service-provider.test.ts,\n  rest/discovery-schema-conformance.test.ts, scripts/check-service-providers.mjs.\n  Two objectql fixtures asserted search: {enabled:false} — i.e. asserted the defect — went RED as\n  predicted and were updated with the reason recorded inline.\n\nFULL PACKAGE SUITES (after pnpm build; an earlier run’s suite-level failures were unresolved\nworkspace deps, not assertions — every collected test passed even then):\n      spec                374 files /  9805 tests passed\n      metadata-protocol    71 files /  1051 tests passed\n      objectql            178 files /  3153 tests passed\n      rest                 83 files /  1345 tests passed\n      runtime             122 files /  1976 tests passed\n      client               21 files /   279 tests passed\n      => 17609 tests, 0 failures.\n\nTYPE-CHECK LEDGER — this PR first shipped RED, and I fixed it at source rather than raising the ledger.\n  CI reported: \"@objectstack/rest: TEST_DEBT records 155 raw tsc error(s), tsc --noEmit now reports 157 (+2)\".\n  Reproduced with the gate’s own measurement (packages/rest/tsconfig.json with the *.test.ts globs\n  dropped from exclude, then tsc --noEmit --pretty false -p on the result):\n\n    BEFORE: TOTAL 157   (ledger records 155)\n      discovery-search-capability-agreement.test.ts(112,32): error TS2345: Argument of type\n        '{ params: {}; query: {}; }' is not assignable to parameter of type 'IHttpRequest'.\n      discovery-search-capability-agreement.test.ts(127,29): error TS2345: Argument of type\n        '{ params: {}; query: { q: string; }; }' is not assignable to parameter of type 'IHttpRequest'.\n\n    Exactly my 2, both TS2345 (not TS7006). Fixed by building a real IHttpRequest typed against the\n    contract, rather than casting the handler to any as the neighbouring conformance test does.\n    Not cosmetic: enforceAuth runs BEFORE either predicate under test and reads req.method and\n    req.path — both undefined in the old literals — so the pin now measures the statuses a real\n    request shape actually produces.\n\n    AFTER:  TOTAL 155   (equal to the recorded entry; my file contributes 0)\n\n  $ node scripts/check-type-check-coverage.mjs --re-measure\n      check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 318.4s,\n      1785 raw tsc error(s) total, none above its recorded number.\n\n  The ledger entry was NOT raised, and no other package’s entry was lowered — the remaining\n  \"can be lowered\" lines in that output are informational and belong to #6376, not this PR.\n\nOTHER GATES (local): turbo run typecheck over the 4 touched packages 20/20; pnpm build 71/71;\n  check-service-providers OK; check-empty-changeset OK; check-adr-0087-registration OK;\n  docs-audit/check-audit-scope OK (0 docs affected; content/docs/releases/ untouched);\n  eslint --no-inline-config clean on every changed file.\n\nCI on 1043d0b (head of PR #7577): 24 success, 2 skipped, 0 failures.\n  TypeScript Type Check => completed / success\n  https://github.com/objectstack-ai/objectstack/actions/runs/31466192364/job/93699472987\n  The job log BODY is not fetchable from this container (the log redirect returns 403 through the\n  egress proxy), so the gate output quoted above is my local reproduction of the identical command;\n  the CI conclusion itself is read from the check-runs API.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed (pre-existing, no user-visible symptom today): packages/runtime HttpDispatcher.getDiscoveryInfo still derives capabilities.search from slot presence (hasSearch = !!searchSvc). Its false is honest for that host because it mounts no /search route at all, so discovery-schema-conformance.test.ts:245 stays valid — but the predicate would over-promise if anything ever registered the slot on a dispatcher host. PM independently reviewed and cleared this; packages/runtime deliberately untouched.",
        "NOT filed (pre-existing, no symptom): routes.search is never advertised by either producer (serviceToRouteKey has no search entry) even though SERVICE_CONFIG.search declares /api/v1/search. Harmless today because the SDK client.search() uses a fixed path."
      ]
    }

    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