Route public RAG /search through the gateway — completion notes
Date: 2026-07-08
Spec: specs/2026-07-08-rag-search-gateway-design.md
Plan: plans/2026-07-08-rag-search-gateway.md
Status: implemented, gated green, deployed dormant + dormancy-verified
(commit cf49dbb). The gateway cutover (RAG_SEARCH_URL env) is a separate
operational flip — not done, awaiting Efran.
What changed
packages/orchestrator-types/tools.ts— additiveretryable?+
retry_after_sec?onToolResult(backpressure signal).tools/rag.ts—RagTooltakes an optionalRagIdentity;/searchnow
carries the CONVENTIONS §2 header set + a deterministicIdempotency-Key;
429/503degrade gracefully.orchestrate.ts—buildToolsbuildsRagToolfrom
env("RAG_SEARCH_URL", env("RAG_BASE_URL"))and threads the turn identity;
runOrchestratorpasses the nonce-free idempotency base
${sessionId}:t${turn}:rag.infra/env/public.env.example— documentsRAG_SEARCH_URL.
Report back (per the prompt)
- Seam chosen:
RAG_SEARCH_URL(falls back toRAG_BASE_URL), not an
overload ofRAG_BASE_URL. Reason:RAG_BASE_URLis also read by the
knowledge-reingestworker (/admin/ingest) and the admin search proxy —
neither of which the gateway's/search-only facade can serve. A scoped var
flips just the public retrieval path. - Gateway-headers helper reused: the same per-turn identity the LLM path
established —X-Tenant-Id: ahu-chatbot,X-Surface = req.surface,
X-User-Id = req.sessionId(the stable per-session id),X-Request-Id = makeTurnTraceId(req)(the turn's trace),X-Priority: interactive. Threaded
throughbuildToolsas aRagIdentityobject (no second identity pattern). - How the trace id + user id reached
RagTool:runOrchestratormints the
turn trace id once and passes it (+req.sessionId+ the idempotency base) to
buildTools, which constructsRagToolwith them — so/searchshares the
turn'strace_idand actor with the synthesis LLM call. Idempotency-Key:${sessionId}:t${turn}:rag:${sha256(query)[:16]}—
deterministic, built from the nonce-free base (not the trace id), so a retried
retrieval of the same query within a turn dedupes at the gateway.- 429/503 degradation:
RagToolreturnsok:false, status:"error"(so the
orchestrator proceeds without RAG context —evaluate.tsalready skips
status:"error", non-fatal) plusretryable:true+retry_after_sec(from
Retry-After).500/parse failures stay non-retryable. Mirrors the LLM
client'sRetry-Afterhandling — no new degradation flow invented. - Browser-direct (
NEXT_PUBLIC_DOC_RAG_API_URL) finding: the public Tanya
page does not call RAG from the browser —useAgentStream("doc")→
OrchestratorProvider→ same-origin/api/orchestrate→ server-side
RagTool. In public-web,NEXT_PUBLIC_DOC_RAG_API_URLis used only
server-side, in/api/threads/doc/*(thread history via ai-ahu-rag
/sessions). That is a separate server-side DMZ→Internal path the gateway's
/searchfacade does not cover — its own cross-zone question (needs a gateway
facade for/sessionsor a scoped path). Flagged; not changed. No
browser-direct exposure to fix.
Dormancy — verified on staging
Deployed ahu-ai-chatbot-public:cf49dbb (only image affected; ahu-public
recreated, agent-public untouched). Left RAG_SEARCH_URL unset → /search
still resolves to ai-ahu-rag:8110. A real public query
("Apa syarat mendirikan PT perorangan?") streamed a full grounded answer with a
references event — retrieval works byte-identically; the additive X-*
headers are ignored by the RAG service. Container healthy, 0 restarts.
Tests
tests/orchestrator/tools.test.ts (+5): §2 header set on /search (trace id =
turn's, per-session user id) + body verbatim; deterministic Idempotency-Key
(same query → same key, distinct across queries); identity-less call omits
per-actor headers (dormant); 429 degrades to retryable+retry_after_sec
without throwing; 503 retryable, 500 not. Gate: pnpm check green
(public-web 133 / internal-web 65 / streams 17 + typecheck + conventions).
Operational flip (Efran) + post-flip verification
Set RAG_SEARCH_URL=http://192.168.83.20:8200 in infra/env/public.env and
recreate ahu-public. Then run one public Tanya query and confirm in the
observatory an operation=search, engine=ahu-chatbot, surface=public event
sharing the same trace_id as that turn's synthesis LLM call — retrieval
and answer stitched into one trace. (This is a cross-zone routing change — left
to Efran's go-ahead.)