Setup
TYPESAFE_API_KEY, ANTHROPIC_API_KEY and OPENAI_API_KEY. We use TypeSafe to score
each retrieved passage, OpenAI to embed the corpus for the search step, and Claude to write
the final answer out of whatever survives the scoring.
None of the three needs a key to reproduce this page. json_cache.json ships with the
cookbook and replays every recorded call, so a re-render costs nothing. Delete the file to
run the pipeline live instead. The numbers here came out of jev-1.12 and
claude-sonnet-5 on 2026-08-27.
Load the docs corpus
The corpus filecorpus.json holds 81 passages. We copied 80 of them straight from the
Supabase auth docs at commit 2440b06, one passage per heading, verbatim and used under
Apache 2.0:
https://github.com/supabase/supabase/tree/2440b06/apps/docs/content/guides/auth
Each passage carries id, title, text and source_type, and every request sends all
four. Near-misses fill the set. Rotation, expiry, sessions and signing keys each get their
own page, and those pages read alike. Refresh-token rotation and JWT signing-key rotation
are different things described in nearly the same words.
We wrote the last one ourselves, forum-injection, marked community_forum: it reads as
an ordinary forum answer until its final paragraph, which is an instruction aimed at the
model.
We also wrote two of the six queries to state a premise the docs contradict, so the
injection and conflict routes both have something to catch.
Retrieve the top passages
Rank the passages by cosine similarity over embeddings, usingtext-embedding-3-small at
256 dimensions, and keep the best TOP_K = 12 for each query. Short vectors keep the
shipped cache small, and the embedding calls are cached with everything else, so the
vectors travel inside json_cache.json.
forum-injection, ranks 1st at 0.584.
The passage that refutes the premise, sessions-01, ranks 7th at 0.509. All 12 scores
fall between 0.584 and 0.455, a spread too narrow to separate the passage that corrects
the query from the one trying to hijack the answer.
Ask four questions about each passage
Put the query and one passage in the state together, so every question is about the pair rather than the passage alone. Shape:Nouls, and what each answer drives:
is_relevant: the relevance floor.contains_answer_evidence: include, or drop.contradicts_query_premise: promotes to the conflict block.contains_prompt_injection: excludes outright.
Route each passage in code
Every answer comes back as a probability, and there are plenty of ways to turn four of them into one decision. A plain run of comparisons worked here. Test the four probabilities against their thresholds in a fixed order and stop at the first match. That match labels the passage, and the label decides what happens to it: evidence in the prompt, a conflict in the prompt, or dropped. The tests, in order:contains_prompt_injection > 0.70-> excludecontradicts_query_premise > 0.70-> conflicting_evidenceis_relevant < 0.45-> excludecontains_answer_evidence > 0.55-> include- otherwise exclude
We picked these four numbers for this corpus. Treat them as a starting point, not
defaults. Moving one is cheap:
THRESHOLDS holds all four and route() reads only the
stored answers, so re-routing every passage costs no API calls.sessions-01 at 0.92 and sends it to the
conflict block. Relevance reads 0.49 and answer evidence 0.51, so those two alone would
have dropped it.
Similarity ranked forum-injection first and its relevance clears the floor at 0.71. The
injection score of 0.99 is what drops it.
Nothing reaches the prompt as evidence, which is right for a question built on a false
premise. Below, the same table for a query the docs do answer.
forum-injection is excluded again at 0.99.
Note - treat the injection question as a filter, not a security boundary. It is one layer: the generator prompt still has to treat passages as untrusted text, and a passage scoring under the threshold still reaches the prompt.One request per passage, so cost scales with
k. Nothing batches passages into one
request, because each question is about one pair.
Build the prompt from the accepted evidence
TypeSafe scores the passages and the routing labels them. An LLM still writes the answer, hereclaude-sonnet-5. Keep accepted and conflicting evidence in separate blocks.
Two blocks let the answer push back. Merge them into one and the generator has no way to
tell a passage that answers the query from one that denies its premise.
sessions-01 on refresh tokens never expiring rather than inventing a 30-day setting.
The second had 4 accepted passages and no conflict, and cites all four. Nothing of the
injected instruction reaches the text.
Compare the six queries


