Access layer · verified root cause (3rd time's the truth)

NLWeb /ask: it wanted query.text, not a string

orank reported /ask as "endpoint not found." Two wrong guesses later (scanner bug ❌, cold-start ❌), scanning ora.ai itself revealed the truth: the NLWeb request body is {query:{text}} — and our route read query as a string, crashing with a 500 on the real shape.

How ora.ai gave it away

Sending our string format to ora.ai's own passing endpoint returned its error contract:

$ curl -X POST https://ora.ai/ask -d '{"query":"what is ora"}'
{"_meta":{"response_type":"failure","version":"0.55"},
 "error":"Missing query.text"}   # ← the canonical shape is query.text

The bug, reproduced

500POST /ask {"query":{"text":"…"}} — the NLWeb-canonical body orank sends → our route did query.trim() on an object → crash
200POST /ask {"query":"…"} — a bare string → worked. This is the only shape I kept testing, which is why I never saw the failure.
200POST ora.ai/ask {"query":{"text":"…"}} — the reference passing shape

The fix

- query = body?.query ?? body?.q ?? body?.question ?? query;
+ const bodyQuery =
+   typeof body?.query === "string" ? body.query : body?.query?.text;
+ query = bodyQuery ?? body?.q ?? body?.question ?? query;
testnew regression case: {query:{text}} → 200 with _meta + results, and SSE with Accept: text/event-stream. ask tests 8/8, tsc + biome clean.
verifyfinal proof = post-deploy orank re-scan (not an assertion). Fixes both nlweb-ask and nlweb-streaming (both 500'd on the object body).

The lesson

I blamed the scanner, then blamed cold-start — both without reproducing orank's exact request. The answer came from checking the reference (ora.ai) directly and reading its error. Verify the other side's actual behavior before diagnosing; never test only the convenient path.