You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Discovery advertises capabilities.search = {enabled:false} while GET /api/v1/search serves 200 with real hits #7541
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 searchAllregardless 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
Boot showcase on a fresh isolated file DB (SqlDriver / better-sqlite3); authenticate as admin@objectos.ai.
GET /api/v1/discovery → capabilities.search.enabled === false, services.search.status === "unavailable".
GET /api/v1/search?q=audit → 200 with 5 real hits.
Source
Extracted from the QA run #7463 (framework a86db17).
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:
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.
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.
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:
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.
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.
{
"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."
]
}
Activity
os-zhuang commentedon Aug 11, 2026
Claimed —
domain:metadataseat.session_01GHTC2SvMXprxV9eEHqfbCPclaude/issue-7541-search-capability-predicate../objectstack-7541(dedicated)packages/metadata-protocol/src/protocol.ts(the well-known capability builder — primary lane); the seam reachespackages/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
chunkedUploadwas moved onto it in #5672.searchcomes 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:
searchservice slot carries independent meaning — if a deployment can register a real search service for better search thansearchAll's default, the slot is not noise, and the bit may need to express serveability whileservices.searchkeeps expressing which implementation. Grep for registrants and consumers before collapsing the two.services.search.statusshould say once the capability bit flips true. The card measures onlycapabilities.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 === truewould pass again the day someone hardcodes it.Generated by Claude Code
fix(rest): build a real IHttpRequest in the #7541 pin instead of a pa…
os-zhuang commentedon Aug 11, 2026
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.mjsis absent from the diff — it gave the test a properly typedIHttpRequestinstead. That is better than merely type-clean:enforceAuthruns before either predicate under test and readsmethodandpath, so the previous partial literal was exercising the gate withundefinedon 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:
searchslot 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.tsresolves the slot only to fill its ownservices.searchentry). ButCoreServiceNamedeclares it "Search Engine (Elastic/Meili)" andISearchServiceis a real index/query contract — so a deployment registering Meilisearch for better search thansearchAll's LIKE-scan is exactly what the slot is for. The two keys answer different questions and the schema already said so.services.search.statusstaysunavailable, and the message changed instead. Flipping it toavailablewas considered and rejected on the right grounds — no engine is registered, and claiming otherwise is the original defect pointed the other way. TheREMEDY_DETAILsentence keeps the pinned "No implementation ships" opening and adds which question the entry answers, followingui'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 === servedwith 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 assertingenabled === truewould 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'sHttpDispatcheralso emitscapabilities.search, still from slot presence (!!searchSvc). I checked the route ledger and the dispatcher: that host mounts no/searchroute at all, so itsfalseis honest for that host anddiscovery-schema-conformance.test.ts:245correctly stays green — which is why leavingpackages/runtimeuntouched 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 nocontent/docs/releases/in the six-file surface, changeset present covering all three affected packages,Fixes #7541as the first line, and the gate jobs' own conclusions (ESLintsuccess, TypeScript Type Checksuccess) 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
claude commentedon Aug 11, 2026
{ "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
fix(metadata-protocol,rest,spec): one predicate for `capabilities.sea…
fix(runtime): dispatcher `capabilities.search` answers a stated `fals…