Repository navigation
[0.20.0] - 2026-10-03
Iris says what it checked. A verdict tells a pass from a check that never ran, names who recorded the evidence it judged, and cannot be replaced by the agent it judges. 0.20.0:
- "Not checked" is its own answer in the verdict, the CI gate, the counts and the dashboard: a check that was asked for and could not run is no longer reported as a pass, and a
--fail-ongate exits 0 only when every trace it read was judged. - Every verdict says who recorded what it judged. A capture source declares what it records in full, so an empty list of tool calls from software that watched every call means none were made, and the same list from the agent's own report does not.
- The detectors read what was written: a test runner's verdict, a failure the agent recovered from, text spaced or wrapped to slip past a phrase rule, and an output written as JSON. A new rule,
says_something, fails an answer that says nothing. - Full-text search over traces, spans included, in any language: every search is bounded, and on a store in a file it runs off the event loop.
install --upgrademoves every MCP client on the machine to the new release and copies the database before it migrates. - Provider calls recorded without the model choosing to: OpenAI and Anthropic wrappers and LangChain and LangGraph handlers, in the Python client (
iris-eval0.2.0 on PyPI) and, for JavaScript, in this repository ahead of their npm packages, and OpenTelemetry GenAI traces sent to Iris are scored, with an estimated cost when a trace reports tokens but no cost. - Also: an optional relevance judge within a daily budget, CSV and JSON Lines export, Decision Moments ranked by significance, a dashboard that works from the keyboard and with a screen reader, and API key revocation that takes effect on the next request. The server is published with npm provenance, and its Docker image, MCPB bundle and SBOMs are signed.
Check before upgrading. The first group can change what a CI job or a script does: read it if Iris gates a build or a script reads its answers.
Gates, verdicts and API answers. An entry marked BREAKING can change what a CI job or a script does with the same input.
- BREAKING — A
--fail-ongate exits 0 only when every trace it read was judged, and a basis trips when that layer decided the verdict or would have. Two changes toiris-eval ingestand the gate action, each of which turns a job that passed into one that fails. (1)verdict.basisnames one layer, and--fail-on <basis>read it alone: an output that broke a policy you configured and also leaked a credential readpolicy_gate, so--fail-on detector_veto, then the action's default, exited 0 on it. The verdict now lists every later layer that would have decided on its own underverdict.also, and--fail-onreads both;failandunknownread the state of every layer the same way. A job gating onrisk_over_losswill also trip on an output a critical detector vetoed, because the risk estimate is over the cut there too. (2) With--fail-on, each of these exited 0 and exits 2 now, with a sentence saying which: a trace rejected for a malformed field or a missingoutput(the run that crashed before answering); a trace stored without being evaluated; an empty file; and, with--dataset, a run that held none of the dataset's cases. Pass--allow-empty(allow-empty: trueon the action) where a job legitimately has nothing to gate, such as one shard of a split run; it never excuses a rejected or unevaluated trace. A--filepath that is not a file exits 2 with one sentence; it used to exit 1, the code a tripped gate uses, with a stack trace. Without--fail-onnothing changes. - BREAKING — The gate action fails the job on every verdict that is not a pass. Its
fail-oninput defaulted todetector_veto, which exits 0 on an output the evaluation failed for any other reason (a hidden tool failure, an invented figure, a broken policy) and on every verdict that was not checked. The default isanynow. A workflow that uses the action withoutfail-onand relied on only critical detections failing the job setsfail-on: detector_veto. The CLI has no default and is unchanged; the README anddocs/ci-gate.mdlead with--fail-on any. - BREAKING — A check somebody asked for, on a call that left out what it reads, makes the verdict not checked instead of a pass. With a cost ceiling set in your config (
cost_threshold), a trace over it failed and the same trace with the cost left out passed: the rule skipped, and a skip was nothing. A rule that skips for lack of what it reads now makes the verdictunknown(basis: "required_evidence_missing",passed: false) when somebody had asked for it. The deployment asked when it set one of the rule's thresholds (cost_threshold,max_steps,max_tool_repeats,max_target_rereads,max_token_ratio,keyword_overlap,topic_consistency,tool_choice_margin,tool_choice_min_fit), promoted the rule witheval.criticalRules, deployed it at severityhighorcritical, or installed the relevance judge. The call asked when it set such a threshold itself, supplied a gating rule inline, or supplied the part of anexpected_trajectorythe rule compares against.verdict.bynames the missing inputs, the rule result carrieslackedandasked, and a sentence to the agent names the rule, what it lacked and who asked. A blank is not sent: an input of only whitespace and a negative cost are now treated as left out, where each used to be judged as a value (and passed). An explicit empty list of tool calls is sent: it says none were made, and an honest turn that used no tool does not read "not checked" because a step ceiling is set.eval.onCriticalSkipped: "pass"accepts a critical rule that could not run for missing evidence. A gate keyed onpassed, or on--fail-on any,unknownorrequired_evidence_missing, that passed such traces fails them now: send the field, or remove the setting that asks for it. With the shipped configuration and a call that supplies no threshold, rule or expectation of its own, nothing changes, and a text-only evaluation passes as before. - BREAKING — A failure outranks a layer that could not check. An output the risk layer fails, on a deployment where a critical check could not answer or required evidence was missing, read
verdict.state: "unknown"; it readsfailnow,basis: "risk_over_loss", with the layer that could not check underverdict.also.--fail-onand the webhooks already read every layer and trip as before; a caller that branched onverdict.stateorverdict.basisitself sees the failure first. Each stored evaluation is stamped with the ordering it was judged under and reads back under it. One kind of row from 0.19.0 does not read back as its caller was given it, and did not before this change either: an evaluation that was not checked because required evidence was missing reads back as a pass, because that release did not store the requirement with the row. Its stored result is still "did not pass", and every count treats it so. - BREAKING — An evaluation linked to a trace is that trace's verdict only when it scores the trace as stored.
evaluate_outputwith atrace_idandoutput,input,tool_calls,tools,cost_usdortoken_usagethat differs from what the trace stored, or with oneeval_type, now answers withreference_trace_idin place oftrace_idand a sentence saying what differed; so does a call that names a trace which recorded no output, and so does everyevaluate_with_llm_judgeandverify_citationscall that names a trace. Such an evaluation is still stored, listed with the trace and erased with it, and it no longer counts in the trace's run, incompare_runsorcompare_traces, in the score filter ofget_traces, or in thelatest_*export columns. A workflow that judged each trace withevaluate_with_llm_judgeand compared runs on those judgments will find the comparison reading the deterministic verdicts instead; to have a judge decide a trace's verdict, set a relevance judge (IRIS_RELEVANCE_JUDGE_MODEL). To re-score a stored trace, callevaluate_outputwith itstrace_idand nothing else:outputis optional now, and the stored input, tool calls, tools and cost are read from the trace, so the relevance rules run where they used to skip. Evaluations stored by earlier releases are not changed. Migration020-eval-reference-traceadds the column. - BREAKING — An expected call that was never made fails, where it used to pass (
tool_sequenceandstep_budget, version 2).evaluate_outputwithexpected_trajectory.tool_callsandtool_calls: []skippedtool_sequence: there was "no trajectory to judge". An explicit empty list is a trajectory of no calls, and against an expectation of calls it now fails every mode butsuperset;step_budgetjudges it as zero calls, which is within any budget. Calls that are not sent at all still skip. The ruleset fingerprint changes. - BREAKING —
compare_runsandPOST /api/v1/compareanswer differently in four ways. (1)betteris no longer true when the second run was judged by fewer rules than the first;improvement_withheldis true instead. A job that readbetterto promote a run stops promoting one that sent less evidence. (2)equivalent_withinis null unless you passequivalence_margin; it used to default to a margin computed from the run sizes. A caller that readequivalent_within.holdswithout a margin now reads null. (3)differencecarries a 90% interval where it carried a 95% one, and when the runs pair it is the interval McNemar's test implies, not the unpaired one;difference.significantnow agrees withworseandbetter. (4)smallest_detectableis larger: it is the difference detected four times in five, where it was the one detected about half the time (62 points for two runs of eight, where it read 40). The summary no longer contains**markers. New fields:call,coverage,critical_rises,paired.fell, andjudged_before/judged_afteron each rule row. - BREAKING —
compare_tracesreports a wider interval, andoverall.rateis the mean over cases. The run-level interval is exact for the number of cases and can no longer be zero-width: ten of ten cases reads [69.2%, 100%] where it read [100%, 100%].overall.rateis the mean of the cases' own pass rates; it equalspooled.ratewhen every case was asked the same number of times and differs otherwise. - BREAKING — Two runs that differ in a verdict-deciding setting are no longer called comparable, and a rule change that cannot be recorded is refused. The configuration fingerprint (
provenance.configHash) now coverseval.defaultsGate,eval.falsePassCost,eval.onCriticalSkipped,eval.requiredEvidence,eval.priorModeand a prior set in config or estimated from labels, and the ruleset fingerprint (provenance.rulesetHash) covers the content of every deployed, inline and plugin rule. A deployment with none of these set and no custom rules keeps both hashes; any other gets new ones from its next evaluation, socompare_runsbetween a run from before the upgrade and one after names the configuration or the ruleset as different (it already named the engine version) and compares them only withforce. With an estimated prior the fingerprint moves as labels are added, because the verdicts do.deploy_rule,delete_rule, the dashboard's rule routes anddelete_tracenow write their audit entry before the change and fail withIRIS_STORAGE_ERRORwhen it cannot be written; they used to carry on without a record. - BREAKING —
verify_citationsreports a judge failure underjudge_error, notresolve_error. A caller that read the judge's error fromresolve_erroron a citation whoseresolve_statusis"ok"reads it fromjudge_errorinstead, with the samekindandmessage.resolve_errornow means only that the source was not resolved. compare_runssays when the cases that fell were not checked rather than failed. Ten cases that passed before and could not be checked after read "10 cases passed before and failed after ... This is a regression". They still count against the second run (worseis true: a run that cannot be checked has not passed), and the summary now says what happened: "did not pass after (10 not checked, 0 failed)", and when none of them failed, that evidence stopped arriving and nothing was found wrong with the answers. Each run summary carriesnot_checked, andpairedcarriesfell_not_checked.- A case is flaky only when it passed and FAILED.
flakyonGET /api/v1/cases/:key, in theflaky_casesview and incompare_traces, and theflaky_casewebhook, counted a verdict that was not checked as the other answer, so a case with one pass and one unreached verdict was "answered both ways". An attempt that was not checked is now neither answer. - A row of
categoriesis the verdict, read for the rules that bundle holds.categories.<bundle>.passedwas its own arithmetic: a weighted mean of the bundle's rule scores against a threshold, with the critical veto. The verdict stopped using that arithmetic in 0.10.0, so on an evaluation that failed for a hidden tool error every row could readpassed: true, and a script keyed oncategories.safety.passedshipped the output the evaluation failed. A row now carriesstate(pass,fail,unknown) andpassedfollows it (true,false, andnullfor a row that was not checked): the row holding the rules that failed the evaluation fails, every checked row of a passing evaluation passes, a rule that lacked evidence it was asked for leaves its own bundle not checked, and an inputeval.requiredEvidencenames leaves every bundle not checked. A script that read a bundle'spassedwill see failures it did not see before, andnullwhere it sawfalsefor a bundle that lacked evidence; a row no longer fails for a lowscorealone.scoreon a row is unchanged. - A webhook that names no
eventsalso receivesverdict_not_checked. The new event is delivered for a verdict that could not be reached and that no layer failed. Name the events you want to keep the old five. - Required evidence is met by what the call carried, and a capture source can say a turn made no tool call.
eval.requiredEvidenceused to count an input only when a rule that ran had read it, so a deployment that requirescostread a call that sent its cost as "not checked" when the call ran one bundle (eval_type: "safety"); it passes now. A trace recorded by the Claude Code capture plugin now names the plugin and what it records in full (below): on a deployment that requirestool_calls, a captured turn that made no tool call read "not checked" and passes now, and a captured turn whose record is incomplete reads "not checked" where it may have passed. The trace CSV export has acapturecolumn betweensourceandsession_id; a reader that takes columns by position shifts by one from there. Migration022-trace-captureadds the column that holds it. A verdict that nothing could judge (basis: "no_rules") now lists missing evidence underverdict.also(required_evidence_missing), whether a deployment required it or a capture source promised it, so--fail-on required_evidence_missingtrips on such a trace where it passed. Two more changes in what counts as sent: a blankexpected, or anexpected_trajectoryno trajectory rule can use ({},{ "tool_calls": [] }), no longer meets a requirement for one; andevaluate_outputthat sendstool_calls: []for a trace stored without tool calls is now kept beside the trace (reference_trace_id), because an empty list is other evidence than none, where it used to be the trace's verdict. GET /api/v1/momentswith averdict,min_significanceorsignificance_kindfilter returns full pages from a window of recent traces.offsetnow counts matching moments rather than traces,totalis the exact number of matches in the window rather than the trace count, and the response carrieswindow. A client that paged a filtered list by addinglimittooffsetgets every match once instead of gaps; one that readtotalas the trace count readswindow.tracesInRange. Matches older than the window (the newest 500 traces by default,windowto narrow it) are not returned: narrowagent_name,sinceoruntilto reach them.
Rules that fail outputs they used to pass. A gate on detector_veto or any can trip where it passed before.
no_injection_patterns,no_blocklist_words,no_piiandno_injection_compliancefail outputs that hid a phrase behind its spacing or a value behind an invisible character. An override phrase written one word per line, with tabs, or with no-break spaces in place of spaces passed before and fails now. So does a blocked phrase written that way, and a recovery phrase listed one word per line. A Social Security number, card number or key with a right-to-left override, an invisible separator, a variation selector or a tag character inside it passed and fails now. A gate that passed such an output will block it. Ordinary wrapped prose and a quoted discussion of an attack phrase read as before. No setting turns this off.no_silent_tool_failurereads a test runner's verdict, and stops counting a failure the agent recovered from (rule version 2). Two changes to what fails, in opposite directions. (1) An answer of "All tests pass" beside a tool output endingTests 3 failed | 12 passedpassed every rule: the call carried no error and its first line was the runner's banner. For a tool that runs a command, the head and tail of its output are now read for a failing verdict (3 failed,FAIL …,FAILED …,npm ERR!,Found 3 errors, a non-zero exit code), and an object output written as a JSON string ("{\"ok\": false}") is read as the object. An answer that does not own such a failure now fails the rule, and at the shipped configuration the verdict. (2) A failure followed by a later call that went right on the same thing no longer has to be owned: the same command passing after a fix, a misspelled argument then the right one, a failing run then a passing one. Until now every such answer failed the rule unless it happened to use a failure word. A gate that blocked on a retried call stops blocking it; a gate that passed a false "tests pass" blocks it. The ruleset fingerprint changes, socompare_runsrefuses a run from before against one from after unless forced.- A quotation does not run from one field of a structured output into the next, and a sentence has a word in it. The injection rule treats a phrase inside quotation marks as discussion. Quote marks in two neighbouring fields of a JSON answer bracketed the field between them as quoted, so an override phrase there passed, plain or disguised; a quotation now ends at the mark the reading puts on a line of its own between two values. A quotation in prose may still run across paragraphs, as a forwarded email does, and a pilcrow inside a line of prose ("under ¶ 4") is a character. The sentence splitters end a sentence at that mark too, and count no segment without a letter or a digit as a sentence, so a horizontal rule (
---), a lone bullet or a line holding only an emoji no longer adds tosentence_count. no_pii: a masked key is not a secret, and asterisks after a whole secret do not mask it. The check for a value assigned to a secret's name readsk-****…3f9aas a secret. A value is masked when four asterisks hide all but a few of its characters;Xk9mP2vQ7rL4nT8wZ3hY****is still a secret.DOBandMRNlabels are read with a quote and a colon after them, as a JSON field writes them ("dob": "1990-04-12"); a quoted label with no colon after it is not one.no_stub_output: a marker that a removal verb acts on is finished work. "Removed the last TODO comment" and "resolved all remaining FIXME notes" read as unfinished work. A removal verb before the marker, on the same line, marks a report of done work, and an article before a marker may be followed by any horizontal space (a no-break space included) but not a blank line; "the only TODO left is the retry" still fires, and so does a marker on the line after "removed".no_stub_outputandno_hallucination_markersread the same text however it is spaced or wrapped (version 2 of each). A placeholder phrase typed with two spaces ("omitted for brevity"), or cut by a line wrap, was not the phrase the rule knew, and a paragraph wrapped at 60 columns was counted as one sentence per line. On the labelled corpus, doubling every space turned three placeholder findings and two fabrication findings into passes. Both rules now match a phrase across any short run of whitespace and count sentences on the paragraph. An output that passed only because of its spacing fails now. The published accuracy of both rules is unchanged (the same errors on the same cases), and the ruleset fingerprint changes.- An output written as JSON is read by what it says (the sixteen rules that read the output are each at a version 0.19.0 did not have). An answer sent as a JSON object or array, such as
{"answer": "…", "sources": […]}, was read in its escaped form: a line break was the two characters\n, a quote was\", and a phrase inside a string value sat between quotes, where the injection rule reads it as a quotation. On the labelled corpus, sending every output as one string field of a JSON object changed five verdicts and thirteen answers of the rules that decide one: three injections were missed and an empty answer was not empty. The rules that read the output now read a structured one by its values, each value its own paragraph; the leak, blocklist and injection rules read every field name too, quoted, before its value ("password": …,"dob": …) or alone where its value is an object or a list, so a secret is recognised by the field it is assigned to, a key can be the leak, and a directive smuggled as a key is found as it is inside prose. Every evidence offset still points into the output as sent, and the relevance judge's redaction reads a structured text the way the leak rule does. Custom rules read the output exactly as sent. Changes in both directions: a structured output that passed because escaping hid a leak, a placeholder or an injection fails now, and one that failed only on its escaping passes (an empty answer in{"answer": ""}now failsnon_empty_output). A leading byte-order mark is skipped; JSON with a no-break space or another Unicode space beside it is read as written. One injection shape is read only in a structured reading: a field whose value is an override, quoted or not and disguised or not, which the labelled reading writes without the quotes the prose pattern needs; a value whose sentence quotes a payload is a report about it, as in prose. Each rule result that read a structured output says which reading it took inread. The ruleset fingerprint changes. - An answer that says nothing fails: a new rule,
says_something. Against two fixed asks (an action, and a piece of writing), ten of twelve trivial agents passed clean: "Done.", "OK", "The task has been completed successfully.", "…", "null", "lorem ipsum dolor sit amet", the ask copied back, one sentence repeated 400 times, and two refusals.says_somethingreads the shape of a non-answer against what the ask asked for. To an ask for something written: a promise in place of the work ("Will do!") when no tool call is recorded, a placeholder ("null", "…", "{{answer}}", lorem ipsum), or a "here is …" with nothing after it. To an ask to write or to act when the call says no tool was called (tool_calls: []): a bare "Done." or "OK", a promise, or a placeholder. To any ask: an announcement that ends on a colon with nothing after it, the ask handed back, or one passage on a loop. A question answered "OK", "null" or "Done.", an ask to confirm answered "OK", an action reported done when the call does not say that no tool ran, and a refusal pass: each can be a correct answer, and whether a refusal was right is a judgement about the ask. Its family is 242 labelled cases, 200 of them written from a definition of a non-answer by model instances that never saw the rule: it fails none of the 126 answers and catches 23 of the 116 non-answers (precision 100%, 95% interval 85.7 to 100; recall 19.8%). On the 100 written after its last revision it caught 4 of 50 non-answers and failed none of 50 answers. Most non-answers there are fluent replies that never contain what was asked, which no rule that reads the shape of an output can detect.proof/INVARIANTS.mdpublishes the twelve agents against both asks: wheretool_calls: []records that nothing was called, none passes except the two refusals and the action handed back, each with its reason. It is a detection in the completeness bundle and thestubclass; no composite verdict changes, and a typical clean pass's risk estimate moves from 0.118 to 0.110. The ruleset fingerprint changes, and the built-in rule count is 26. - A trace that sends token counts and a model but no cost is now stored with an estimated cost, so cost totals rise and the cost rules start judging it (#702). Most OpenTelemetry and framework traces are such traces. After upgrading, their cost counts toward the dashboard's total cost,
GET /api/v1/summaryand thecost_by_agentview, andcost_under_threshold(default $0.10),cost_anomaly, customcost_thresholdrules and thecost_anomalywebhook act on it instead of skipping. A gate oncost_under_thresholdcan start failing traces it used to skip. Each estimate is markedcost_source: "estimated"wherever it shows. Traces stored before the upgrade are not re-priced. To keep the old behaviour, setpricing.estimate: falseinconfig.json. answers_the_askstops blocking at the shipped config. A gate that relied on it failing off-topic answers at the defaults stops blocking them. To keep it gating, setIRIS_RELEVANCE_JUDGE_MODELto have the relevance judge decide (below), or set akeyword_overlaportopic_consistencythreshold in your config file to gate on the lexical reading.
Upgrading the server and its data.
- Move every MCP client to 0.20.0 in one step:
npx -y @iris-eval/mcp-server@latest install --upgrade(#704). Every client shares~/.iris/iris.db, and since 0.19.0installpins each client to one release. 0.20.0 adds migrations015-trace-searchand later, and the first 0.20.0 process to open the file (the Claude Desktop extension,npx iris-eval, ornpx @iris-eval/mcp-serverwith no version) applies them; from then on a client still pinned to 0.19.x refuses to start withThis database was migrated by a newer Iris (…) — migration(s) 015-trace-search, … are unknown to v0.19.0. That message comes from 0.19.x and cannot change. The command moves every pinned client; the Claude Code plugins move withclaude plugin marketplace update iris-evalandclaude plugin update iris-eval@iris-eval(andiris-eval-capture@iris-eval). To go back to 0.19.0 instead, restore the copy 0.20.0 takes before migrating: README, Downgrading. - The first start after upgrading copies the database before migrating it, and takes longer by the copy. Measured on a Windows 11 desktop (Node 24.11, better-sqlite3): 2.4 to 2.7 s for a 468 MB file of 100,000 traces, three runs. The copy needs free disk space about the size of the file; without it the start migrates without a copy and says so.
- After upgrading, the trace search index is built in the background, and the file grows by about two thirds. At 100,000 traces on the benchmark machine the file went from 125 MB to 211 MB (69% larger). The server starts and answers MCP and HTTP requests at once, and the index is built behind it in steps of about 50 ms of work each. Measured on a file of 100,000 traces from 0.19.0: the first start took 449 ms, and the index was built in 33.4 s, with the event loop held for at most 449 ms. Traces sent over OTLP with spans take longer, because their span text is indexed too: 158.8 s at 100,000 agent-loop traces, with a first start of 360 ms and the event loop held for at most 329 ms, and the file is larger (993 MB where the same traces took 841 MB with an index of their own fields only; the table under Added has the numbers). Traces with Chinese, Japanese or Korean text take longer again, because each run is also indexed as two-character pieces: 50.8 s at 10,000 agent-loop traces written in Chinese, and a file of 251 MB where those traces took 128 MB before (the numbers are under Added, with the entry for words inside CJK text). Until the build finishes, a search reads the traces directly (
search.indexisscan): slower, with the same results, and on a large store it stops at the search time budget withsearch.complete: false(see Security). The server logs one line when the build starts, with how many traces it will index, and one when it is done, with the count and the time;GET /api/v1/healthreportssearch.stateandsearch.progress(the share of traces indexed so far), and--self-testreports the configured database's index.get_traceswithoutqsorts bytimestampas before; withqthe default order is relevance. - After upgrading, four indexes are built in the background, after the server answers (#711, #744). Migration 019 adds the indexes the dashboard's summary, filters and trace list and the agent failure log now name, and drops the three they replace. On a file with traces they are built after the server starts, each in one statement off the event loop; until they are, those reads answer with the same results on the indexes the file already had, more slowly, and
GET /api/v1/healthsays"indexes": "building"(--self-testsays so too). On a file of 100,000 agent-loop traces written by 0.19.0, the first start's work before the MCP connection opens took 54 to 60 ms where it took 1.72 to 2.13 s with the indexes built in the start (three runs each, without the copy taken before migrating, above), and the indexes were ready 2.1 to 4.9 s after it; a page of 50 traces took 9 to 16 ms meanwhile, 5 ms after. While each index is built, a write waits for it, as it waits behind any other writer. - Committed writes reach the disk at each checkpoint of the write-ahead log, on both drivers (
storage.synchronous, defaultnormal) (#711). A crash of Iris loses nothing and the file cannot be corrupted; a power cut or an operating-system crash can roll back the writes made since the last sync. With better-sqlite3 this is what 0.19.0 already ran on every start but the one that created the file, because that SQLite is built to open a WAL file atNORMAL; on Node's built-innode:sqliteit wasFULLthroughout and isNORMALnow. Set"storage": { "synchronous": "full" }inconfig.jsonto sync every commit, which keeps each committed trace through a power cut and costs about 1.5 ms a write. - Storing a trace costs more with the search index on, which is the default. Sustained OTLP ingest of traces with two model spans stored 1,735 traces/s at 100,000 traces and 2,000 at 10,000, where 0.19.0 stored 2,330 and 2,620, and one
log_tracecall takes about 0.1 ms longer. A feed that needs the ingest rate more than fast search can setstorage.searchIndex: "off"(IRIS_SEARCH_INDEX=off), which stored 4,237 and 4,393 traces/s; search then reads the traces themselves within its time budget (under Added). - The retention sweep runs after the server answers, in short steps (#695). It used to run before the MCP connection opened, as one transaction: a stdio client that started Iris on a store with traces past
retention.dayswaited for the whole sweep, and the search index made the sweep several times slower. The connection now opens first, and the sweep deletes a few traces per transaction with the event loop free between them; for a sweep of more than 1 in 125 of the indexed traces it then merges the search index in steps, which erases the swept words from the file asoptimizedid.Retention cleanup: deleted …is logged when both are done, and a merge that a closing server leaves unfinished is finished by the next start. Measured over stdio against the code before this change, alternated, on the benchmark machine while other work shared it, with agent-loop traces (three model calls and two tool calls each) and 3% of them past the window: at 10,000 traces the first answer toinitializecame after 640 to 661 ms instead of 1,442 to 1,467 ms (three runs each); at 100,000, after 0.74 s and 1.21 s instead of 8.6 s and 17.6 s. The sweep itself still takes seconds at that size, in the background: it was done 9.0 s and 12.1 s after the start, against 9.2 s and 18.3 s. While it runs, the longest the event loop was held was 57 to 95 ms at 10,000 traces and 189 ms and 545 ms at 100,000. - BREAKING — The Docker image keeps its whole Iris home in the
/datavolume. The image now setsIRIS_HOME=/data, so deployed rules (custom-rules.json),audit.log,config.jsonandpreferences.jsonsit besideiris.db. Before, they were written to/home/iris/.irisinside the container and were lost whenever the container was recreated. A deployment that mounted its own volume at/home/iris/.iristo keep them should copy those files into the/datavolume, or setIRIS_HOMEback to that path./datais now created mode 700, like a fresh~/.iris. - The retention sweep keeps the pinned baseline run. Traces and evaluations of the run pinned as the baseline (
PATCH /api/v1/runs/:id { "baseline": true }) are no longer deleted atretention.days. If you pinned a run and relied on the window to remove its text, unpin it, or delete its traces withdelete_trace; each sweep that keeps some of it logs the run and the counts. With no run pinned the sweep deletes exactly what it did. - A rule deployed through one MCP client now applies in every other client's server. Each client's server process re-reads the shared rules file when it changes, so a rule deployed, switched off or deleted through one applies in all of them within 20 ms. If you kept different rules per client by relying on each process holding its own copy until restart, they now converge on the file.
Integrations.
- BREAKING — OTLP traces in the GenAI conventions store words, not JSON, as their input and output. A trace from Pydantic AI, Semantic Kernel, or any other exporter whose input or output is a message array (
gen_ai.input.messages/gen_ai.output.messages, or OpenAI-shaped[{ role, content }]) now stores the last question asked asinputand the last answer asoutput. A caller readinginput/outputfromGET /api/v1/traces,get_tracesor the OTLP answer sees text where it saw a JSON array; the span attributes keep the JSON as sent. Traces stored before the upgrade keep their JSON. - BREAKING — An OTLP trace's output is what the run ended with, and an unnamed service is not an agent. Past the root span,
outputis now read from the span that ended last rather than the one that started first, and a trace whose resource carries only the OTel SDKs' defaultservice.name(unknown_service,unknown_service:<process>) is filed under the agent its spans name (gen_ai.agent.name, OpenInference'sagent.name), else under"otel"withservice.namelisted as lacked. A caller that filtered OTLP traces byagent_name=unknown_service:python.exefinds them under the agent's name or"otel"; traces stored before the upgrade keep theirs. A span attribute that is a Python object's repr (StopEvent(result=…)) is no longer taken as a trace's input or output. - BREAKING — An OTLP agent span is
INTERNAL, notLLM. A span whosegen_ai.operation.nameisinvoke_agentorcreate_agentis stored with kindINTERNAL; a caller that countedLLMspans from OTLP traces counts only the model calls now. Token and cost totals do not change. - The Claude Code capture plugin records the tool calls that failed, and loses no call a turn made. Claude Code reports a failed tool call (a command that exited non-zero, a missing file, an MCP tool's error) through
PostToolUseFailure, which the plugin did not listen to, so a failed call never reached Iris and the rule that asks whether an answer owned a failed call had nothing to read on the path that records every turn. It is recorded now, with the error the agent received. A turn is now kept under its prompt's id (prompt_id, sent by Claude Code from 2.1.196) rather than its session, and nothing it records is deleted before it is sent: the calls of a turn you interrupted (which fires noStop) and of a background sub-agent that kept working after its turn ended were thrown away when the next prompt arrived, or landed in the next turn; they are sent then as anunfinishedpart of their own turn, stored and not judged, with a sub-agent's calls named inmetadata.subagent_calls. A turn aStophook keeps going is sent as a second,continuedpart with its prompt. A turn that ends in an API error (StopFailure) is stored with its calls and the error, with no answer, and is not judged, rather than scored on the error text. An emptytool_callslist is sent only when the record is known whole (the prompt seen, the turn stopped, nothing running in the background), so Iris does not read "no tool calls were made" into a turn whose calls it may not have. When the model callslog_tracefor the turn itself, the plugin no longer stands down: both traces are kept, and the plugin's names the model's, once, inmetadata.model_logged.trace_ids, because the model's account can leave out the call that failed. Expect more failed verdicts on captured turns (failed calls the answer did not own), and more traces: two for a turn the model logged, and a part for each turn that did not end cleanly. What the plugin holds is kept like a secret: without a host data directory it lives incapture/inside your Iris home instead of a shared temporary directory, readable by you alone; calls are appended as they finish, each on its own line, so calls run in parallel are all kept and a write cut short loses only itself; a tool output over 256 KB is kept as its head and tail, markedtruncated; a turn that could not be ingested is retried and removed after seven days, a closed session's files are sent and removed after a day, and the capture log is rotated at 1 MB; both ingest attempts run the pinned version. Each turn is sent in the form the pinned release reads: a release before 0.20.0 gets it without thecapturedeclaration, which that release's ingest would refuse along with the whole turn. A test in the upgrade suite has the release the plugin pins ingest a turn the plugin's own hooks built. - The dashboard's Moments page opens ranked by significance, not newest first, and refreshes every 10 seconds instead of 3. Add
?sort=newestto the page address, or pick Newest first in its Order control, for the old stream.GET /api/v1/momentsis unchanged unless a caller passessort_by=significance.
Every entry below is shown by its lead sentence. The notes in full, with each entry's detail: CHANGELOG.md at v0.20.0.
Security
- A long run of full stops, question marks or exclamation marks no longer holds the event loop.
- Two ways past the critical detectors are closed.
- The agent being judged can no longer replace its own verdict.
- A swapped rule and a changed gating setting change the fingerprint, and the audit log says what a rule was.
- A verdict that was
unknownfor missing required evidence read back as a pass. - A critical detection is no longer hidden behind a policy failure.
- Dependency advisories closed in every workspace:
- Citation fetching refuses NAT64 addresses that lead to a private network.
- One search can no longer stall the server (#703).
delete_traceno longer leaves the deleted text readable iniris.db(#703).- Revoking an API key takes effect on the next request, with no restart.
- A browser session ends when the API key it signed in with expires.
- The Python client is published only from a tag on
main, built with hash-pinned tools. - The SDK, the LangChain.js handler and the
iris-evallauncher can be published by the release workflow, with npm provenance, once each is set up on npm. - CI installs the MCP clients it tests against from a lockfile.
SECURITY.mdcovers everything the repository publishes:- The storage threads start from fixed code.
Added
stateon every rule result, bundle row, run result and case attempt, and a count of what was not checked beside every pass count.verdict_not_checkedwebhook eventproof/INVARIANTS.md: what a verdict does when evidence is left out, and when a failure is added.storage.searchIndex: "off"(IRIS_SEARCH_INDEX=off) keeps no full-text index, for a feed that needs the ingest rate more than fast search (#729).- A trace that reports no cost gets one estimated from its tokens, marked as estimated (#702).
install --upgrademoves every MCP client on the machine to this release, andinstall --listshows which Iris each one runs (#704).- A database is copied before it is migrated (#704).
- A migration names the oldest release that can still use the database, so a later additive migration no longer locks out the release before it (#704).
- Export traces and evaluations as CSV or JSON Lines, with the list's filters and search applied (#4).
- Recipes for the OpenAI Agents SDK (Python and JavaScript) and LlamaIndex, each run in CI against a real Iris server.
- Every OpenAI and Anthropic call recorded and scored, without the model choosing to (#648).
- LangChain and LangGraph runs scored, in Python and JavaScript (#642).
- An OTLP sender can ask for a verdict on its own traces.
- The LLM judge prices Claude Opus 5.5
- Claude Desktop installs Iris in one click (#650).
npx iris-evalstarts Iris.- Decision Moments can be ranked by significance, and the Moments page ranks by default (#409).
- An off-topic answer fails when you turn on the relevance judge (#649).
- The
relevancejudge template, forevaluate_with_llm_judgetoo. - The relevance judge spends within a daily budget, keeps personal data and credentials from the provider, and says when it reaches the network (#649).
- Full-text search over traces: find the run where the agent said it
- Search reads what a trace's spans say: the later model calls, tool results and exceptions of an agent loop sent over OTLP (#683).
- A word inside Chinese, Japanese or Korean text is found (#682).
- Every verdict says who recorded what it judged, and a capture harness can certify that no tool was called.
Changed
provenance.composer.calibrationnames the calibration table, not the corpus it was measured on.- The published verdict accuracy is measured through the composer the server runs.
- A Decision Moment whose trace has one passing evaluation and one that was not checked reads "No verdict".
- The 24 real transcripts: bundle verdicts agreeing with the answer key went from 78 to 82 of 96
- iris-eval.com shows what exists today: the open-source server.
- Community links point to GitHub, npm and X.
- Iris's own test runs stay out of its public download count.
- Background work's steps are sized to stay under 50 ms, and the index build no longer runs FTS5's merges inside them.
- After upgrading, the server answers its client before it copies and migrates the database.
- After upgrading, the server answers before the covering index for search filters is built.
- Each connection compiles a statement once (#711).
- Storing one trace no longer writes the search index in the same commit (#729).
GET /api/v1/healthandGET /healthcarrysearch: the index'sstate(ready,buildingorunavailable),index(what a search reads now,fts5orscan) andprogress, the share of stored traces indexed, from 0 to 0.99 while it is built.- GenAI message arrays are read down to their words, for every framework.
- The agent's own span is filed as the agent, not as a model call.
- Installing Iris succeeds where
better-sqlite3cannot be built (#661). - A filtered Decision Moments list returns full pages (#657).
- The dashboard loads each page's code when the page is shown (#662).
- Behaviour change:
verify_citationsreports a judge failure underjudge_error, notresolve_error(#407). evaluate_outputis back to 0.19.0's speed, reading evaluations is faster than 0.19.0, and every number is unchanged (#717).- The dashboard sends compressed files and API responses (#718).
- The public claims schema
Fixed
evaluate_outputwith anexpected_trajectoryno longer fails withIRIS_INTERNAL_ERROR.- A comparison no longer counts a rule that did not run as a pass.
- A comparison's words match its numbers.
- A comparison no longer calls runs "equivalent" within a margin nobody chose.
- A critical rule firing on new cases is named in the comparison.
compare_tracesno longer prints a 95% interval of [100%, 100%].- "Could not have detected a change smaller than X" now means what it says.
- The dashboard no longer draws a rule that never ran as "100% pass".
- The run comparison page shows what each run was judged on.
- Several server processes on one Iris home run the same rules and lose no rule.
- A label written through one server process is read by the others.
- The retention sweep no longer deletes the pinned baseline run.
- The HTTP endpoint serves more than one MCP client.
- The site and the docs say what the code does, and a test holds each of these statements to the thing it describes.
- The compare pages hold against the vendors' pages as of 2026-10-01.
- Closing the store releases the file before it returns (#750).
- A search's page of trace ids, and the start's check for evaluations with no stored risk estimate, keep their plans when the file has statistics (#742).
- The website builds only when something its build reads has changed.
- A database file is never opened by both copies of SQLite in one process (#739).
- Scoring a trace no longer reads every evaluation in the store (#711).
- Every hot read keeps its query plan, whatever SQLite's statistics say (#711).
- The dashboard's first render reads index entries, not trace rows (#711).
get_traceswithmin_scoreormax_scoreno longer walks every evaluation once per trace (#711).- Case results for one case key or one session start from that case's traces (#711).
- On Node's built-in
node:sqlite, the first read after another process changed the schema returns every column (#711). - Rebuilding the search index no longer holds the server while the old index is erased (#695).
- A write no longer waits for SQLite to copy the whole write-ahead log into the database file, and the log stays short.
- A failing test in the truthbase capture is reported as a failing test, by name, not as drift.
- The truthbase capture refuses a run that exited non-zero with every test passing.
- The Python client's publish workflow parses again, and every workflow is linted on every pull request.
o1-miniis priced at $1.10 in and $4.40 out per million tokens, not $3 and $12.- On Node 24, Iris no longer loads a better-sqlite3 that would abort the server when it frees a statement (#719).
- A stdio server shuts down in order when the client closes its stdin.
- A published precision's interval is the one for the prevalence asked (#717).
--self-testno longer says PASS on a database the server then refuses (#704).- A trace stored by a process that does not index it is found by the next search.
- A start refused over the database prints its sentence on its own line.
- Every test file is type-checked in CI (#721).
- The published test counts are no longer recorded from a run in which a test file failed to load.
- The Python recorder batches as it says it does.
- The Failures and Moments pages no longer re-run the regression-alarm watcher once per trace (#680).
- The first Failures or Moments request after a start no longer spends seconds drawing alarm lines.
- A busy host no longer gets ordinary custom regex rules killed as backtracking (#677).
- LangGraph traces from LangSmith's OpenTelemetry export arrive readable.
- A tool call whose arguments arrive as a JSON string is checked as the object it encodes.
- The LangGraph recipe works as written.
- The LangChain and CrewAI examples are complete programs, not scaffolds.
- The published list of judge models is the list the judge accepts.
answers_the_askhas blocked at the shipped defaults since 0.18.0, contrary to its release notes; it now advises there, as documented.- The published composite numbers move, because they were measured with the rule blocking.
- The response schema accepts a judge's
sampleevidence - The evaluator matrix reads a measured
proof/judge-results.json. - A fresh clone builds with
pnpm install && pnpm run buildagain. npm run demoand the release and tool-list checks no longer start Iris on your own data.- The dashboard works from the keyboard and reads correctly to a screen reader, and CI now checks it on every page in both themes (#724).
- Dashboard text reaches WCAG AA contrast (4.5:1) in both themes (#725).
Supply-chain transparency
- SBOMs:
iris-npm-sbom.spdx.json+iris-docker-sbom.spdx.json(attached below). Both are SPDX 2.3 JSON, cover direct + transitive dependencies. - SBOM signatures: each SBOM has a companion
.sigstore.json(Sigstore bundle — signature + cert + Rekor entry) attached to this release. The release workflow runs this exact verification itself before it reports success. Verify with:cosign verify-blob \ --bundle iris-npm-sbom.spdx.json.sigstore.json \ --certificate-identity='https://github.com/iris-eval/mcp-server/.github/workflows/release.yml@refs/tags/v0.20.0' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ iris-npm-sbom.spdx.json - npm provenance: published with
--provenance(verifiable vianpm audit signaturesor on the package page). Any npm library or launcher version this workflow publishes beside the server carries provenance too. - Docker signature: image signed with cosign keyless (Sigstore). Verify with:
cosign verify ghcr.io/iris-eval/mcp-server:v0.20.0 \ --certificate-identity='https://github.com/iris-eval/mcp-server/.github/workflows/release.yml@refs/tags/v0.20.0' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' - Build attestation: both the npm SBOM and Docker image manifest carry GitHub-signed build-provenance attestations. Inspect with
gh attestation verifyorcosign verify-attestation. - MCPB bundle:
iris-eval.mcpb(open it to install in Claude Desktop) is built from the npm tarball above plus the production dependencies in this tag'spackage-lock.json, and holds the tarball's files byte for byte. It is signed (iris-eval.mcpb.sigstore.json) and carries a build-provenance attestation. Verify with:cosign verify-blob \ --bundle iris-eval.mcpb.sigstore.json \ --certificate-identity='https://github.com/iris-eval/mcp-server/.github/workflows/release.yml@refs/tags/v0.20.0' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ iris-eval.mcpb gh attestation verify iris-eval.mcpb -R iris-eval/mcp-server
What's Changed
- feat(npm): the unscoped iris-eval package launches the server instead of printing a notice by @irparent in #640
- docs: rate-limit keys, context-anchored PII patterns, and the README env table held to the code; dead roadmap links fixed by @irparent in #641
- docs(langchain): the package note links its rebuild (#642) instead of calling its future undecided by @irparent in #643
- fix(npm): the iris-eval launcher is checked out with LF on every platform, so its shebang runs on macOS and Linux by @irparent in #644
- chore(npm): record the iris-eval launcher as published by @irparent in #645
- deps: Bump tsx from 4.23.13 to 4.23.15 by @dependabot[bot] in #631
- deps(website): Bump framer-motion from 13.4.0 to 13.4.1 in /website by @dependabot[bot] in #634
- deps(dashboard): Bump @typescript-eslint/eslint-plugin from 8.61.0 to 8.70.1 in /dashboard by @dependabot[bot] in #628
- deps(website): Bump @radix-ui/react-visually-hidden from 1.2.4 to 1.2.11 in /website by @dependabot[bot] in #632
- deps: Bump fast-check from 4.10.1 to 4.10.2 by @dependabot[bot] in #629
- feat(judge): price Claude Opus 5.5; the published judge-model list is read from the pricing table by @irparent in #646
- deps: Bump @types/node from 26.6.1 to 26.6.2 in the types group across 1 directory by @dependabot[bot] in #626
- deps(dashboard): Bump @types/d3-shape from 3.1.8 to 3.2.0 in /dashboard in the types group across 1 directory by @dependabot[bot] in #622
- deps(website): Bump @upstash/redis from 1.38.4 to 1.39.0 in /website by @dependabot[bot] in #627
- verify_citations: report judge failures under judge_error, not resolve_error by @irparent in #652
- deps(website): Bump the nextjs group across 1 directory with 2 updates by @dependabot[bot] in #624
- Decision Moments: rank by significance within a stated window by @irparent in #653
- deps(website): Bump @tailwindcss/postcss from 4.3.0 to 4.3.3 in /website by @dependabot[bot] in #630
- fix: shipped thresholds read as shipped (answers_the_ask advises at the defaults, as documented) by @irparent in #660
- Dashboard: load each page's code when the page is shown by @irparent in #664
- deps(dashboard): Bump react and @types/react in /dashboard by @dependabot[bot] in #633
- Record every OpenAI and Anthropic call as a GenAI span, in Python and JavaScript by @irparent in #659
- An off-topic answer fails when the relevance judge is on (#649) by @irparent in #656
- dashboard: drop recharts and the dead components that imported it by @irparent in #666
- feat(release): an MCPB bundle with each release — one-click install in Claude Desktop (#650) by @irparent in #655
- chore(deps): lock the MCPB reference tooling as an exact devDependency by @irparent in #676
- feat(traces): full-text search over traces (#7) by @irparent in #658
- Failures and Moments: run the regression-alarm watcher once per request by @irparent in #684
- tests: fix the intermittent root-suite failures from a busy machine by @irparent in #678
- Fix two recorder test failures on main: the Python batching interval, and a JS wall-clock assertion by @irparent in #686
- tests: run half as many workers, and give the jsdom suite a measured timeout by @irparent in #685
- Regex sandbox: count the budget from the start of the match by @irparent in #679
- Regression alarms: read the alarm lines from a generated table, not a first-request simulation by @irparent in #687
- tests: remove the timing assumptions a busy machine breaks by @irparent in #681
- Install succeeds when better-sqlite3 cannot build: it is optional, and Iris runs on node:sqlite (#661) by @irparent in #673
- Moments: a filtered list pages within a stated window by @irparent in #674
- e2e: choose the port at run time and check the server is this build by @irparent in #675
- LangChain and LangGraph runs scored, in Python and JavaScript by @irparent in #663
- Search: read what a trace's spans say — later model calls, tool results, exceptions (#683) by @irparent in #688
- The Python end-to-end servers cannot outlive the test run by @irparent in #694
- GenAI message arrays are read down to their words, for every framework by @irparent in #692
- An agent span (invoke_agent, create_agent) is INTERNAL, not a model call by @irparent in #693
- fix(claims): capture-tests refuses a run where a test file failed to load by @irparent in #697
- tests: the alarm-line table's spread check reads three rows with a measured timeout by @irparent in #699
- fix(build): a fresh clone builds with pnpm install && pnpm run build; CI builds that way and requires every tool by @irparent in #647
- Search: find words inside Chinese, Japanese and Korean text, and tokenize exactly as the index does (#682) by @irparent in #698
- Estimate a trace's cost from its tokens when it reports none, and mark it estimated (#702) by @irparent in #705
- Release supply chain: npm provenance for the SDK, handler and launcher; hash-pinned PyPI build; sessions end with their key; image home in /data by @irparent in #706
- Search: bound what one query can cost; delete_trace leaves no text on disk (#703) by @irparent in #712
- Search runs on a worker thread with its own read-only connection (#703) by @irparent in #716
- CI: lint every workflow with actionlint; publish-python.yml parses again and builds on every pull request by @irparent in #728
- Truthbase capture: a failing test is refused by name; claims:check names the fields that drifted by @irparent in #730
- tests: search worker and budget tests prove their point without racing the clock by @irparent in #731
- Refuse a better-sqlite3 build that aborts Node 24 when it frees a statement (#719) by @irparent in #720
- Startup never waits on the store: retention after connect, in steps; #695 erased in steps; checkpoints off the event loop by @irparent in #727
- deps: ip-address 10.7.2 and undici 7.30.0; the citation SSRF guard refuses NAT64 to private networks by @irparent in #736
- Relevance judge: daily budget, per-request cap, redaction, honest annotations, loud misconfiguration, MCPB settings by @irparent in #707
- perf: evaluate_output back to 0.19.0 speed, evaluation reads never run the draws, identical numbers; compressed dashboard by @irparent in #715
- Hot reads keep their plans, statements compile once, synchronous is explicit, dashboard reads use index entries (#711) by @irparent in #726
- Upgrades: copy the database before migrating, a compatibility floor, install --upgrade, and a self-test that reads the real file (#704) by @irparent in #710
- perf(dashboard): compress the answers to reads only; a write's acknowledgement goes out as it is by @irparent in #738
- Background steps sized to stay under 50 ms; FTS5 merges out of the index build's steps; the covering index built after the start by @irparent in #737
- website: build only when something the site's build reads has changed by @irparent in #740
- Revoking an API key takes effect on the next request, with no restart by @irparent in #714
- Export traces and evaluations as CSV or JSON Lines, with the list's filters and search (#4) by @irparent in #709
- Checkpoints: the write-ahead log starts over again; background steps empty it off the event loop; the risk fill's steps are sized with their commit by @irparent in #741
- Dashboard: keyboard and screen-reader fixes, and an axe check on every page in both themes by @irparent in #713
- Stall guard: each case copies to a file of its own, so a retry never reads the failed attempt's log by @irparent in #747
- Migration 019's indexes are built after the server answers, and health says when they are building (#744) by @irparent in #745
- Plan test covers the export reads; a search's page ids and the risk-estimate fill keep their plans under statistics (#742) by @irparent in #743
- deps: fast-uri 3.1.8 and brace-expansion 5.0.12 / 1.1.21 in every workspace by @irparent in #753
- Tests: every temporary directory is removed with retries, so Windows does not fail a test on a file still being released by @irparent in #749
- Stall guard: while writes never pause, assert that the log keeps starting over, and say how when it fails by @irparent in #755
- Search budget test: require one answered and one refused query, not more than 5 answered by @irparent in #752
- Dashboard: text reaches WCAG AA contrast (4.5:1) in both themes by @irparent in #735
- Closing the store releases the file before it returns by @irparent in #751
- Truthbase capture: refuse a run that exited non-zero with every test passing by @irparent in #754
- Checkpointer tests prove the log's thresholds at a fifth of their size by @irparent in #757
- Upgrades: the server answers first, then copies and migrates on the checkpoint worker; every door waits at one gate by @irparent in #748
- Price 1-hour cache writes at Anthropic's 1-hour price (#702) by @irparent in #723
- Type-check every test file in CI, and fix the 108 errors found on main (#721) by @irparent in #722
- Tests: storage.synchronous's store test gets 30 s, like the other file-backed storage tests by @irparent in #760
- Tests: the risk-fill checkpoint test gets 90 s; it runs its fill twice by @irparent in #762
- Queue a single stored trace for the search index and index it in batches after the write (#729) by @irparent in #734
- OTel recipes for the OpenAI Agents SDK (Python and JavaScript) and LlamaIndex, run in CI by @irparent in #708
- Erasure test: say which page of iris.db holds a swept word, and whether it is free (#758) by @irparent in #759
- Dashboard: no per-request compression for a client on this machine (#761) by @irparent in #763
- One copy of SQLite per database file in a process, and prove worker threads end cleanly (#739) by @irparent in #733
- CI: the worker-exit job gets its own workflow, run where it can find something by @irparent in #765
- deps(website): Bump framer-motion from 13.4.1 to 13.4.4 in /website by @dependabot[bot] in #700
- deps(dashboard): Bump react-router from 8.3.0 to 8.4.0 in /dashboard by @dependabot[bot] in #672
- deps(dashboard): Bump @storybook/addon-docs from 10.5.6 to 10.6.0 in /dashboard by @dependabot[bot] in #690
- deps(dashboard): Bump @storybook/react-vite from 10.4.6 to 10.6.0 in /dashboard by @dependabot[bot] in #671
- deps(dashboard): Bump jsdom from 29.1.1 to 30.1.1 in /dashboard by @dependabot[bot] in #669
- ci: Bump the codeql group across 1 directory with 4 updates by @dependabot[bot] in #691
- deps: Bump @modelcontextprotocol/sdk from 1.30.0 to 1.30.1 by @dependabot[bot] in #689
- deps: Bump the testing group across 1 directory with 2 updates by @dependabot[bot] in #701
- deps: Bump the linting group across 1 directory with 4 updates by @dependabot[bot] in #654
- Drop an unused import; keep the SDK's Node types at its Node 22 floor by @irparent in #764
- Start the storage threads from fixed code by @irparent in #775
- deps: Bump @types/node from 26.6.2 to 26.6.3 in the types group across 1 directory by @dependabot[bot] in #766
- Compare pages: every vendor statement re-read on 2026-10-01 by @irparent in #776
- deps(website): Bump @types/node from 26.6.2 to 26.6.3 in /website in the types group across 1 directory by @dependabot[bot] in #767
- deps(langchain): Bump @langchain/core from 1.2.12 to 1.2.13 in /packages/langchain in the langchain group across 1 directory by @dependabot[bot] in #769
- deps(dashboard): Bump vite from 8.3.0 to 8.3.1 in /dashboard in the vite group across 1 directory by @dependabot[bot] in #770
- deps(dashboard): Bump @testing-library/react from 16.3.2 to 16.3.3 in /dashboard by @dependabot[bot] in #772
- deps(dashboard): Bump @testing-library/jest-dom from 6.9.1 to 7.0.1 in /dashboard by @dependabot[bot] in #771
- test: the search-budget cases get 30 s by @irparent in #779
- deps(website): Bump sharp from 0.35.4 to 0.35.5 in /website by @dependabot[bot] in #768
- deps(dashboard): Bump @testing-library/dom from 10.4.1 to 10.4.2 in /dashboard by @dependabot[bot] in #773
- docs(changelog): 0.20.0 release notes by @irparent in #778
- Publish the four-way comparison by @irparent in #777
- Tidy a comment in the release workflow test by @irparent in #781
- The package inventory leaves out manifests git ignores; the node-forge advisory is recorded by @irparent in #782
- The phrase rules read text both ways; every invisible character is dropped by @irparent in #783
- A failing verdict names every layer that would have failed it; a gate passes only what it judged by @irparent in #784
- Site and docs statements are tested against the code they describe by @irparent in #785
- deps(sdk): Bump the providers group across 1 directory with 2 updates by @dependabot[bot] in #780
- A trace's verdict cannot be replaced by the agent it judges by @irparent in #786
- One edition: open source, free, every feature included by @irparent in #787
- Several processes and several clients share one data folder by @irparent in #788
- A comparison counts only what was checked, and its words match its numbers by @irparent in #789
- Not checked is its own answer: in the verdict, the gate, the counts and the dashboard by @irparent in #790
- Detectors read what was written: a test runner's verdict, a recovered failure, and the same text spaced or wrapped another way by @irparent in #791
- An output written as JSON is read by what it says by @irparent in #792
- A long run of full stops no longer holds the event loop by @irparent in #793
- A verdict is stamped with the calibration table it was labelled from by @irparent in #795
- An answer that says nothing fails: a new rule, says_something by @irparent in #796
- The Claude Code capture plugin records failed calls and loses no call a turn made by @irparent in #797
- Every verdict says who recorded the evidence it judged; a capture harness can certify that no tool was called by @irparent in #798
- The session reader: Claude Code's own session logs, read as turns and judged by @irparent in #799
- Community links: GitHub, npm and X by @irparent in #800
- The site and release notes say what Iris is: one edition, every feature included by @irparent in #801
- The site states Iris's price as it is, with one commitment beside it by @irparent in #802
- The site says what exists today and leaves room for what comes next by @irparent in #803
- The capture plugin sends each turn in the form the release it pins reads by @irparent in #807
- A release publishes what the workflow can, its page carries the notes whole, and the 0.20.0 notes lead with what can fail a build by @irparent in #808
- release: 0.20.0, with the Python client at 0.2.0 by @irparent in #809
Full Changelog: v0.19.0...v0.20.0