Skip to content

v0.16.0

Choose a tag to compare

@github-actions github-actions released this 22 Sep 08:35
· 219 commits to main since this release
fc17324

[0.16.0] - 2026-09-22

Earn the comparison. 0.16.0 is the release where Iris can be opened beside any incumbent and every noun on the table is either shipped and measured or named as a gap in Iris's own words. A trace carries a session; a run can be a baseline and a comparison lists the cases the two runs disagree on; a moment fires a webhook; a CI job gets a one-line Action; Python gets a client and a pytest plugin; every OpenTelemetry framework gets a recipe bound to a fixture that proves it; every listing renders from the truthbase; the fourteen compare pages hold against the vendors' pages as they read today, with a cost-to-run row and a five-question FAQ; the response schema is published and locked; the verdict's decision path is exported; and how long an evaluation takes is measured rather than asserted.

Check before upgrading.

  • suggestions is gone from every evaluation response, deprecated in 0.13.0 and removed here on the two-minor schedule. Read interpretations[] (who the sentence is for, and the setting that changes it) and rule_results[] (the failing rules and each skip's reason) instead. The database column stays, written as [], so a database 0.16.0 writes still opens on 0.15.0.
  • docs/roadmap.md is gone. The capability map is the roadmap: docs/capabilities.md and https://iris-eval.com/capabilities. iris-eval.com/roadmap redirects there.
  • A trace's session_id is a column, not metadata. Migration 014 adds it with its index on first start, beside 001–013. A trace that carried a session id in metadata keeps it there; new writes fill the column from session_id, gen_ai.conversation.id, or SEP-414 baggage.
  • Migration 013 adds runs.baseline and its partial unique index — one baseline per agent, enforced by the database rather than by a read.
  • notify.webhook is strict like the rest of the configuration: an unknown key refuses startup and names the nearest known one.
  • The compare pages carry a thirteenth row (cost to run), so a tool that parsed the twelve-row table by position needs to read by id.

Added

  • The run comparison lists its discordant cases, and a run can be the baseline. compare_runs and POST /api/v1/compare answer with discordant[] — the paired cases whose verdict flipped, regressions first, each with both evaluations, the direction and the rules that flipped (the b + c McNemar counts, named) — and discordant_total; the Runs page lists them under the comparison and each row opens the moment. PATCH /api/v1/runs/:id { "baseline": true } pins a run as the baseline (one per tenant, kept by a partial unique index — migration 013); GET /api/v1/runs carries baseline; an omitted before on compare_runs and POST /api/v1/compare means the pinned baseline, and the Runs page's compare form starts from it. The interval stays: nothing about the statistics changed.

  • Sessions. A trace carries session_id — explicit on log_trace and POST /api/v1/traces, or gen_ai.conversation.id on the OTLP door, or the SEP-414 baggage session_id when a request named nothing else — in a column with an index (migration 014). get_traces and GET /api/v1/traces filter by session; the trace drawer shows the session strip (which turn this is, the previous and the next, every turn linked); compare_traces takes group_by: session, so a conversation answered both ways reads as flaky. A sessions page is later work, once sessions exist in stored data.

  • A webhook fires on a moment (#5). notify.webhook: { url, secret, events, cooldownMinutes, format, timeoutMs } in the strict config.json, or IRIS_WEBHOOK_URL and IRIS_WEBHOOK_SECRET. Five events — verdict_fail, detector_veto, cost_anomaly, regression_alarm (the CUSUM line crossed at this evaluation), flaky_case (the first attempt that disagreed with every earlier one) — read from the store after the evaluation row is durable, whichever door wrote it, so nothing waits on the receiver. The body is the Standard Webhooks shape (id, type, timestamp, data) with the moment's ids, verdict, rules and numbers and never the agent's text; signed two ways any receiver already verifies (webhook-id / webhook-timestamp / webhook-signature: v1,… over id.timestamp.body, and x-iris-signature: sha256=… over the body); one attempt and three retries at 500 ms, 2 s, 8 s (±25%) on a network error, a timeout, 408, 429 or 5xx, then a logged drop; a cooldown per (event, agent, subject), ten minutes by default; a queue of a hundred. format: slack and format: discord post the bodies those services render. A typo'd event name, a non-http URL, or the iris format without a secret refuses startup naming it. iris://capabilities reports notify.webhook — events, host, format, signed — never the URL's token or the key. Found on the way: the strict schema's did-you-mean skipped keys under a .nullable() object; it unwraps those now. Guide: docs/webhooks.md. Tests: tests/unit/notify/{webhook,events,config,doors}.test.ts.

  • A GitHub Action for the CI gate. uses: iris-eval/mcp-server/.github/actions/gate@v0.16.0 with traces: traces.ndjson runs iris-eval ingest --evaluate --fail-on … (and --dataset), fails the job on the verdicts named, writes the receipt — trace ids, bases, rules, span labels, never the text — to the job summary, and on a pull request posts it as one comment updated in place (a marker names the traces file; a fork's read-only token, a missing permission or the comment turned off each skip it and say so; an empty traces file fails rather than passing green). A composite action with three small scripts and no third-party action to pin; dogfooded on this repository's CI (gate-action: the walk-through's leak caught inside the dataset, a clean file passing, the comment updated not duplicated). Guide: docs/ci-gate.md. Tests: tests/ci-gate-action.test.ts; the action file is held to the docs contract (flags, variables) and the unpublished-package scan.

  • The Python client, iris-eval on PyPI (#6). packages/python: IrisClient and AsyncIrisClient over httpx — log_trace, evaluate_output (through the ingest door with evaluate: true, so the output is stored and shows on the dashboard), get_traces, get_trace, health, capabilities; Bearer auth; typed answers (iris_eval.types mirrors the API reference key for key); a refusal raises IrisError with the server's own sentence, the status and the validation details; no server raises IrisConnectionError with the recipe. The server is found from IRIS_URL or the running dashboard's runtime.json, verified with the health route. A pytest plugin registers itself: the iris fixture (skips with the recipe when no server is there, fails with IRIS_REQUIRE=1) and assert_iris(output, expect="pass"), which asserts on the verdict's state and reads like the dashboard when it fails. CI runs the client's contract test against a keyed server this checkout builds on Python 3.10–3.13 and installs the wheel into a fresh environment; publish-python.yml publishes by PyPI trusted publishing on a py-v* tag once the project and the publisher are registered. A decorator SDK is deliberately not shipped: Iris reads the OpenTelemetry traces a Python framework already emits. Tests: packages/python/tests.

  • The OTel recipes page. docs/otel-recipes.md: one recipe per framework — Pydantic AI, Google ADK, LangGraph via LangSmith's export, CrewAI via OpenInference, AutoGen, Microsoft Agent Framework, Semantic Kernel, Vercel AI SDK, Mastra — the vendor's own setup lines (read from the page each recipe cites) with Iris as the OTLP endpoint, what Iris reads out of that vocabulary, and the fixture in this repository that proves the reading; where the fixture proves the vocabulary and not the framework's own capture (AutoGen, Mastra), the recipe says so. Held by tests/docs-otel-recipes.test.ts: every recipe names a fixture that exists, every convention fixture is named by a recipe, every recipe cites its source and points at the door; the page is in llms.txt and linked from the OTel guide and the README.

  • Every listing inherits the truth. server.json carries title, websiteUrl and icons (the SVG and the PNG the site serves), validated against the vendored 2025-12-11 registry schema in tests/listings-hygiene.test.ts; the README's works-with table is rendered from clients.json through the truthbase (a block npm run llms:render rewrites and llms:check holds); an Install-in-VS-Code button and the Cursor button in its current install form, both registering the server as iris-eval; the mcp.so badge on the listing's real address; a Docker MCP Catalog entry (docs/launch/listings/docker/server.yaml, with its copy) ready for the registry pull request; a Cursor plugin manifest (.cursor-plugin/plugin.json, on the version gates) so the repository installs as a Cursor plugin; smithery.yaml retired with its reason (Smithery lists a local server as an MCPB bundle now, and the file's docs page returned 404); localhost:3838 → localhost:6920 in the site's two mockups, held by the test; the historical launch drafts held to their "do not repost" banners; and a demo GIF at the top of the README — the demo database landing on Failures, a failure opened, two runs compared — recorded by scripts/demo-media.mts (Playwright video → ffmpeg), under 3 MB and asserted so, with the MP4 source at website/public/demo.mp4.

  • The compare pages hold against today's vendor pages. Every vendor cell's sentence is checked against a plain download of its page by npm run compare:verify (scripts/verify-compare-quotes.mjs), which now reads a page the way a reader would — inline Markdown markup gone, since most vendors' docs answer a plain fetch with Markdown, and the spacing a stripped tag leaves before punctuation closed — and --write records quoteVerified and quotesCheckedOn in the files. Twenty-two cells corrected from a second read of the vendors' pages (Langfuse's server-side code evaluators, Weave's Signals, Judgment's evaluate_traces and run_test, Promptfoo's trajectory and tool-call assertions, Opik's 40+ metrics, Phoenix MCP named, Patronus's judge evaluator, CoreWeave's completed acquisition of Weights & Biases on its own page). A thirteenth row, Cost to run, on every comparison, read from the vendor's pricing page and dated, or saying the vendor's pages do not answer. A five-question FAQ on every page in the buyer's words — two written, three derived from the table so the FAQ cannot say what the table does not — emitted as FAQPage JSON-LD too; a playground link in the call to action. The four-way article, "Iris vs Langfuse vs Phoenix vs Promptfoo: where each wins, where each loses", drafted with every vendor statement linked to a page its compare file lists as a source, held unpublished by published: false and a future date; the Dev.to crossposter now honours published: false. tests/compare-contract.test.ts, tests/compare-quote-verifier.test.ts and tests/compare-article-draft.test.ts hold all of it.

  • The verdict says which layer decided, and now you can read the whole path. verdictPath(result, config) returns the composer's decision node by node — a configured gate, a detector's veto, a critical check that could not answer, evidence the deployment requires, the risk estimate — each naming what it found and whether it decided. compose() reads the node that decided and stamps the verdict from it, so the two cannot disagree; a path stops at the decider, because the layers after it were never asked. Exported from @iris-eval/mcp-server/engine with the VerdictNode type, and asserted in the packed-and-installed probe CI runs.

  • The response schema is published and locked. https://iris-eval.com/response-schema-v1.json — JSON Schema 2020-12, rendered from the same zod schema evaluate_output validates its answer against (npm run schema:render), so a client generating types or validating a stored response reads what the server actually returns. A test renders it and fails when the committed file has drifted, and validates a response the engine produced against it.

  • How long an evaluation takes is measured and published. npm run proof now times EvalEngine.evaluateAll — the call evaluate_output makes — over every case in the proof corpus, discards a warm-up and reports p50 and p95 with the machine that produced them, on the proof page and in proof/RESULTS.md. It is excluded from proof --check (latency is a property of the machine, and CI's runner is not your laptop) and the hardcoded-claim scanner's new eval-latency pattern refuses a number anywhere that disagrees with the truthbase.

  • OTLP traces arrive as protobuf too. POST /v1/traces accepts application/x-protobuf beside application/json — the Python OTLP exporter's only encoding, so until now no Python framework (Pydantic AI, Google ADK, LangGraph via LangSmith's export, CrewAI, AutoGen, Microsoft's Agent Framework) reached the door without a Collector between. The decoder is src/otel/protobuf.ts: a wire-format reader for the one message Iris accepts, about two hundred lines and no dependency (against protobufjs at 3.8 MB and @bufbuild/protobuf at 1.9 MB), producing exactly the OTLP/JSON object the JSON path already parses, so the mapping and every test below it are unchanged. Held to the real thing: tests/fixtures/otlp/*.pb are the request bodies opentelemetry-exporter-otlp-proto-http 1.44.0 posted, and the decoder must reproduce their JSON twins byte for byte. Any other content type is a 415 naming both encodings; a malformed protobuf body is a 400 with the byte offset. gRPC is still not served — docs/otel-integration.md carries the one-file Collector bridge.

  • The OTLP door speaks every convention a buyer will test. Beside the GenAI conventions it already read, POST /v1/traces now reads OpenInference (input.value, output.value, llm.token_count.*, llm.model_name, session.id, openinference.span.kind), OpenLLMetry (traceloop.entity.input / output, traceloop.span.kind, llm.request.type, the indexed gen_ai.prompt.N.content), LangSmith's export (langsmith.span.kind), Semantic Kernel's gen_ai.response.prompt_tokens / completion_tokens, and the Vercel AI SDK's legacy ai.prompt / ai.response.text / ai.usage.* / ai.model.id / ai.toolCall.*; gen_ai.agent.name names the agent before the "otel" default; the model lands in metadata.model (what the judge's same-family check reads), gen_ai.conversation.id in metadata.session_id, and gen_ai.tool.definitions in tools[] for valid_tool_arguments. Token usage is summed over the leaf carriers only — a framework that puts the run's totals on invoke_agent beside chat children that carry their own (Microsoft Agent Framework, the Vercel SDK's generateText over its doGenerate) is counted once, where every span used to be added; Pydantic AI's whole-run gen_ai.aggregated_usage.* wins when present. One fixture per framework, authored to the vendor's documentation, holds each of these (tests/fixtures/otlp/conventions/).

  • Trace context is carried (SEP-414). log_trace and evaluate_output read W3C traceparent / tracestate / baggage from the request's _meta (and the MCP session id from the transport), and POST /api/v1/traces and POST /v1/traces from the headers of the same names; what arrived is stored under metadata.trace_context (the header, the trace id, the caller's parent span id, the sampled flag, the session id) — evaluate_output writes it onto its linked trace when that trace has none. The API returns it and the trace drawer shows it. A root that joined the caller's trace also carries mcp.method.name and mcp.session.id per the OTel MCP conventions. A trace stored with a context is exported to IRIS_OTEL_ENDPOINT under the caller's trace id with its root parented to the caller's span, so Iris's evaluation appears inside the agent's own trace in Langfuse, Phoenix, Logfire or Jaeger. Without a context nothing changes. Claude Code sends _meta as null on tools/call (anthropics/claude-code#76391), so from that client the capture plugin is the session link.

  • Expected-trajectory rules, measured — and an off-task answer no longer reads clean. Four built-in rules (25 now), each with a corpus family and its precision and recall on the proof page. tool_sequence compares the calls the caller expected (expected_trajectory.tool_calls on evaluate_output) with the calls made, in a mode — strict, unordered, subset, superset or ordered_subset (the default; a two-pointer subsequence check) — matching by tool name and, when the expectation names arguments, by exact or subset input; step_budget holds the call count to the budget the caller stated for THIS task (expected_trajectory.step_budget, or the number of expected calls) times a tolerance (default 1.5); both are policies that gate because the caller stated the expectation, and both skip without one. tool_choice finds the wrong tool for the ask from the catalogue valid_tool_arguments already takes: it scores the ask's actionable terms (the ones some tool's name, description or input properties mention) against each tool and fires when an uncalled tool fits at least 0.5 and beats the tools called by at least 0.34 — a heuristic detection that lowers the score and never vetoes. answers_the_ask answers the composer question: a truncated or off-topic answer read passed: true, basis: clean because the two relevance rules are measurements, which inform the score, and only policies and detections gate. Measured on the 141-case composite corpus before the rule existed, gating on both measurements failing together flipped no classless clean case, caught four of the eight off-task cases and six stub cases the composer missed — so the pair now decides: answers_the_ask is a policy with no number of its own — when the input is present and both keyword_overlap and topic_consistency fail at their thresholds, the output answers something else and the verdict fails on policy_gate, at the shipped defaults too. It skips whenever either measurement skips and on an ask with fewer than two content terms; a right answer that reuses none of the ask's words reads as off task (three of the corpus' twenty-five negatives), and the two measurements' thresholds are the knob. Two failure classes join the vocabulary (wrong_trajectory, wrong_tool) and one need (expected_trajectory); off_task joins the composite corpus' tier A, so the composite is regenerated. The capability map's Q3×S3 and Q4×S3 move from gap to has with evidence that resolves.

Fixed

  • Two processes opening one cold database at the same instant no longer lose one of them on the WAL switch. PRAGMA journal_mode = WAL upgrades the connection's SHARED lock to EXCLUSIVE, and SQLite does not run the busy handler on that upgrade (two connections each waiting for the other's SHARED lock would never return), so the 0.14.0 fix — the timeout set before the switch — covered a plain wait and never this one: the losing process still failed with database is locked whatever busy_timeout said. The adapter now retries the switch itself with a short backoff inside the same five-second budget, on either driver's busy error; an error that is not BUSY is thrown as it came, and a failed boot closes the handle. Found by a CI run on the Node 22 built-in driver.
  • passed means one thing across the four eval tools. evaluate_with_llm_judge and verify_citations stored one judgment row each and reported their own pass/fail beside it, so a stored judgement read back with no verdict at all (the composer derives a verdict on read only from a row that carries provenance) and the two tools' passed was the threshold's word where every other surface's was the composer's (#375 item 2). Both rows now go through the engine's composer before they are stored — provenance and all — and both responses carry the composer's verdict (state, basis, by), the same object evaluate_output prints: a failed judgment gates (policy_gate by the judge), a passed one is clean, and a citation check that judged nothing is unknown (no_rules), never a pass. tests/unit/tools/judge-verdict.test.ts drives both tools through the MCP surface with the model mocked and reads the rows back.
  • The demo is the engine's own output. --demo used to write hand-shaped evaluations — a failing card read FAIL · Fail: with nothing after the colon, a FAIL card said 2/2 pass, Runs was empty, the Audit Log was empty and Drift had no prior period. Now every seeded trace is stored and judged through the same function log_trace, POST /api/v1/traces and ingest call, dated when the trace happened, so a demo row carries exactly what a real one does: the verdict with its basis, the evidence spans, the interpretations, the provenance. The traffic spans two weeks (the Drift view's default window has the week before it), two runs on the same twelve questions are seeded with a release-gate dataset over them (the bad deployment shipped unfilled templates; the comparison shows the recovery with an interval), the demo's two custom rules go through the real store (one enabled and firing, one paused, three audit rows), and the injection case is one the rule actually judges — a hidden directive in a forum post the summarizer complied with, not a quoted phrase the rule rightly reads as discussion. The three LLM-judge rows keep canned scores (the judge needs a key) but are built by the judge tool's own row builder, now one function (src/eval/llm-judge/persisted.ts).
  • The dashboard's top bar holds at phone width. Below 768 px the command-palette trigger ("Search or jump to… Ctrl K") ran under the status chips (live, judge off, DEMO) on every page. The trigger is now its icon at that width (the accessible name and the shortcut are unchanged), the title row wraps its chips onto a second line, and the header grows to fit — nothing overlaps and every chip stays visible, because the chips carry live state a phone reader still needs. tests/e2e/header-phone.spec.ts measures it: on four pages of a demo server at 400 × 820, no two top-bar children share a pixel and nothing runs past the right edge.
  • The read routes refuse a misspelled filter. Every dashboard GET query schema was a bare z.object, which strips an unknown parameter, so GET /api/v1/moments?agent_nme=docs-qa returned every agent's rows exactly as if the filter had applied — the silent-strip defect the mutating bodies were cured of in 0.5.x (#376), one verb over. The thirteen query schemas (/traces, /evals, /runs, /cases, /summary, /eval-stats and its /trend, /drift and /failures, /failures, /moments, /audit, /labels, /issues) are now strict: an unknown parameter is a 400 naming it and the valid ones. Observable for an API caller: a request that sent an extra or misspelled query parameter and got a page back now gets a 400 — check the parameter names against docs/architecture.md.
  • Every README link survives the npm package. The package ships dist, LICENSE, README.md and server.json, so a relative link to docs/ or a sibling package was dead for everyone reading the README from node_modules (#369 item 1). The last four relative links are absolute, and tests/docs-contract.test.ts refuses any README link that is relative to a file the package does not carry.
  • POST /api/v1/rules/custom deploys an action_policy. The route's own copy of the rule-type enum stopped at eight types while deploy_rule accepted nine, so the policy that gates a deploy could be deployed over MCP and not over HTTP (found by the 0.15.0 stranger's gate phase). The list is one exported constant (RULE_TYPE_VALUES) the store, the tool and the route all build from; tests/unit/dashboard/routes/rules-action-policy.test.ts deploys one rule of every type over HTTP and reads them back. docs/ci-gate.md step 1 shows the config-file form beside the env form — a session that cannot prefix a command with a variable starts the same refusal through --config gate.json — and the walk-through test runs that form.
  • The dashboard's poll no longer discards a slow response. A poll tick that fired while the previous request was still in flight took the request id, so the pending response was dropped as stale — and the next one, and the next. On a server with enough traffic that a 200-moment window took longer than the cadence, the Drift and Health prior windows never rendered and the banner said "No prior period to compare against yet" over two weeks of data. A tick now waits for the request in flight (a manual refetch still supersedes); the Drift, Health and agent-list reads poll at the normal cadence rather than the live tail's; the change banner says it is comparing while a window loads instead of claiming there is nothing to compare; and the moments and failures routes hydrate a page's evaluations in one batched read (getEvalsByTraceIds) instead of one query per trace.

Changed

  • no_tool_loop catches a repeating cycle of any length. The cycle clause stopped at period 3 ("beyond three the pattern is a plan, not a loop"), so a four-call block repeated three times over — twelve calls that between them asked four questions three times, every answer unchanged — walked straight through (#427). The period searched is now bounded by what can still repeat more than the allowance inside the trajectory (maxCycleLength(n) = floor(n / 3)): a plan repeated twice is a retry and passes; the same plan a third time is the machine stuck. The polling exemption and the shorter-period rule are unchanged. Two corpus cases pin both sides (loop-041 fails, loop-042 passes); the family measures n = 42, precision 1.00, recall 1.00 on corpus a2825049eb4f (it was n = 40, 1.00 / 1.00 — nothing that fired stops firing, nothing that passed starts). Observable: an agent whose trajectory repeats a block of four or more distinct calls three times over is now judged a loop where it was not.

Removed

  • suggestions is gone, on the schedule 0.13.0 announced. Every line it carried was a restatement of a field the same response already had — the failing rules (rule_results), the veto (verdict.basis detector_veto with critical_failures), each skip and its reason (rule_results[].skipped and .skipReason), the critical checks that could not judge (critical_skipped) — and a second copy of a verdict is a second verdict. interpretations[], which has carried the sentences since 0.13.0, says who each one is for and which setting changes it; two sentences that only suggestions used to carry are now written there: when nothing was judged, and when a critical check skipped without making the verdict unknown. Check before upgrading: if you read suggestions from evaluate_output, from POST /api/v1/traces with evaluate: true, or from a stored row, read interpretations[] and rule_results[] instead. The suggestions column stays in the database, written as [], so a database this version writes still opens on 0.15.0.
  • docs/roadmap.md is gone; the capability map is the roadmap. It said in prose what docs/capabilities.md says from the truthbase, and it went stale between releases — it still listed OpenTelemetry ingest and datasets as open a release after both shipped. iris-eval.com/roadmap redirects to /capabilities, the links that pointed at the file point at the map, and a test holds that no live surface links it and that the tracks on the site do not call shipped work planned.

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-regexp='https://github.com/iris-eval/mcp-server' \
      --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
      iris-npm-sbom.spdx.json
    
  • npm provenance: published with --provenance (verifiable via npm audit signatures or on the package page).
  • Docker signature: image signed with cosign keyless (Sigstore). Verify with:
    cosign verify ghcr.io/iris-eval/mcp-server:v0.16.0 \
      --certificate-identity-regexp='https://github.com/iris-eval/mcp-server' \
      --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 verify or cosign verify-attestation.

What's Changed

  • fix(stranger): the gate rows graded on what Iris printed, never on fetched docs; a dashboard port of its own per phase by @irparent in #564
  • feat(demo): the demo is the engine's own output; the dashboard's poll no longer discards a slow response by @irparent in #569
  • fix(dashboard): the top bar holds at phone width by @irparent in #570
  • fix(tools): passed means one thing across the four eval tools — the judge and citations rows through the composer by @irparent in #571
  • fix(dashboard): the read routes refuse a misspelled filter — every query schema strict by @irparent in #572
  • docs(readme): every README link survives the npm package by @irparent in #573
  • fix(rules): one rule-type list for every surface; the walk-through's config-file form by @irparent in #574
  • feat(rules): no_tool_loop catches a repeating cycle of any length by @irparent in #575
  • feat(otel): OTLP traces arrive as protobuf too by @irparent in #576
  • feat(otel): the OTLP door speaks every convention a buyer will test by @irparent in #577
  • feat(otel): trace context is carried — SEP-414 by @irparent in #578
  • feat(rules): expected-trajectory rules, measured — and an off-task answer no longer reads clean by @irparent in #579
  • feat(runs): the run comparison lists its discordant cases, and a run can be the baseline by @irparent in #580
  • feat(sessions): a trace carries its session — on every door, with a filter, grouping in the comparison, and the strip in the trace drawer by @irparent in #581
  • feat(notify): a webhook fires on a moment — signed two ways, retried, cooled down, never in the way of an evaluation by @irparent in #582
  • feat(ci): a GitHub Action for the gate — the receipt on the job summary, one pull-request comment updated in place, dogfooded by @irparent in #583
  • docs(otel): the recipes page — one recipe per framework, each proved by a fixture by @irparent in #585
  • feat(python): iris-eval on PyPI — a thin client over the HTTP API and a pytest plugin that gates a test on the verdict by @irparent in #584
  • feat(listings): every listing inherits the truth by @irparent in #586
  • feat(compare): the compare pages hold against today's vendor pages by @irparent in #587
  • feat(engine): suggestions removed, the response schema published and locked, the verdict's path exported, the roadmap is the map, and evaluate_output's latency measured by @irparent in #588
  • deps: Bump fast-check from 4.9.0 to 4.10.1 by @dependabot[bot] in #565
  • deps(website): Bump @upstash/redis from 1.37.0 to 1.38.4 in /website by @dependabot[bot] in #488
  • deps(init): Bump @types/node from 25.6.0 to 26.6.2 in /packages/init in the types group across 1 directory by @dependabot[bot] in #352
  • deps(dashboard): Bump the vite group across 1 directory with 2 updates by @dependabot[bot] in #306
  • deps(init): Bump vitest from 4.1.11 to 5.0.1 in /packages/init in the testing group across 1 directory by @dependabot[bot] in #351
  • deps(dashboard): Bump @typescript-eslint/parser from 8.61.0 to 8.70.0 in /dashboard by @dependabot[bot] in #311
  • deps(website): Bump the react group across 1 directory with 4 updates by @dependabot[bot] in #526
  • deps(init): Bump tsx from 4.22.4 to 4.23.13 in /packages/init by @dependabot[bot] in #491
  • deps(website): Bump the nextjs group across 1 directory with 2 updates by @dependabot[bot] in #566
  • deps(dashboard): Bump jest-axe from 10.0.0 to 11.0.0 in /dashboard by @dependabot[bot] in #313
  • deps(website): Bump @types/node from 26.1.2 to 26.6.2 in /website in the types group across 1 directory by @dependabot[bot] in #567
  • deps(dashboard): Bump lucide-react from 1.23.0 to 1.47.0 in /dashboard by @dependabot[bot] in #316
  • deps(init): Bump typescript from 5.9.3 to 7.0.2 in /packages/init by @dependabot[bot] in #353
  • deps(website): Bump framer-motion from 12.38.0 to 13.4.0 in /website by @dependabot[bot] in #568
  • deps(dashboard): Bump eslint from 10.2.1 to 10.11.0 in /dashboard by @dependabot[bot] in #355
  • release: 0.16.0 — Earn the comparison by @irparent in #589

Full Changelog: v0.15.0...v0.16.0