v0.20.0Iris 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→

Releases

Iris 0.20.0Iris 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.

2026-10-03 · rendered from CHANGELOG.md · the same section is the GitHub release

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-on gate 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 --upgrade moves 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-eval 0.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-on` gate 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 to iris-eval ingest and the gate action, each of which turns a job that passed into one that fails. (1) verdict.basis names one layer, and --fail-on <basis> read it alone: an output that broke a policy you configured and also leaked a credential read policy_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 under verdict.also, and --fail-on reads both; fail and unknown read the state of every layer the same way. A job gating on risk_over_loss will 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 missing output (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: true on 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 --file path 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-on nothing changes.

- BREAKING — The gate action fails the job on every verdict that is not a pass. Its fail-on input defaulted to detector_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 is any now. A workflow that uses the action without fail-on and relied on only critical detections failing the job sets fail-on: detector_veto. The CLI has no default and is unchanged; the README and docs/ci-gate.md lead 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 verdict unknown (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 with eval.criticalRules, deployed it at severity high or critical, 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 an expected_trajectory the rule compares against. verdict.by names the missing inputs, the rule result carries lacked and asked, 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 on passed, or on --fail-on any, unknown or required_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 reads fail now, basis: "risk_over_loss", with the layer that could not check under verdict.also. --fail-on and the webhooks already read every layer and trip as before; a caller that branched on verdict.state or verdict.basis itself 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_output with a trace_id and output, input, tool_calls, tools, cost_usd or token_usage that differs from what the trace stored, or with one eval_type, now answers with reference_trace_id in place of trace_id and a sentence saying what differed; so does a call that names a trace which recorded no output, and so does every evaluate_with_llm_judge and verify_citations call 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, in compare_runs or compare_traces, in the score filter of get_traces, or in the latest_* export columns. A workflow that judged each trace with evaluate_with_llm_judge and 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, call evaluate_output with its trace_id and nothing else: output is 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. Migration 020-eval-reference-trace adds the column.

- BREAKING — An expected call that was never made fails, where it used to pass (`tool_sequence` and `step_budget`, version 2). evaluate_output with expected_trajectory.tool_calls and tool_calls: [] skipped tool_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 but superset; step_budget judges it as zero calls, which is within any budget. Calls that are not sent at all still skip. The ruleset fingerprint changes.

- BREAKING — `compare_runs` and `POST /api/v1/compare` answer differently in four ways. (1) better is no longer true when the second run was judged by fewer rules than the first; improvement_withheld is true instead. A job that read better to promote a run stops promoting one that sent less evidence. (2) equivalent_within is null unless you pass equivalence_margin; it used to default to a margin computed from the run sizes. A caller that read equivalent_within.holds without a margin now reads null. (3) difference carries 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.significant now agrees with worse and better. (4) smallest_detectable is 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, and judged_before / judged_after on each rule row.

- BREAKING — `compare_traces` reports a wider interval, and `overall.rate` is 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.rate is the mean of the cases' own pass rates; it equals pooled.rate when 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 covers eval.defaultsGate, eval.falsePassCost, eval.onCriticalSkipped, eval.requiredEvidence, eval.priorMode and 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, so compare_runs between 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 with force. With an estimated prior the fingerprint moves as labels are added, because the verdicts do. deploy_rule, delete_rule, the dashboard's rule routes and delete_trace now write their audit entry before the change and fail with IRIS_STORAGE_ERROR when it cannot be written; they used to carry on without a record.

- BREAKING — `verify_citations` reports a judge failure under `judge_error`, not `resolve_error`. A caller that read the judge's error from resolve_error on a citation whose resolve_status is "ok" reads it from judge_error instead, with the same kind and message. resolve_error now means only that the source was not resolved.

- `compare_runs` says 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 (worse is 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 carries not_checked, and paired carries fell_not_checked.

- A case is flaky only when it passed and FAILED. flaky on GET /api/v1/cases/:key, in the flaky_cases view and in compare_traces, and the flaky_case webhook, 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 `categories` is the verdict, read for the rules that bundle holds. categories.<bundle>.passed was 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 read passed: true, and a script keyed on categories.safety.passed shipped the output the evaluation failed. A row now carries state (pass, fail, unknown) and passed follows it (true, false, and null for 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 input eval.requiredEvidence names leaves every bundle not checked. A script that read a bundle's passed will see failures it did not see before, and null where it saw false for a bundle that lacked evidence; a row no longer fails for a low score alone. score on a row is unchanged.

- A webhook that names no `events` also receives `verdict_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.requiredEvidence used to count an input only when a rule that ran had read it, so a deployment that requires cost read 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 requires tool_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 a capture column between source and session_id; a reader that takes columns by position shifts by one from there. Migration 022-trace-capture adds the column that holds it. A verdict that nothing could judge (basis: "no_rules") now lists missing evidence under verdict.also (required_evidence_missing), whether a deployment required it or a capture source promised it, so --fail-on required_evidence_missing trips on such a trace where it passed. Two more changes in what counts as sent: a blank expected, or an expected_trajectory no trajectory rule can use ({}, { "tool_calls": [] }), no longer meets a requirement for one; and evaluate_output that sends tool_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/moments` with a `verdict`, `min_significance` or `significance_kind` filter returns full pages from a window of recent traces. offset now counts matching moments rather than traces, total is the exact number of matches in the window rather than the trace count, and the response carries window. A client that paged a filtered list by adding limit to offset gets every match once instead of gaps; one that read total as the trace count reads window.tracesInRange. Matches older than the window (the newest 500 traces by default, window to narrow it) are not returned: narrow agent_name, since or until to 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_pii` and `no_injection_compliance` fail 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_failure` reads 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 ending Tests 3 failed | 12 passed passed 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, so compare_runs refuses 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 to sentence_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 read sk-****…3f9a as a secret. A value is masked when four asterisks hide all but a few of its characters; Xk9mP2vQ7rL4nT8wZ3hY**** is still a secret. DOB and MRN labels 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_output` and `no_hallucination_markers` read 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 fails non_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 in read. 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_something reads 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.md publishes the twelve agents against both asks: where tool_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 the stub class; 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/summary and the cost_by_agent view, and cost_under_threshold (default $0.10), cost_anomaly, custom cost_threshold rules and the cost_anomaly webhook act on it instead of skipping. A gate on cost_under_threshold can start failing traces it used to skip. Each estimate is marked cost_source: "estimated" wherever it shows. Traces stored before the upgrade are not re-priced. To keep the old behaviour, set pricing.estimate: false in config.json.

