# Claude ownership claim — 2026-08-11 Read `CLAUDE_HANDOFF_2026-08-10.md` first. Two items in it have since moved on (checked against the code and against live production on 2026-08-11): 1. The structured-claims refactor it describes as in progress is finished and shipped (`dfdbf52`, then `9c3acd0`). `pytest -q --ignore=tests/test_api.py --ignore=tests/test_live_datastores.py` = 219 passed at the start of this session. 2. Entailment majority vote (2-of-3) is no longer in the code. `df55af4` introduced it; `9c3acd0` replaced it with a single pass (`_ENTAILMENT_MAX_ATTEMPTS = 1`), with the reasoning in `_verify_entailment`'s docstring: repeating an identical temperature-0 prompt is a correlated retry rather than an independent vote. ## What this session is changing, and why All five items come from driving production (`https://realvuxbaro.me`) by hand plus direct `/api/chat` probes — measured, not inferred from docs. - **`rag/answer.py`** — availability failures during the entailment pass are currently recorded and shown as `unsupported_claim`, so a timing or outage problem reaches the user as a content failure. The failure taxonomy in `docs/current-rag-pipeline-audit.md` §4 keeps these separate. Reusing the reason codes that already exist and are already mapped in `apps/web/app/api/chat/route.ts` (`request_budget_exhausted`, `provider_unavailable`, `malformed_output`) — **no new reason code**, so the frontend mapping needs no change. Fail-closed behaviour is unchanged; only the label changes. - **`rag/answer.py`** — `_verify_entailment`'s `for _ in range(_ENTAILMENT_MAX_ATTEMPTS)` loop always returns on its first iteration, so raising that constant silently does nothing, and the unreachable `return False` after it returns a `bool` where every caller reads `.supported`. Making the single-pass intent explicit. - **`rag/agent.py`** — the pediatric dosing gate asks "Bé bao nhiêu tuổi và cân nặng bao nhiêu kg?" even when the user just gave one of the two. Reproduced 5/5 live ("18 ký", "18 cân", explicit "18 kg", "Trẻ 5 tuổi"). **The gate itself is NOT being loosened** — both fields stay required, which is clinically right here (the correct answer uses both an age band and a mg/kg rule). Only the question text becomes specific to what is actually missing. - **`apps/web/app/_components/ChatPanel.tsx`** — a hard 25s client abort against a backend whose own budget is 40s (`config.py:60`). Measured n=8 sequential: 2/8 exceeded 25s, and one of those was a **correct** `answerable`/grounded/2-citation reply at 25.1s that the user never saw. Caddy (`reverse_proxy web:3000`, no timeout) and the BFF (`signal: request.signal`, no own timeout) do not cap this, so the client constant is the only binding limit. - **`packages/ui/src/ChatBubble.tsx`** — chips dedupe by `chunkId` but the label is only drug+section+page, so several distinct chunks render as identical-looking chips. **Not** collapsing them by label: the click handler maps to a specific citation index, so collapsing would make real evidence blocks unreachable from the prose, and provenance is a hard guardrail. ## Second batch — guardrail gaps (same day) A guardrail review against `docs/architecture.md` found two things the design specifies that were not in the code. Both were implemented around the files currently carrying uncommitted changes, so nothing in that set was touched. - **Rate limiting — new `apps/web/middleware.ts`.** `/api/chat` is public, takes no credentials and spends Bedrock credit per call against a small personal AWS budget; the architecture assigns this to `api-gateway`, which is not built. 12/min and 120/hour for `/api/chat`, 120/min for `/api/suggest` (a local catalog lookup, no model call), keyed on the left-most `X-Forwarded-For` entry that Caddy sets, returning 429 with `Retry-After`. Counters are per process and in memory: correct for the single `web` container in production today, and the point at which that scales past one replica is the point this has to move to Redis or to the gateway. It is a cost/abuse guard, not authentication. - **Disclaimer — `rag/answer.py` only, wire-up still pending.** `GroundedAnswer` now carries `disclaimer: str = DISCLAIMER` as a dataclass default, so no response path can be constructed without it, including abstains and clarifications. Deliberately a module constant and never sent through the generator: a model-written disclaimer can be reworded or dropped, and would then need verifying like any other generated claim. ### Wire-up left for whoever next owns `routers/rag.py` `routers/rag.py` has uncommitted changes in this worktree, so the last step is left undone rather than edited around someone else's work. Two small changes complete it: 1. Add `disclaimer: str` to `RagQueryResponse` and pass `grounded.disclaimer` through when the response is built. 2. `packages/shared-types/src/dto/chat.ts` already declares `disclaimer?: string`, so the BFF only needs to copy it onto the message it returns — no type change required. Until step 1 lands, the guarantee exists in the domain object but is not yet visible to an API consumer. ## Files claimed `apps/ai-service/rag/answer.py`, `apps/ai-service/rag/agent.py`, `apps/ai-service/tests/test_grounded_generation.py`, `apps/ai-service/tests/test_agent.py`, `apps/web/app/_components/ChatPanel.tsx`, `packages/ui/src/ChatBubble.tsx`, and this file. Not touching `ingestion/`, retrieval adapters, `rag/service.py`, `rag/understanding.py`, or anything sparse/BM25 related — task #4 (real BM25 via Qdrant native sparse vectors) is still **not started**. ## Test-coverage context for review These areas start with little automated cover: no tests currently touch `missing_pediatric_age_or_weight` / `missing_population`, and the repo has no frontend test setup (no `test` script in `apps/web/package.json`, no ChatPanel/ChatBubble tests). A passing `pytest` run therefore is not on its own sufficient evidence here. Backend tests are being added alongside the changes, and the two frontend changes are verified by driving the real site.