- `answers_the_ask` stops 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, set IRIS_RELEVANCE_JUDGE_MODEL to have the relevance judge decide (below), or set a keyword_overlap or topic_consistency threshold 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.0 install pins each client to one release. 0.20.0 adds migrations 015-trace-search and later, and the first 0.20.0 process to open the file (the Claude Desktop extension, npx iris-eval, or npx @iris-eval/mcp-server with no version) applies them; from then on a client still pinned to 0.19.x refuses to start with This 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 with claude plugin marketplace update iris-eval and claude plugin update iris-eval@iris-eval (and iris-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.index is scan): slower, with the same results, and on a large store it stops at the search time budget with search.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/health reports search.state and search.progress (the share of traces indexed so far), and --self-test reports the configured database's index. get_traces without q sorts by timestamp as before; with q the 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/health says "indexes": "building" (--self-test says 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`, default `normal`) (#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 at NORMAL; on Node's built-in node:sqlite it was FULL throughout and is NORMAL now. Set "storage": { "synchronous": "full" } in config.json to 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_trace call takes about 0.1 ms longer. A feed that needs the ingest rate more than fast search can set storage.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.days waited 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 as optimize did. 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 to initialize came 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 `/data` volume. The image now sets IRIS_HOME=/data, so deployed rules (custom-rules.json), audit.log, config.json and preferences.json sit beside iris.db. Before, they were written to /home/iris/.iris inside the container and were lost whenever the container was recreated. A deployment that mounted its own volume at /home/iris/.iris to keep them should copy those files into the /data volume, or set IRIS_HOME back to that path. /data is 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 at retention.days. If you pinned a run and relied on the window to remove its text, unpin it, or delete its traces with delete_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 as input and the last answer as output. A caller reading input / output from GET /api/v1/traces, get_traces or 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, output is 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' default service.name (unknown_service, unknown_service:<process>) is filed under the agent its spans name (gen_ai.agent.name, OpenInference's agent.name), else under "otel" with service.name listed as lacked. A caller that filtered OTLP traces by agent_name=unknown_service:python.exe finds 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`, not `LLM`. A span whose gen_ai.operation.name is invoke_agent or create_agent is stored with kind INTERNAL; a caller that counted LLM spans 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 no Stop) 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 an unfinished part of their own turn, stored and not judged, with a sub-agent's calls named in metadata.subagent_calls. A turn a Stop hook keeps going is sent as a second, continued part 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 empty tool_calls list 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 calls log_trace for the turn itself, the plugin no longer stands down: both traces are kept, and the plugin's names the model's, once, in metadata.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 in capture/ 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, marked truncated; 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 the capture declaration, 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=newest to the page address, or pick Newest first in its Order control, for the old stream. GET /api/v1/moments is unchanged unless a caller passes sort_by=significance.

Security

Added

Changed

Fixed

Earlier

One line per release, the bold lead of its changelog section; each links to the release notes built from that section.

VersionDateLeadEntries
0.19.02026-09-25Verdicts that say how sure they are, detectors that see through disguises, and one command to set up any client9
0.18.02026-09-24Honest numbers, a lighter footprint, nothing done silently15
0.17.02026-09-23Hostile input, honest verdicts, a harder release path11
0.16.02026-09-22Earn the comparison26
0.15.02026-09-21Reach, honestly26
0.14.02026-09-20The dashboard is a product25
0.13.02026-09-08Found and fed31
0.12.12026-09-07Truth patch 310
0.12.02026-09-07Compare17
0.11.02026-09-06The act layer24
0.10.02026-09-05The verdict18
0.9.02026-09-05Iris explains its verdicts12
0.8.22026-09-05The truth patch5
0.8.12026-09-04The honest-surfaces patch0
0.8.02026-09-04The trajectory release10
0.7.02026-09-04The proof release21
0.6.02026-09-03The correctness release47
0.5.12026-09-03The verdict-integrity patch9
0.5.02026-08-12The acceptance-test release19
0.4.62026-08-10Security and data-integrity release14
0.4.52026-08-07Security release12
0.4.42026-06-12—2
0.4.32026-05-21—5
0.4.3-rc.02026-05-16—0
0.4.22026-05-06—3
0.4.12026-05-06WITHDRAWN—12
0.4.02026-04-24—28
0.3.12026-04-22—9
0.3.02026-04-21—9
0.2.42026-04-17—9
0.2.32026-04-16—5
0.2.22026-04-16—7
0.2.12026-04-16—16
0.2.02026-04-14—29
0.1.92026-04-07—7
0.1.82026-03-25—10
0.1.72026-03-25—8
0.1.62026-03-23—3
0.1.52026-03-21—21
0.1.42026-03-20—19
0.1.32026-03-15—3
0.1.22026-03-14—13
0.1.02026-03-13—35