> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwedai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> Release notes for the QWED Protocol: version history, new guards, breaking changes, security fixes, and hardening across QWED engines.

All notable changes to the QWED platform, listed by release.

Jump to: [v9.1.0](#v9-1-0-—-allowlist-admission-for-python-code) · [v9.0.0](#v9-0-0-—-eleven-advisories-and-models-you-configure) · [List agents and owner activity access](#qwed-verification-—-list-agents-and-owners-read-agent-activity) · [Session admin auth, key list, and user list](#qwed-verification-—-session-sign-in-for-key-rotation-and-threats-richer-key-list-and-user-list) · [Audit CSV exports and natural-language domain](#qwed-verification-—-audit-csv-exports-carry-verdict-and-admission-natural-language-audit-domain) · [Owner sign-in from qwed init](#qwed-verification-—-qwed-init-can-create-an-owner-who-can-sign-in) · [Whole-chain trail scope and Verification Context audit rows](#qwed-verification-—-whole-chain-trail-scope-and-verifiable-verification-context-audit-rows) · [Startup hook digest pins](#qwed-verification-—-startup-hooks-can-be-allowed-by-content-digest) · [SQL and code verifier false positives](#qwed-verification-—-sql-and-code-verifier-false-positive-fixes-and-python-3-14) · [diff() and batch math verdicts](#qwed-verification-—-math-identities-read-diff-and-batch-math-matches-verify-math) · [Claude providers stop sending `temperature`](#qwed-verification-—-claude-providers-stop-sending-temperature) · [AgentStateGuard JSON numbers](#qwed-verification-—-agentstateguard-gives-one-verdict-on-a-json-number) · [QWED-MCP v0.2.3](#qwed-mcp-v0-2-3-—-qwedai-com-no-longer-trusted-and-mcp-sdk-pinned-below-2-0) · [v8.0.0](#v8-0-0-—-attested-verdicts-and-upgrade-guide) · [Batch and agent routes enforce attestations](#qwed-verification-—-batch-and-agent-verify-routes-return-enforced-attested-results) · [Audit paging and security events](#qwed-verification-—-audit-log-paging-caller-and-admission-fields-and-security-events) · [Clients fail closed](#qwed-verification-—-python-sdk-integrations-and-github-action-fail-closed) · [Pinned sandbox image](#qwed-verification-—-pinned-sandbox-image-and-hardened-container) · [CrossHair in the Docker sandbox](#qwed-verification-—-crosshair-runs-only-in-the-docker-sandbox) · [Signed audit chain for API rows](#qwed-verification-—-api-audit-rows-written-through-the-signed-hash-chain) · [Logic DSL fixes](#qwed-verification-—-logic-dsl-compiles-iff-and-quantifiers-and-rejects-boolean-coercion) · [Proven-false math claims](#qwed-verification-—-proven-false-math-claims-are-verified-with-admission-deny) · [GitHub Action scan paths](#qwed-verification-—-github-action-scans-use-mode-defaults-and-deny-empty-scans) · [Compliance session sign-in](#qwed-verification-—-session-sign-in-and-whole-chain-check-for-compliance-endpoints) · [Security activity summary](#qwed-verification-—-security-activity-summary-replaces-the-soc-2-report) · [Role checks and hashed agent tokens](#qwed-verification-—-role-checks-on-api-key-and-audit-routes-hashed-agent-tokens) · [Docker image runs the API server](#qwed-verification-—-docker-image-runs-the-api-server-and-the-compose-stack-builds) · [Go, TypeScript, and Rust SDK admission gating](#qwed-verification-—-go-typescript-and-rust-sdks-read-the-diagnosticresult-envelope) · [Signed Verification Context documents](#qwed-verification-—-signed-verification-context-documents-and-attestation-checks) · [Contacts moved to qwedai.tech](#qwed-verification-—-contacts-and-links-moved-to-qwedai-tech) · [v7.3.0](#v7-3-0-—-five-advisories-tenant-scoping-and-credential-hardening) · [Admin endpoint tenant scoping](#qwed-verification-—-tenant-scoped-admin-endpoints-and-working-rbac) · [`verify_fact` fails closed offline](#qwed-verification-—-verify_fact-fails-closed-offline-and-money-rejects-non-finite-amounts) · [Request caps and sandbox limits](#qwed-verification-—-request-body-caps-timeouts-and-sandbox-concurrency-limits) · [v7.2.3](#v7-2-3-—-code-secret-and-sql-verification-security-release) · [QWED-A2A runtime hardening](#qwed-a2a-—-request-size-limits-engine-budget-and-code-guard-hardening) · [QWED-Tax v0.2.1](#qwed-tax-v0-2-1-—-classification-claim-vocabulary-and-strict-facts) · [QWED-Legal v0.5.1](#qwed-legal-v0-5-1-—-deadlineguard-signing-anchor-allowlist) · [v7.2.2](#v7-2-2-—-verification-soundness-and-safety-hardening) · [Open Responses v0.6.1](#qwed-open-responses-v0-6-1-—-toolguard-verifies-client-action-items) · [QWED-Finance v3.0.1](#qwed-finance-v3-0-1-—-iso-20022-sanctions-and-queryguard-security-fixes) · [QWED-Legal date and input bounds](#qwed-legal-—-fail-closed-date-handling-and-input-bounds) · [Open Responses v0.6.0](#qwed-open-responses-v0-6-0-—-fail-closed-hardening) · [QWED-Legal fail-closed hardening](#qwed-legal-—-fail-closed-hardening-for-guards-diagnostics-and-github-action) · [`qwed init` server identity handshake](#qwed-verification-—-qwed-init-server-identity-handshake) · [Leaked key auto-revocation](#qwed-verification-—-leaked-api-key-auto-revocation-and-false-positive-feedback) · [API-key format spec](#qwed-verification-—-canonical-api-key-format-spec) · [Signed secret-scanning webhook](#qwed-verification-—-signed-secret-scanning-webhook-endpoint) · [v7.2.1](#v7-2-1-—-math-answer-comparison-security-patch) · [v7.2.0](#v7-2-0-—-precision-advisory-and-security-hardening-batch) · [v7.1.0](#v7-1-0-—-verification-context-v1-0-rollout) · [v7.0.0](#v7-0-0-—-full-diagnosticresult-engine-conformance)

***

## v9.1.0 — Allowlist admission for Python code

**Released: November 7, 2026** · [GitHub PR #596](https://github.com/QWED-AI/qwed-verification/pull/596) · [GitHub PR #594](https://github.com/QWED-AI/qwed-verification/pull/594) · [GitHub Release](https://github.com/QWED-AI/qwed-verification/releases/tag/v9.1.0)

> **Minor release with one security fix.** `CodeVerifier` now admits Python code only when every module, module name, builtin and special attribute it uses is on a list. Code that 9.0.0 admitted may now be refused. The server also reads `.env` before any of its settings. Read the upgrade steps below before you upgrade.

### Security fixes

The advisory affects all versions up to 9.0.0. Upgrading is recommended.

* **`CodeVerifier` admits only listed modules, builtins and special attributes** ([GHSA-9r7q-mx75-hcj7](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-9r7q-mx75-hcj7), high). Reported by kta1kri. `/verify/code` returned `VERIFIED · ADMIT` for Python that read and wrote files with `io.FileIO`, sent data with `urllib3`, unpickled with `pickle.Unpickler`, started processes with `multiprocessing` and read `os.environ`. It refused code by lists of dangerous names and admitted anything those lists did not hold. Python code is now admitted only when:

  * every module it imports, and every name it uses from one, is on the list of standard-library modules that compute without files, the network, processes, the environment or the import system. Third-party modules are not admitted.
  * every builtin it reads is listed: the public exceptions, the builtins that compute, `print`, `input`, `exit` and `quit`.
  * every special attribute (`__x__`) it uses is listed, and it reads no attribute's value by a name made at run time. `hasattr` is admitted: it only answers whether an attribute exists, although the lookup can run a property.
  * no module is used as a value or written to, and no frame or traceback is read.

  The code-execution sandbox passes the third-party modules its import guard confines (`numpy`, `pandas`, `scipy`, `sklearn`, `statsmodels`, `patsy`) as trusted, so the Statistics engine still runs that code. The GitHub Action's `scan-code` mode takes none of the new finding types, so repository scans don't change.

### What changed

* **`CodeVerifier(trusted_modules=...)`.** Names top-level modules that your own runtime confines, such as a sandbox with an import guard. The module checks skip a trusted module, so don't rely on them for one. The builtin, attribute and frame checks still apply.
* **The server reads `.env` before any of its settings** ([#594](https://github.com/QWED-AI/qwed-verification/pull/594)). Before 9.1.0, the maths worker, database, rate limiter and auth settings were read before `.env` loaded, so a value written only in `.env` was ignored. For example, `DATABASE_URL` fell back to `sqlite:///./qwed.db`. The server now loads `.env` from the working directory, then `~/.qwed/.env`, before any other module. Values set in the process environment (the shell, Docker `-e`, Kubernetes `env`, Compose) still win over `.env`.

> [!WARNING]
>
> **Upgrading to 9.1.0.** These changes can make `/verify/code`, `QWEDLocal.verify_code` and the CLI refuse code that 9.0.0 admitted. Each refusal is a finding that names the module, builtin or attribute.
>
> 1. **Imports.** Code that imports a third-party module (`numpy`, `pandas`, `requests`), or a standard-library module that is not listed (`asyncio`, `logging`, `warnings`, `subprocess`, `importlib`, `pathlib` and others), is refused even if it calls nothing from it. So are the `os.path` functions that read the file system, the working directory or the environment; `sys.stdin`, `sys.stdout` and `sys.stderr`; `contextlib.redirect_stdout`; `pprint.PrettyPrinter`; and star imports from `copy`, `dataclasses`, `enum`, `functools` and `time`.
> 2. **Builtins.** A read of `open`, `vars`, `globals`, `locals`, `getattr`, `setattr`, `delattr`, `help`, `license`, `credits`, `copyright`, `breakpoint`, `memoryview` or `__build_class__` is refused, also where it is the name of your own variable or parameter. Rename it, for example to `help_text`.
> 3. **Attributes.** Special attributes that are not listed (`__func__`, `__wrapped__`, `__get__`, `__reduce__` and others) are refused. `.format` and `.format_map` need a template written in the code with no attribute in a field. Use an f-string otherwise. `copy.copy`, `functools.update_wrapper`, `ast.iter_fields`, `types.MethodType` and `dataclasses.make_dataclass` are no longer listed. A class pattern with positional parts (`case Point(x, y)`) needs a class whose `__match_args__` is fixed: a builtin or listed class, or a class statement whose bases are such and whose decorators are listed.
> 4. **SDK cache.** `QWEDLocal` doesn't serve code results it cached before 9.1.0 (its cache policy version is now `code_verifier-v3`).
> 5. **`.env`.** Before upgrading, check `.env` in the working directory and `~/.qwed/.env` for settings the process environment doesn't set. The server now uses them: a `DATABASE_URL` there becomes the database the server opens, `QWED_AUTH_TRUSTED_PROXIES` there decides which proxies' `X-Forwarded-For` the server trusts for the client address, and a maths setting the server can't use stops the start.

***

## v9.0.0 — Eleven advisories and models you configure

**Released: November 6, 2026** · [GitHub PR #589](https://github.com/QWED-AI/qwed-verification/pull/589) · [GitHub PR #575](https://github.com/QWED-AI/qwed-verification/pull/575) · [GitHub Release](https://github.com/QWED-AI/qwed-verification/releases/tag/v9.0.0)

> **Major release.** It fixes eleven advisories in code, maths, SQL and logic verification, the GitHub Action and sign-up. QWED no longer names models of its own: each provider runs the model you configure. The CSV exports change their columns, and the Go module path ends in `/v9`. Read the upgrade steps below before you upgrade.

### Security fixes

The advisories affect all versions up to 8.0.0. Upgrading is recommended.

* **`CodeVerifier` refuses modules taken by name from the import system** ([GHSA-qrp4-p44j-m484](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-qrp4-p44j-m484), high). Code that loaded a module by its name as a string, for example through `importlib.util.module_from_spec`, `pkgutil.resolve_name`, `pydoc.locate` or `sys.modules.get(...)`, was `VERIFIED · ADMIT`. These names are now refused wherever they appear, and any read of `sys.modules` is refused.
* **`CodeVerifier` refuses eleven more modules that run strings as code, and more file readers** ([GHSA-jrxf-9jh4-86w7](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-jrxf-9jh4-86w7), high). Importing `timeit`, `cProfile`, `profile`, `pdb`, `bdb`, `code`, `codeop`, `trace`, `doctest`, `logging.config` or `idlelib` is refused. Archive, compressed-file, `tempfile`, `sqlite3`, `shelve`, `glob` and other file readers join the file-access checks.
* **`CodeVerifier` refuses code hidden in strings that typing evaluates** ([GHSA-p75q-7rg5-ff46](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-p75q-7rg5-ff46), high). Strings that typing may compile, such as string annotations and `ForwardRef` arguments, are now parsed and refused when they hold a call, lambda, comprehension or assignment expression.
* **`CodeVerifier` refuses a source encoding other than UTF-8** ([GHSA-pr6c-cqg8-6w5r](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-pr6c-cqg8-6w5r), high). Under a `utf-7` or `unicode_escape` coding declaration, text checked as a comment could decode to statements at run time. Such a declaration is now refused.
* **The Action's `scan-code` mode resolves from-imports, aliases, importlib and `getattr`** ([GHSA-hg73-j3qv-w4vh](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-hg73-j3qv-w4vh), medium). It also runs `CodeVerifier` on each file. Attribute access by a computed name is reported as a warning in the new `warnings_count` output and doesn't fail the scan. A pass means no known dangerous pattern was found, not that the code is safe.
* **The maths parser refuses unknown functions and gives `n` no hidden domain** ([GHSA-5h3v-3463-78hf](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-5h3v-3463-78hf), medium). `sinc(0) = 0` and `Abs(n) = n` were `VERIFIED · ADMIT`. A name of two or more letters in call position must now be a supported function, a multi-letter name is one symbol, and `n` is a plain variable.
* **`/verify/math` and batch maths run in worker processes with a time limit** ([GHSA-qgjc-92m3-fcr6](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-qgjc-92m3-fcr6), medium). A job past `QWED_MATH_TIMEOUT_SECONDS` (default 10) is `UNVERIFIABLE` (`math.timeout`) and its worker is killed. A request that finds the pool full is `BLOCKED` (`math.capacity`).
* **`verify_math` no longer accepts wrong small values or wrong 7th digits** ([GHSA-8hp7-jqjc-hrhf](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-8hp7-jqjc-hrhf), medium). `1/1000000000 = 0` was `VERIFIED`. The comparison is now unrounded, `tolerance=0` is exact, and the result's new `match` field says how a claim matched.
* **SQL tautologies written with `IN`, `IS`, `LIKE`, `BETWEEN` and other operators are refused** ([GHSA-jvhg-jw63-gr5g](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-jvhg-jw63-gr5g), medium). A filter predicate that reads no data and still can't be evaluated is not admitted (`sql_verifier.unevaluated_predicate`).
* **Logic verification keeps numbers exact** ([GHSA-w73j-4jjq-q53j](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-w73j-4jjq-q53j), medium). Number-only sub-expressions such as `1/3` or `0.1 + 0.2` were computed as floats before Z3 saw them, so `prove_theorem` proved false statements. Decimal literals and divisions of two numbers are now exact or refused.
* **Sign-up no longer prints the new user's password hash** ([GHSA-mr5j-gm4f-6c27](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-mr5j-gm4f-6c27), low). Debug prints in `POST /auth/signup` wrote the user record, password hash included, to standard output. Treat hashes that earlier logs may have collected as exposed.

### What changed

* **Each provider runs the model you configure** ([#575](https://github.com/QWED-AI/qwed-verification/pull/575)). QWED no longer falls back to model names of its own. Each provider takes its model from the argument you pass or from its variable, and raises an error naming the variable when neither is set.
* **Audit rows store real latency.** `/audit/logs` returns the measured `latency_ms` of each row, and both CSV exports have a `Latency (ms)` column. Rows written before 9.0.0 return `null`.
* **Other changes since 8.0.0.** 9.0.0 also ships the API key list fields (`expires_at`, `rotation_required`, `owner`), `GET /auth/users`, `GET /agents` and owner access to agent activity, `qwed init --owner-email`, `org_name` on sign-in and `/auth/me`, the shared CSV export columns, the `NATURAL_LANGUAGE` audit domain, default sampling for the Anthropic and Claude Opus providers, and one verdict for JSON numbers in `AgentStateGuard`. It also ships key rotation and the threat summary for a signed-in session, statistics verification and `verify_python_deep` on Python 3.14, `diff()` in `/verify/math` (and refusal of `d/dx`), SQL queries with a parenthesised subquery or a `;` inside a string or comment, `exec`, `eval` and `compile` read as names instead of substrings, an audit row for a signed Verification Context document that verifies, and startup hooks allowed by the digest of their contents.

> [!WARNING]
>
> **Upgrading to 9.0.0.** These changes can break existing deployments and callers.
>
> 1. **Set a model.** Set the model variable of the provider you use: `OPENAI_MODEL`, `AZURE_OPENAI_DEPLOYMENT`, `ANTHROPIC_MODEL`, `CLAUDE_OPUS_DEPLOYMENT`, `GEMINI_MODEL`, `OLLAMA_MODEL` or `CUSTOM_MODEL`. With none set, `qwed verify` and `qwed interactive` stop with an error naming the variable unless you pass `--model`. On the server, `ANTHROPIC_DEPLOYMENT` still wins over `ANTHROPIC_MODEL` when it is set. With an explicit `--base-url`, `qwed verify` reads no model variable, not even `CUSTOM_MODEL`, and needs `--model`. For Anthropic the CLI reads only `ANTHROPIC_MODEL` or `--model`. The server still starts, because it builds a provider on first use; logic, statistics and fact requests that need the provider then fail with that error, and the natural-language routes answer `UNVERIFIABLE`. `ACTIVE_PROVIDER`, and any provider a request names, must be one of `openai`, `openai_direct`, `openai_compat`, `azure_openai`, `anthropic`, `claude_opus`, `gemini`, `ollama` or `auto`.
> 2. **Pass `model=` in Python.** `QWEDLocal`, `verify()` and `qwed_sdk.integrations.langchain.QWEDTool` raise `ValueError` without it. The `qwed_sdk.langchain.QWEDTool` that calls a QWED server takes no model. If `qwed verify` ran the saved provider with the key in `QWED_API_KEY`, set that provider's key variable or pass `--api-key`.
> 3. **Update CSV export readers.** `GET /admin/compliance/export/csv` and `GET /audit/export` write a new, shared set of columns and at most the newest 1,000 rows. `X-Export-Truncated: true` is set when more rows matched.
> 4. **Write `*` between names you multiply.** `xy` is now one name, and `n` is a plain variable. A single letter before a bracket is still a product (`f(0)` is `f*0`). With no tolerance, `verify_math` needs a claim to match to 9 significant digits, or to be correctly rounded with at least 6, within 1e-6. `VerificationEngine.verify_math`'s `tolerance` defaults to `None`.
> 5. **Size the maths worker pool.** `/verify/math` and batch maths run in worker processes set by `QWED_MATH_WORKERS` (default 2), `QWED_MATH_TIMEOUT_SECONDS` (10), `QWED_MATH_MAX_WAITING` (32), `QWED_MATH_WORKER_MEMORY_MB` (2048, Linux only, `0` turns it off) and `QWED_BATCH_MATH_BUDGET_SECONDS` (120). A value these settings can't use stops the server at startup with an error naming the setting. In 9.0.0, a server started with `uvicorn qwed_new.api.main:app` reads the four `QWED_MATH_*` settings before `.env` loads: a value written only in `.env` is ignored and the default runs, and a value there that the server can't use makes every maths job `BLOCKED` (`math.worker_error`) instead of stopping the start. Set them in the process environment, or upgrade to 9.1.0.
> 6. **Expect more code refusals.** `CodeVerifier` refuses the modules, names and attributes in the advisories above, and a coding declaration other than UTF-8.
> 7. **Check Action gates.** `scan-code` also reports `CodeVerifier`'s code- and process-execution findings, refuses a Python file whose coding declaration isn't UTF-8 or whose bytes aren't valid UTF-8, and adds the `warnings_count` output.
> 8. **Upgrade every server process together.** Each audit row's `latency_ms` is now covered by its entry hash, so an 8.x process can't verify rows that 9.0.0 writes. An existing database gets the column at startup. Natural-language rows are stored with the domain `NATURAL_LANGUAGE`, not `MATH`; rows already written keep `MATH`. The audit row of a signed Verification Context document stores the document as returned, with any personal data in it; its `query` column is still redacted.
> 9. **Update callers of changed results.**
>    * A batch maths item proven false is `VERIFIED` with `is_valid` false and admission `BLOCKED`, as on `/verify/math`.
>    * Input the maths parser refuses is `BLOCKED` with `math.parse_error`, and Python that doesn't parse is `BLOCKED` with `code_verifier.parse_error`.
>    * `/auth/me` answers `403` for an inactive user or organisation or an unknown role, and sign-in refuses a user of an inactive organisation.
>    * A password over 72 bytes is `400` at sign-up and `401` at sign-in, not `500`.
> 10. **Move to `z3-solver==5.3.2.0`.** `qwed` now requires it (it required 5.1.0.0). The server image moves to sqlglot 30.25.0, anthropic 2.3.0, FastAPI 0.162.0, openai 3.41.0 and python-dateutil 3.0.0.
> 11. **Update the Go module path.** Change imports from `github.com/QWED-AI/qwed-verification/sdk-go/v8` to `github.com/QWED-AI/qwed-verification/sdk-go/v9`, then run `go get github.com/QWED-AI/qwed-verification/sdk-go/v9@v9.0.0`. The Go, TypeScript and Rust SDKs have no API changes in this release.

***

## QWED Verification — list agents, and owners read agent activity

**v9.0.0** · [GitHub PR #544](https://github.com/QWED-AI/qwed-verification/pull/544) · Closes [issue #527](https://github.com/QWED-AI/qwed-verification/issues/527)

> Owners and admins can now list their organization's agents and read any of those agents' activity without the agent's token. The activity route's `limit` is now capped at 200.

### What changed

* **New `GET /agents`.** Returns every agent in your organization, oldest first, with `id`, `name`, `type`, `status`, `created_at`, and `last_active`. Suspended and deactivated agents are included. `last_active` is `null` until the agent first authenticates. The response never includes the agent token or its digest. Owners and admins can call it with an API key or a signed-in session. Members and viewers get `403 Forbidden`, and a request with no credential gets `401 Unauthorized`.
* **Owner access to `GET /agents/{agent_id}/activity`.** The route also accepts an owner's or admin's API key or session for an agent in their organization. An agent in another organization, a missing agent, or an out-of-range ID returns `404 Not Found`. When you send `X-Agent-Token`, the token alone decides access, as before. A wrong token returns `401 Unauthorized`, even beside an owner's session.
* **Bounded `limit`.** The activity route's `limit` now accepts `1` to `200` (default `20`). It was unbounded. A value out of range returns `422 Unprocessable Entity`.
* **Ordering and timestamps.** Activity comes newest first, with ties broken by ID. Timestamps on both routes carry their UTC offset.

```bash theme={null}
curl --fail-with-body http://localhost:8000/agents \
  -H "Authorization: Bearer $QWED_SESSION_TOKEN"
```

See [`GET /agents`](https://docs.qwedai.tech/api/endpoints#get-%2Fagents) and [`GET /agents/{agent_id}/activity`](https://docs.qwedai.tech/api/endpoints#get-%2Fagents%2F%2Factivity).

***

## QWED Verification — session sign-in for key rotation and threats, richer key list, and user list

**v9.0.0** · [GitHub PR #531](https://github.com/QWED-AI/qwed-verification/pull/531) · [#540](https://github.com/QWED-AI/qwed-verification/pull/540) · [#541](https://github.com/QWED-AI/qwed-verification/pull/541) · [#549](https://github.com/QWED-AI/qwed-verification/pull/549)

> A dashboard signed in with a session can now rotate keys, read the threat summary, and list the organization's users without an API key. The key list and the user object return more of what QWED already stores.

### Added

* **Session sign-in for key rotation and the threat summary.** `POST /admin/keys/rotate` and `GET /admin/security/threats/{org_id}` accept `Authorization: Bearer <jwt>` with the same session checks as the compliance endpoints. QWED uses the API key when you send both, and returns `401` when you send neither. The role and organization rules are unchanged. `POST /agents/{agent_id}/token/reissue` still requires an API key.
* **Key list fields.** Each key from `GET /auth/api-keys` adds `expires_at` (`null` when the key doesn't expire), `rotation_required`, and `owner` (`{id, email}`). `owner` is `null` for a key with no user, or whose user isn't in the key's organization. `expires_at`, `created_at`, and `last_used_at` always carry their UTC offset. Who sees which keys is unchanged.
* **`org_name` on the user object.** Sign-up, sign-in, and `GET /auth/me` return `org_name`, the organization's display name, or `null` if QWED can't read it.
* **`GET /auth/users`.** Owners and admins list the users of their organization, oldest first, with an API key or a signed-in session. Each user has `id`, `email`, `role`, `is_active`, and `created_at`. Inactive users are included. Members and viewers get `403`.

### Fixed

* **Threat summary.** `GET /admin/security/threats/{org_id}` no longer fails on every call with the pinned SQLModel release. Its `generated_at` is now UTC with a `+00:00` offset.
* **Inactive accounts on `/auth/me`.** `GET /auth/me` now applies the user-session checks. An inactive user or organization, or an unknown role, returns `403` instead of an answer from a still-valid token. Sign-in refuses a user whose organization is inactive with `403` `"Organization is not active"`.
* **Passwords over 72 bytes.** `POST /auth/signup` and `POST /auth/signin` no longer return `500` for a password over 72 bytes in UTF-8. Sign-up refuses it with `400` `"Password must be at most 72 bytes"`, before checking the email. Sign-in answers `401` `"Invalid email or password"`. An account whose password was set over 72 bytes under bcrypt 4, which used only the first 72 bytes, can't sign in with that password.

See [Session sign-in for admin endpoints](https://docs.qwedai.tech/api/endpoints.md#session-sign-in-for-admin-endpoints), [`GET /auth/users`](https://docs.qwedai.tech/api/authentication#get-%2Fauth%2Fusers), and [`GET /auth/api-keys`](https://docs.qwedai.tech/api/authentication#get-%2Fauth%2Fapi-keys).

***

## QWED Verification — audit CSV exports carry verdict and admission; natural-language audit domain

**v9.0.0** · [GitHub PR #542](https://github.com/QWED-AI/qwed-verification/pull/542) · [GitHub PR #543](https://github.com/QWED-AI/qwed-verification/pull/543) · [GitHub PR #546](https://github.com/QWED-AI/qwed-verification/pull/546)

> Both CSV exports now write the same columns, including the stored verdict, admission, and latency, and take a period. Natural-language audit rows get their own domain, and every audit row stores its real latency.

### What changed

* **Shared CSV columns.** [`GET /admin/compliance/export/csv`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fcompliance%2Fexport%2Fcsv) and [`GET /audit/export`](https://docs.qwedai.tech/api/authentication#get-%2Faudit%2Fexport) write `ID`, `Timestamp`, `User ID`, `API Key ID`, `Verifier Version`, `Domain`, `Query`, `Verdict`, `Admission`, `Is Verified (legacy)`, `Result`, `Latency (ms)`, `Entry Hash`, `HMAC Signature`, and `Chain Status`. `Verdict` and `Admission` are the stored values, as `GET /audit/logs` returns them. A value QWED didn't record is an empty cell. No cell is truncated.
* **Formula guard.** A cell that starts with `=`, `+`, `-`, `@`, a tab, a carriage return, or a line feed gets a leading apostrophe, so spreadsheet software doesn't run it as a formula.
* **Export period.** Both exports take `start` and `end`. A time without a zone is UTC, and `start` after `end` returns `400`. Rows are newest first. The compliance export defaults to the 30 days before `end`, and `end` defaults to now. It checks the period after those defaults, so a future `start` with no `end` returns `400`.
* **Row cap.** Both exports hold at most the newest 1,000 rows and set `X-Export-Truncated: true` when more matched, otherwise `false`. CORS exposes the header. The compliance export had no cap before.
* **`NATURAL_LANGUAGE` audit domain.** `/verify/natural_language`, `/agents/{agent_id}/verify`, and natural-language `/verify/batch` items now write domain `NATURAL_LANGUAGE` (`engine_used` on `/audit/logs`, `Domain` in the CSV exports) instead of `MATH`. `/verify/math` and batch math items still write `MATH`. Rows already written keep `MATH`, because the domain is part of each signed, chained entry.
* **Real latency in the audit log.** `GET /audit/logs` and `GET /audit/logs/{log_id}` returned `latency_ms` as a fixed `0`. QWED now stores each row's latency: whole milliseconds from the start of the HTTP request to when QWED began writing the audit row, before it waits for the append lock, including the verifier and any LLM call. Rows written outside a request, and rows written before this change, return `null`. Both CSV exports carry it in a `Latency (ms)` column after `Result`, empty when `null`. The entry hash covers `latency_ms`, so changing or clearing it fails integrity verification. Rows hashed before this change still verify. QWED adds the `latency_ms` column to an existing database at startup. Upgrade every server process together: an 8.x process can't verify rows that 9.0.0 writes.

> [!WARNING]
>
> **Breaking for CSV consumers.** Update any client that reads the export columns by name or position.
>
> * **Before:** `GET /admin/compliance/export/csv` covered only the last 30 days, cut the query to 100 characters and the signature to 16, and had no verdict, admission, or result. `GET /audit/export` wrote `is_verified` as `verified` or `blocked` under `Status`, with no admission or caller.
> * **After:** Both write the 15 shared columns above in full. Read `Admission` for the decision. `Is Verified (legacy)` is the old flag as `Yes` or `No`.
> * **Audit domain:** Reports that group by `engine_used` or `Domain` now see `NATURAL_LANGUAGE` for new natural-language rows. `MATH` rows written before the upgrade can include natural-language requests.

***

## QWED Verification — `qwed init` can create an owner who can sign in

**v9.0.0** · [GitHub PR #547](https://github.com/QWED-AI/qwed-verification/pull/547) · Fixes [issue #524](https://github.com/QWED-AI/qwed-verification/issues/524)

> `qwed init` used to sign up with a generated email and password that it never showed or saved. Nobody could sign in to the organization it created, and its records could not be read in qwed-dashboard. You can now name the owner.

### What changed

* **`--owner-email`.** Names the organization owner. `QWED_OWNER_EMAIL` is read only when the flag is not given. An explicit empty or blank flag is refused, not replaced by the environment variable. Without either, interactive `qwed init` asks for the email after the organization name.
* **Hidden password prompt.** `qwed init` asks for the owner password with hidden input and a confirmation. The password is never a flag, and `qwed init` never prints or saves it.
* **Non-interactive mode.** With an owner email, the password comes from `QWED_OWNER_PASSWORD`. If it is missing, `qwed init` stops before the server starts. The local server `qwed init` starts does not inherit `QWED_OWNER_PASSWORD`.
* **No owner email.** `qwed init` keeps the generated owner and warns that nobody can sign in to the organization. Running `qwed init` again with `--owner-email` creates a new organization and does not recover the earlier one's records.
* **Checks.** The email needs a name before exactly one `@`, a domain with a dot inside it, and no whitespace. The password must be 1 to 72 UTF-8 bytes. An email that is already registered fails with `Email already registered` and tells you how to continue.
* **Output.** On success, `qwed init` prints `[ok] Owner: <email> (sign in with the password you set)`.

```bash theme={null}
export QWED_OWNER_PASSWORD="$CI_QWED_OWNER_PASSWORD"
qwed init --non-interactive --provider openai --api-key "$OPENAI_API_KEY" --model "$OPENAI_MODEL" --owner-email owner@example.com
```

See [Organization owner](https://docs.qwedai.tech/advanced/cli.md#organization-owner).

***

## QWED Verification — whole-chain trail scope and verifiable Verification Context audit rows

**v9.0.0** · [GitHub PR #551](https://github.com/QWED-AI/qwed-verification/pull/551) · [GitHub PR #550](https://github.com/QWED-AI/qwed-verification/pull/550)

### Fixed

* **The whole-chain check names the whole trail.** [`GET /admin/compliance/verify-trail`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fcompliance%2Fverify-trail) with no `start` or `end` checks every entry of the organization, but its `scope` said "audit entries recorded in the period". With neither bound, `scope` now reads "Signature and hash-chain check of every audit entry recorded for the organisation. It does not show that every request was recorded." With `start`, `end`, or both, it still names the period. The security activity summary always has a period, so its `audit_trail.scope` is unchanged. Only the text changed. The status, counts, and `proof_ref` are as before.
* **The audit row of a signed Verification Context document verifies.** [`POST /verification-context/from-diagnostic`](https://docs.qwedai.tech/api/endpoints#post-%2Fverification-context%2Ffrom-diagnostic) signs the document it returns, but its audit row stored a copy with personal data redacted after signing. That copy no longer matched its attestation, so a consumer reading it from `GET /audit/logs` couldn't verify it.
  * The row now stores the document exactly as returned. Its attestation matches the stored document, so the row verifies with `POST /verification-context/validate` until the attestation expires (`QWED_ATTESTATION_TTL_SECONDS`, 24 hours by default) and while the server keeps the signing key.
  * The row's `query` is still redacted.
  * The stored document keeps any personal data you put in the query or the diagnostic, including its `agent_message` and `developer_fields`. To keep personal data out of it, mask the data in the query of the original verification and send that query unchanged here: for a `VERIFIED` diagnostic the attestation covers the query, so masking it only at this call makes the document `BLOCKED`.
  * Rows written before this change keep their redacted copy, which may not verify against its attestation: it fails when the redaction changed a signed field.

***

## QWED Verification — startup hooks can be allowed by content digest

**v9.0.0** · [GitHub PR #553](https://github.com/QWED-AI/qwed-verification/pull/553) · Fixes [issue #491](https://github.com/QWED-AI/qwed-verification/issues/491)

> In a Python 3.11 venv, setuptools' `distutils-precedence.pth` (which uses `__import__(`) and coverage's `a1_coverage.pth` (which uses `exec(`) stopped the server at startup. Allowing them by name didn't help, because an allowed name is still scanned for those patterns. The only way through was `QWED_SKIP_ENV_INTEGRITY_CHECK=true`.

### What changed

* **Digest pins.** `QWED_ALLOWED_STARTUP_PTH_FILES` now also accepts `NAME.pth:sha256:<64 hex>`. QWED allows such a file only while its exact contents hash to that digest, and then doesn't pattern-scan it. A pin overrides a plain name for the same file.
* **Mismatches fail closed.** A pinned file whose contents differ fails the check and is counted as `digest_mismatch` in the guard's `counts`. A pin that isn't `NAME.pth:sha256:<64 hex>` stops startup with a `ValueError` instead of being read as a name.
* **Plain names unchanged.** A plain name works as before, and the suspicious patterns are unchanged. QWED ships no built-in digests, because these files change between setuptools and coverage versions.
* **Python SDK.** `StartupHookGuard` takes `pinned_pth_digests`, and `parse_startup_hook_allowlist` parses the variable's value.

See [Startup environment integrity](https://docs.qwedai.tech/advanced/security-hardening.md#llm05-supply-chain-vulnerabilities) for how to take a file's digest, and [StartupHookGuard](https://docs.qwedai.tech/sdks/guards.md#startuphookguard).

***

## QWED Verification — SQL and code verifier false-positive fixes, and Python 3.14

**v9.0.0** · [GitHub PR #554](https://github.com/QWED-AI/qwed-verification/pull/554) · [GitHub PR #555](https://github.com/QWED-AI/qwed-verification/pull/555) · [GitHub PR #560](https://github.com/QWED-AI/qwed-verification/pull/560) · [GitHub PR #569](https://github.com/QWED-AI/qwed-verification/pull/569)

> The SQL and code verifiers no longer refuse common safe queries and snippets, and statistics verification works on Python 3.14.

### What changed

* **SQL subqueries are checked, not blocked ([#554](https://github.com/QWED-AI/qwed-verification/pull/554)).** A query with an `IN (SELECT ...)`, a scalar subquery, or a subquery in `FROM` was `BLOCKED` with `sql_verifier.execution_error`. It is now checked like any other query. Nesting past `max_subquery_depth` (`3`) is `BLOCKED` with `sql_verifier.complexity_limit_exceeded`. `complexity.subquery_depth` now reports `0` for a `UNION` of two plain `SELECT` statements. See [Subqueries and complexity limits](https://docs.qwedai.tech/engines/sql.md#subqueries-and-complexity-limits).
* **A `;` in a string or comment is not a stacked query ([#555](https://github.com/QWED-AI/qwed-verification/pull/555)).** A `;` inside a string literal, a dollar-quoted string, or a comment no longer marks the query as `stacked_queries`. Two real statements still do. A query with a backslash, a `--` not followed by ASCII whitespace, a nested `/*`, a lone carriage return, or a parse failure still counts every `;`. See [Stacked queries](https://docs.qwedai.tech/engines/sql.md#stacked-queries).
* **`exec`, `eval`, and `compile` are matched as names ([#560](https://github.com/QWED-AI/qwed-verification/pull/560)).** `CodeVerifier` matches `eval`, `exec`, `compile`, `__import__`, `getattr`, and `setattr` on the AST, not as substrings. `cur.execute(...)`, `model.evaluate(x, y)`, and `re.compile(...)` in a snippet that imports `re` are no longer `dangerous_function`. Any line containing `__import__` is still reported as `dangerous_attribute`. The names stay refused as values, keywords, function or parameter names, imports, attributes, and in strings, including strings built from literals. Python that does not parse is now `BLOCKED` with `code_verifier.parse_error`. See [`eval`, `exec`, and `compile` are matched as names](https://docs.qwedai.tech/engines/code#eval-exec-and-compile-are-matched-as-names).
* **Statistics verification on Python 3.14 ([#569](https://github.com/QWED-AI/qwed-verification/pull/569)).** Python 3.14 removed `ast.Num` and `ast.Str`. On the server image, which runs 3.14, every `/verify/stats` request that passed the upload checks was `BLOCKED`, and `verify_python_deep` blocked code with a sink argument such as `os.system(request.args)`. Both failed closed and now work. Python 3.10 to 3.13 behave as before. See [Stats engine](https://docs.qwedai.tech/engines/stats.md#prerequisites).

***

## QWED Verification — math identities read diff(), and batch math matches /verify/math

**v9.0.0** · [GitHub PR #558](https://github.com/QWED-AI/qwed-verification/pull/558) · [GitHub PR #557](https://github.com/QWED-AI/qwed-verification/pull/557)

> **Behavior change.** `/verify/batch` math items with `=` now return the same verdict as `POST /verify/math`. A batch math claim proven false is `VERIFIED` with `is_valid: false`, a `proof_ref`, an `attestation`, and admission `BLOCKED`. It was `UNVERIFIABLE`.

### What changed

* **`diff()` in identities.** The expression parser evaluates `diff` and `Derivative`, so `diff(x**2, x) == 2*x` is `VERIFIED`. It was `BLOCKED` as an internal error. The total derivative order in one expression is at most 3, nested calls included. A derivative cannot appear inside a power, `factorial`, or `binomial`.
* **`==` reads as `=`** in an identity.
* **Leibniz notation is refused.** `d/dx(x^2) = 2x` was read as symbols `d` and `dx` and came back proven false. Now `d/dx`, `dy/dx`, `d(x^2)/dx`, `d^2y/dx^2`, and `d`-prefixed names that SymPy splits into letters (`dx`, `dtheta`, `data`) return `BLOCKED` with the message to write `diff(f, x)`. To use such a name as a symbol, pass it in `extra_symbols`. `extra_symbols` is an argument of `safe_parse_expr()` in the Python package only; `/verify/math` and batch items don't take it, so there rename the variable.
* **Parse errors are input errors.** Input the parser refuses is `BLOCKED` with `constraint_id: "math.parse_error"` and `Expression could not be parsed`, not `api.math.execution_error`. The parser's text is not returned. A batch math item the parser refuses gets the same result instead of a `failed` item.
* **Domain check covers derivatives.** `diff(sqrt(x), x)*2*sqrt(x) = 1` holds only for `x` other than 0, so it is `UNVERIFIABLE` with `math.domain_restricted_identity`. A non-zero numeric multiplier in a denominator no longer counts as a singular factor, so `2/(2*sqrt(x)) = 1/sqrt(x)` is `VERIFIED`.
* **Batch math equality items carry the route's fields.** Items with `=` include `left_side`, `right_side`, `simplified_difference`, `result`, and the route's messages (`Identity is true`, `Identity is false`), and keep `query`, `type`, and `diff`.
* **Undefined parts are not disproven.** A claim with a side that has an undefined part (one that evaluates to `zoo` or `nan`) that SymPy evaluated away, such as `log(0)**0 = 2`, is `UNVERIFIABLE` with `math.undefined_part` on both routes. `log(0)**0 = 1`, where the sides agree, is `UNVERIFIABLE` with `math.domain_restricted_identity`. `/verify/math` gave it a signed disproof before.

See [Derivatives in `/verify/math` identities](https://docs.qwedai.tech/engines/math#derivatives-in-%2Fverify%2Fmath-identities), [`POST /verify/math`](https://docs.qwedai.tech/api/endpoints#post-%2Fverify%2Fmath), and [Math items](https://docs.qwedai.tech/api/endpoints.md#math-items-proof-vs-simplification).

***

## QWED Verification — Claude providers stop sending `temperature`

**v9.0.0** · [GitHub PR #573](https://github.com/QWED-AI/qwed-verification/pull/573)

> **Bug fix.** Every Claude call on the server image raised `TypeError` before QWED sent the request. Claude translations were refused, and `verify_image` returned `ERROR`.

### What changed

* **No sampling parameters.** The Anthropic and Claude Opus providers no longer pass `temperature=0.0` to `messages.create`. The anthropic SDK has had no sampling parameters since 1.0, and Anthropic API version `2026-10-21`, which the 2.x SDK sends, refuses `temperature`, `top_p`, and `top_k`.
* **Default sampling.** Claude now samples with the model's defaults. No other setting replaces `temperature`. Sampling can vary translations and the verdicts `verify_fact` and `verify_image` return. QWED's fact and image verifiers record those verdicts only as advisory checks, never as `VERIFIED`. See [LLM configuration](https://docs.qwedai.tech/getting-started/llm-configuration.md#anthropic-claude).

***

## QWED Verification — AgentStateGuard gives one verdict on a JSON number

**v9.0.0** · [GitHub PR #571](https://github.com/QWED-AI/qwed-verification/pull/571)

> `AgentStateGuard` now checks a JSON number's text before converting it to `Decimal`, so it returns the same verdict on any Python build.

### What changed

* **Range and digit checks.** A number whose exponent is outside the C `decimal` module's range, or whose coefficient or exponent has more than `sys.get_int_max_str_digits()` digits (4300 by default), is rejected with `QWED-AGENT-STATE-102`. Leading zeros count toward the limit; a sign does not.
* **Same result with either decimal module.** On a Python built without the C `_decimal` module, a `number` field holding `1e9999999999999999999999` was previously `VERIFIED`. It is now blocked, with the same message as on the C module.
* **C module builds.** The server image builds `_decimal`. There, the only change is that a decimal with more than 4300 digits is now refused, as a JSON integer of that length already was.

See [AgentStateGuard](https://docs.qwedai.tech/advanced/agent-state-guard.md#security-considerations).

***

## QWED-MCP v0.2.3 — `qwedai.com` no longer trusted and MCP SDK pinned below 2.0

**Released: November 2, 2026** · [GitHub PR #72](https://github.com/QWED-AI/qwed-mcp/pull/72) · [GitHub PR #74](https://github.com/QWED-AI/qwed-mcp/pull/74) · [GitHub Release](https://github.com/QWED-AI/qwed-mcp/releases/tag/v0.2.3)

> **Security fix.** `SkillProvenanceGuard` no longer trusts `qwedai.com` as a skill source. QWED no longer uses that domain, and it lapses on 9 December 2026.

### What changed

* **`qwedai.com` removed from trusted domains.** The built-in trusted list is now `github.com`, `gitlab.com`, `bitbucket.org`, `pypi.org`, and `npmjs.com`. qwed-mcp 0.2.2 and earlier still trust `qwedai.com`, so upgrade with `pip install --upgrade qwed-mcp`. To trust another domain, pass it in `trusted_domains`. See [`trusted_domains`](https://docs.qwedai.tech/mcp/tools#constructor-parameters).
* **Requires `mcp>=1.30.0,<2`.** MCP Python SDK 1.30.0 patches GHSA-84m7-p3x7-pcfv, GHSA-rwrf-2pqf-9j8j, and GHSA-5h93-6whr-6q8j. MCP SDK 2.x removed the server API that qwed-mcp uses, so qwed-mcp stays on 1.x. qwed-mcp 0.2.2 allowed any `mcp>=1.2.0`, so a fresh install of 0.2.2 now picks up MCP SDK 2.x and fails at startup with `AttributeError: 'Server' object has no attribute 'list_tools'`; 0.2.3 fixes this. qwed-mcp runs over stdio and uses neither the SDK's HTTP transports nor its bearer authentication.
* **Reports its own version.** The server now sends qwed-mcp's version to MCP clients during initialization, instead of the MCP SDK's version.

***

## v8.0.0 — Attested verdicts and upgrade guide

**Released: October 26, 2026** · [GitHub PR #526](https://github.com/QWED-AI/qwed-verification/pull/526) · [GitHub PR #510](https://github.com/QWED-AI/qwed-verification/pull/510) · [GitHub PR #511](https://github.com/QWED-AI/qwed-verification/pull/511) · [GitHub Release](https://github.com/QWED-AI/qwed-verification/releases/tag/v8.0.0)

> **Major release.** Every `VERIFIED` answer now carries an ES256 attestation that anyone can check against the server's public keys at `GET /.well-known/jwks.json`. Several server settings, response shapes, and SDK types change. Read the upgrade steps below before you upgrade.

### What's new

* **Attestations on every `VERIFIED` answer.** The server signs the status, `proof_ref`, and statement hash of each `VERIFIED` answer, and the admission on routes that decide one. Callers verify the token against the keys published at `/.well-known/jwks.json`. See [Attestations](https://docs.qwedai.tech/advanced/attestations.md).
* **Configured signing key.** Set `QWED_SIGNING_KEY` (PEM text) or `QWED_SIGNING_KEY_FILE` (a path to a PEM file) to an unencrypted EC P-256 key. Attestations then verify across restarts and on every replica. Without either variable, each process generates its own key. See [Signing keys](https://docs.qwedai.tech/advanced/attestations.md#signing-keys).
* **Attestation lifetime.** `QWED_ATTESTATION_TTL_SECONDS` sets how long a token stays valid. The default is `86400` (24 hours), and the allowed range is `60` to `2592000` (30 days).

> [!WARNING]
>
> **Upgrading to 8.0.0.** These changes can break existing deployments and callers.
>
> 1. **Set `QWED_AUDIT_SECRET_KEY`.** The server refuses to start without it. Re-run `qwed init`, which keeps existing secrets, or set it yourself. See [Signed audit chain for API rows](#qwed-verification-—-api-audit-rows-written-through-the-signed-hash-chain).
> 2. **Set a signing key.** Before 8.0.0, no signing key setting existed. Now set `QWED_SIGNING_KEY` or `QWED_SIGNING_KEY_FILE` to an unencrypted EC P-256 PEM key. Unset, the server generates a key per process, and its attestations stop verifying after a restart or on another replica. Setting both variables, or a key that is unreadable, not P-256, or encrypted, stops the server at startup.
> 3. **Switch Docker scans to the Action.** Before 8.0.0, the `qwedai/qwed-verification` image ran the GitHub Action. From 8.0.0, it runs the API server on port 8000. Use the [GitHub Action](https://docs.qwedai.tech/advanced/github-action.md) for scans.
> 4. **Update the Go module path.** Change imports from `github.com/QWED-AI/qwed-verification/sdk-go/v7` to `github.com/QWED-AI/qwed-verification/sdk-go/v8`, then run `go get github.com/QWED-AI/qwed-verification/sdk-go/v8@v8.0.0`.
> 5. **Update the Rust crate.** Pin `qwed = "8.0.0"`. `VerificationResponse` has a new public `attestation` field, so every struct literal needs `attestation: None`.
> 6. **Pass `require_attestation=False` for unsigned documents.** `validate` and `resolve` in the API, the CLI, and the Python SDK now require a Verification Context document's attestation by default. For v1.0 documents, pass `require_attestation=False`, `"require_attestation": false`, or `--no-require-attestation`. See [Signed Verification Context documents](#qwed-verification-—-signed-verification-context-documents-and-attestation-checks).
> 7. **Update callers of changed routes.**
>    * `/agents/{agent_id}/verify` returns the enforced result with its `admission`, in the `/verify/natural_language` shape.
>    * `/verify/batch` logic and natural-language items return what their own routes return, for example `VERIFIED` rather than `SAT`.
>    * The compliance report drops its fixed `PASS` fields. Read `audit_trail` and `security_events_by_type` instead.
>    * A math claim proven false is `VERIFIED` with admission `DENY`, not `UNVERIFIABLE`.

### Also in 8.0.0

These changes were listed earlier as unreleased and ship in 8.0.0:

* [Pinned sandbox image and hardened container](#qwed-verification-—-pinned-sandbox-image-and-hardened-container)
* [CrossHair runs only in the Docker sandbox](#qwed-verification-—-crosshair-runs-only-in-the-docker-sandbox)
* [API audit rows written through the signed hash chain](#qwed-verification-—-api-audit-rows-written-through-the-signed-hash-chain)
* [Signed Verification Context documents and attestation checks](#qwed-verification-—-signed-verification-context-documents-and-attestation-checks)
* [Contacts and links moved to `qwedai.tech`](#qwed-verification-—-contacts-and-links-moved-to-qwedai-tech)

***

## QWED Verification — batch and agent verify routes return enforced, attested results

**v8.0.0** · [GitHub PR #500](https://github.com/QWED-AI/qwed-verification/pull/500) · [GitHub PR #501](https://github.com/QWED-AI/qwed-verification/pull/501) · [GitHub PR #502](https://github.com/QWED-AI/qwed-verification/pull/502)

> **Security fix.** Every verify route, every `/verify/batch` item, and `POST /agents/{agent_id}/verify` now sign a `VERIFIED` result and enforce its attestation as mandatory. Previously, most routes and batch items returned an authoritative `VERIFIED` with a `proof_ref` that no attestation was issued for or checked against.

> [!WARNING]
>
> **Breaking changes for `/verify/batch` and `/agents/{agent_id}/verify`.**
>
> Batch logic items now return the `/verify/logic` verdict instead of the solver's raw `SAT`/`UNSAT`:
>
> ```python theme={null}
> # Before
> if item["result"]["status"] == "SAT":
>     model = item["result"]["model"]
>
> # After
> result = item["result"]
> if result["status"] == "VERIFIED" and result["proof_ref"]:
>     model = result["developer_fields"]["model"]
> ```
>
> Batch natural-language items and the agent route now return the `/verify/natural_language` shape with `admission`. `final_answer`, `translation`, and `trust_boundary` are no longer returned:
>
> ```python theme={null}
> # Before
> if response["status"] == "VERIFIED":
>     answer = response["final_answer"]
>
> # After
> if response["admission"] == "ADMIT":
>     answer = response["calculated_value"]
> ```

### What changed

* **Mandatory attestation on every verify route.** QWED signs each `VERIFIED` result, binding its status, `proof_ref`, and the query, then enforces the attestation. A natural-language verdict binds the translated expression instead of the query. Results that are not `VERIFIED` are unchanged.
* **Fail closed on signing failure.** A `VERIFIED` result whose attestation cannot be issued, or does not match, is returned `BLOCKED` with no `proof_ref`. `developer_fields` keeps `verdict_status`, `verdict_proof_ref`, and `engine` beside the block for the audit trail.
* **Batch logic items** return the `/verify/logic` response: `VERIFIED` with a `proof_ref` for SAT, `UNVERIFIABLE` for UNSAT, and `BLOCKED` otherwise. The control plane's output stays under `developer_fields`.
* **Batch natural-language items** return the `/verify/natural_language` response, with `admission`.
* **Every batch item type takes its route's trust step.** Code, fact, and SQL items had no trust step before. A code item's `admission` is decided on the enforced verdict.
* **Batch math signing failure is `BLOCKED`.** It was `UNVERIFIABLE`. The attestation's engine is `math` (was `batch_math`).
* **`POST /agents/{agent_id}/verify`** returns the enforced result with its `admission`, not the control plane's raw response, which could still say `VERIFIED` after enforcement blocked it. The agent activity log records the same response.

See [`POST /verify/batch`](https://docs.qwedai.tech/api/endpoints#post-%2Fverify%2Fbatch), [`POST /agents/{agent_id}/verify`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2F%2Fverify), and [Attestation enforcement](https://docs.qwedai.tech/api/endpoints.md#attestation-enforcement).

***

## QWED Verification — audit log paging, caller and admission fields, and security events

**v8.0.0** · [GitHub PR #495](https://github.com/QWED-AI/qwed-verification/pull/495) · [#496](https://github.com/QWED-AI/qwed-verification/pull/496) · [#498](https://github.com/QWED-AI/qwed-verification/pull/498) · [#499](https://github.com/QWED-AI/qwed-verification/pull/499)

> You can now page through the whole audit log, and each audit row records the admission decision its route made, the API key that wrote it, and the server version. Owners and admins can list their organization's security events.

### Added

* **Audit log paging.** `GET /audit/logs` returns rows newest first, by timestamp and then ID. The response adds `next_before`, the last row's ID when the page is full, otherwise `null`. Pass it back as `before` for the next page. A `before` that is not a log in your organization, or is outside the SQL `BIGINT` range, returns `404`. The same applies to out-of-range IDs on `GET /audit/logs/{log_id}`. `total` is still the number of rows on the page.
* **Caller and version fields.** Audit rows store nullable `api_key_id` and `verifier_version`, returned by both log routes. `api_key_id` is `null` for signed-in sessions, `POST /audit/seed`, and `POST /agents/{agent_id}/verify`.
* **Admission field.** Audit rows store nullable `admission`: `ADMIT` or `BLOCKED`, or `ADMIT` or `DENY` for `POST /verification-context/from-diagnostic`. It is `null` for routes that decide no admission (logic, stats, fact, image, RAG, process, consensus, and batch items other than code) and for older rows.
* **`GET /admin/security/events`.** Owners and admins list their own organization's security events with an API key or a signed-in session. It supports `before`/`next_before` paging, `limit` from 1 to 200 (default 50), and an optional `event_type`. QWED masks credential-shaped text in the response.
* **API key `last_used_at`.** QWED now sets `last_used_at` after a key passes every check, at most once a minute per key. `GET /auth/api-keys` returns it. A failed write never fails the request.

The entry hash covers `admission`, `api_key_id`, and `verifier_version`. QWED adds the three columns to an existing table at startup, and rows written by earlier releases still verify.

> [!WARNING]
>
> **Don't roll back past this release.** Upgrade every QWED process together. An older release can't verify rows written with the new fields. When such a row is an organization's chain head, the older release refuses every further append for that organization and its verify routes return `500` (fail closed).

See [Audit endpoints](https://docs.qwedai.tech/api/authentication.md#audit-endpoints), [`GET /admin/security/events`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fsecurity%2Fevents), and [Audit trail verification](https://docs.qwedai.tech/advanced/compliance.md#audit-trail-verification).

***

## QWED Verification — Python SDK, integrations, and GitHub Action fail closed

**v8.0.0** · [GitHub PR #526](https://github.com/QWED-AI/qwed-verification/pull/526)

> **Security fix.** Six advisories fix clients that reported content as verified when the QWED server had not admitted it. They affect all versions up to 7.3.0. Upgrade with `pip install -U qwed` and pin the Action to `QWED-AI/qwed-verification@v8.0.0`.

> [!WARNING]
>
> **Behavior change.** `QWEDClient.verify_code` and `verify_sql` no longer answer offline from the local `CodeGuard` and `SQLGuard`. They always call the server and return `UNVERIFIABLE` when it can't be reached. The LlamaIndex, CrewAI, and LangChain integrations no longer return `verified=True` for content no engine checked, or when a check raises. If your code relied on offline `verify_code` or `verify_sql`, or on `verified=True` from an integration, it now receives an unverified result. Treat it as a rejection.

### Security

* **`is_verified` follows the server's admission** ([GHSA-jppc-7r58-7pjh](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-jppc-7r58-7pjh), high). Where the route returns an `admission`, only `ADMIT` admits. Otherwise, `status` must be `VERIFIED` and `is_valid` must be `true`. Consensus reads the server's status and requires `meets_requirement` to be exactly `True`. The new `VerificationResult.admission` field carries the server's decision.
* **`QWEDClient.verify_code` always asks the server** ([GHSA-4rj3-8wqj-cqwc](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-4rj3-8wqj-cqwc), high). `CodeGuard` also blocks reflective calls, `__builtins__`, dunder attributes, `importlib`, `builtins`, `ctypes`, `marshal`, and dotted imports. It marks its own pass as `advisory`.
* **`QWEDClient.verify_sql` always asks the server, and dangerous SQL functions are denied** ([GHSA-45jv-2j44-4c2q](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-45jv-2j44-4c2q), high). `SQLVerifier` denies `DANGEROUS_SQL_FUNCTIONS`, such as `load_extension()`, `pg_read_file()`, and `xp_cmdshell()`, anywhere in a statement, with issue type `dangerous_function`. `SQLGuard` allows exactly one statement and checks `SELECT ... INTO`, executable comments, and functions.
* **`QWEDLocal.verify_code` uses `CodeVerifier`** ([GHSA-v8qq-v427-qrqc](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-v8qq-v427-qrqc), high). It returns `CodeVerifier`'s admission. Without the full `qwed` package, it returns `UNVERIFIABLE`. Cached code results from the old AST check are not reused.
* **The LlamaIndex, CrewAI, and LangChain integrations fail closed** ([GHSA-83v6-6h23-pvp4](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-83v6-6h23-pvp4), high). They read `is_verified`. A response no engine checked is `UNVERIFIABLE`, and an error is unverified.
* **The GitHub Action's outputs can't be injected** ([GHSA-j788-44pc-8hp3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-j788-44pc-8hp3), low). Outputs are written with a random heredoc delimiter, and the Action stops workflow commands for the rest of its step. Verify mode gates on `is_verified` and reports the server's verdict, for example `VERIFIED · DENY`.

See [Python SDK](https://docs.qwedai.tech/sdks/python.md#how-is_verified-is-decided), [Guards](https://docs.qwedai.tech/sdks/guards.md#codeguard), [SQL engine](https://docs.qwedai.tech/engines/sql.md#dangerous-functions), [QWEDLocal](https://docs.qwedai.tech/advanced/qwed-local.md#3-code-security-codeverifier), [LlamaIndex](https://docs.qwedai.tech/integrations/llamaindex.md), [CrewAI](https://docs.qwedai.tech/integrations/crewai.md), [LangChain](https://docs.qwedai.tech/integrations/langchain.md), and [GitHub Action](https://docs.qwedai.tech/advanced/github-action.md#how-verify-mode-decides).

***

## QWED Verification — pinned sandbox image and hardened container

**v8.0.0** · [GitHub PR #465](https://github.com/QWED-AI/qwed-verification/pull/465) · Closes [issue #322](https://github.com/QWED-AI/qwed-verification/issues/322)

> **Security fix.** The stats and consensus code sandbox now runs an image pinned by digest in a hardened container. Previously, it pulled the movable `amancevice/pandas:slim` tag and ran it with a writable root, the default Linux capabilities, and the image's default root user.

### Security

* **Digest-pinned image.** The sandbox runs `amancevice/pandas@sha256:9257b889…` (the `slim` tag as published on August 10, 2026). The new `QWED_SANDBOX_IMAGE` variable can name another image, but only by digest. QWED refuses any other value and uses the pinned image.
* **Hardened container.** The container has a read-only root, drops all capabilities, sets `no-new-privileges`, and never runs as a privileged root. `/workspace` is the only writable path.
* **Per-execution workspace owner.** The container runs as the owner of the private `0700` workspace directory. When the server process is root, each execution gets a fresh uid from the new `QWED_SANDBOX_UID_RANGE` (default `1879048192-2147483646`). QWED skips uids owned by host accounts or groups, delegated in `/etc/subuid` or `/etc/subgid`, or used by another running execution.
* **Unknown user mappings fail closed.** A root process with a rootless daemon, a `userns-remap` daemon, or a daemon whose security options can't be read is reported as `SECURE_RUNTIME_UNAVAILABLE`.

See [Sandbox image and container user](https://docs.qwedai.tech/advanced/self-hosting.md#sandbox-image-and-container-user).

***

## QWED Verification — CrossHair runs only in the Docker sandbox

**v8.0.0** · [GitHub PR #464](https://github.com/QWED-AI/qwed-verification/pull/464) · Closes [issue #431](https://github.com/QWED-AI/qwed-verification/issues/431)

> **Security fix.** CrossHair imports the module it analyzes, which runs that module's top-level code. The Symbolic engine used to run CrossHair as a plain subprocess on the host. It now runs CrossHair only inside a hardened Docker container, with no host fallback.

> [!WARNING]
>
> **Behavior change.** Without Docker or the sandbox image, `SymbolicVerifier.verify_code()` returns `BLOCKED` with `constraint_id = "symbolic_verifier.runtime_unavailable"`. This replaces `symbolic_verifier.crosshair_not_available`. Build the image before you use the Symbolic engine:
>
> ```bash theme={null}
> docker build -t qwed-crosshair-sandbox:0.0.101 docker/crosshair-sandbox
> ```

### What changed

* **Docker-only execution.** Every CrossHair run uses a container with no network, a read-only root filesystem, no host mounts, all capabilities dropped, `no-new-privileges`, user 65534, and the existing memory, CPU, PID, and concurrency limits. Nothing is written on the host.
* **`symbolic_verifier.runtime_unavailable`.** QWED returns `BLOCKED` when Docker is unreachable, no sandbox slot is free, the image is missing, CrossHair is missing from the image, or the `docker` Python package is not installed. A verifier-created sandbox reconnects once Docker is reachable again.
* **`symbolic_verifier.input_too_large`.** Code over 120 KiB returns `BLOCKED`.
* **Sandbox image.** Build it from `docker/crosshair-sandbox/Dockerfile`. The default tag is `qwed-crosshair-sandbox:0.0.101`. Set `QWED_CROSSHAIR_IMAGE` to use another tag. QWED never pulls the image at runtime.
* **`qwed[symbolic]` installs the `docker` client.**
* **Trusted outcomes only.** A clean run is only ever "no counterexample found" (`UNVERIFIABLE`). An exit code of 1 counts as a counterexample only together with CrossHair's own report line (`<file>:<line>: error: ...`). Any other early exit, or a `SystemExit` raised by the analyzed code, is an engine error.
* **Functions addressed by module qualname.** QWED names functions `target.func` and methods `target.Class.method`. The previous `file.py:func` form never found the function, so CrossHair's exit was misreported as a counterexample for every typed function. Nested functions are unverifiable.

See [Symbolic execution limits](https://docs.qwedai.tech/advanced/symbolic-limits.md#docker-sandbox-requirement).

***

## QWED Verification — API audit rows written through the signed hash chain

**v8.0.0** · [GitHub PR #459](https://github.com/QWED-AI/qwed-verification/pull/459) · [GitHub PR #492](https://github.com/QWED-AI/qwed-verification/pull/492) · [GitHub PR #493](https://github.com/QWED-AI/qwed-verification/pull/493)

> Every `/verify/*` endpoint (including each `/verify/batch` item), the Verification Context routes, `POST /agents/{agent_id}/verify`, and `POST /audit/seed` now append audit rows through `AuditLogger`. New rows are HMAC-signed and hash-chained per organization, so `GET /admin/compliance/verify/{log_id}` and `verify_audit_trail` can verify them. Previously these rows were written without a hash, signature, or previous hash.

> [!WARNING]
>
> **Breaking change.** The API server refuses to start without `QWED_AUDIT_SECRET_KEY`. Projects onboarded with `qwed init` before this release have no audit key in `.env`.
>
> To migrate:
>
> 1. Re-run `qwed init`. It adds `QWED_AUDIT_SECRET_KEY` to `.env` and keeps your existing secrets. Or generate a key and set it yourself:
>
>    ```bash theme={null}
>    python -c "import secrets; print(secrets.token_urlsafe(48))"
>    ```
>
> 2. Set `QWED_AUDIT_SECRET_KEY` in every environment that runs the API server (`.env`, Docker Compose, Kubernetes secret).
>
> 3. Restart the server on this release so it loads the key. `qwed init` refuses a running server that holds a different audit key or doesn't return `audit_hmac`.
>
> 4. Don't rotate the key afterwards. A new key invalidates the signature on every audit row already written.

### What changed

* **Fail-closed audit writes.** If an audit append fails (key unset, a chain head that fails verification, a continuity mismatch, or a database error), the request returns `500` with `audit persistence failed` instead of a result with no audit row.
* **Legacy rows.** Rows written before this change have no hash. `verify_log_entry` reports them as `"legacy": true` and never `"valid": true`. The first chained row after them opens a new segment with no `previous_hash`. `verify_audit_trail` lists them under `legacy_entries` and stays `"valid": false` while any are in range. Pass `start_date` to verify from the upgrade onwards.
* **Single writer.** The audit logger keeps each organization's chain head in memory. Run one process per database. A second writer makes the next append fail with `500` rather than fork the chain.
* **`/verify/consensus` audit rows** store the result JSON like every other endpoint.
* **`POST /audit/seed` is off by default.** It returns `404` and writes nothing unless `QWED_ENABLE_AUDIT_SEED=1`. Seeded rows are marked `{"sample": true}` and timestamped when written.
* **CSV export.** The `Hash Chain Valid` column is now `Chain Status`, with values `Chained` or `Legacy`. A present hash is not a verified chain.
* **`GET /health/identity`** also returns `audit_hmac`, and returns `503` when `QWED_AUDIT_SECRET_KEY` is unset.
* **`/verify/batch` and `/agents/{agent_id}/verify` write audit rows.** Until this fix they wrote none: batch items were missing from the audit trail, and agent verifications were logged only to the unsigned agent activity log. A batch now appends one chained row per item, failed items included. If an item's append fails, the item is marked `failed` with `result: null` and `error: "audit persistence failed"`. An agent verification appends the row `/verify/natural_language` writes, plus `agent_id`. If that append fails, the request returns `500` and no activity is logged.
* **Audit log routes return the stored verdict and chain entry.** `GET /audit/logs` and `GET /audit/logs/{log_id}` add `verdict` (`VERIFIED`, `UNVERIFIABLE`, `BLOCKED`, or `null`), read from the stored result rather than from `is_verified`. The list adds `entry_hash`. The detail adds `entry_hash`, `previous_hash`, and `hmac_signature`. `status` is unchanged. `verdict` is not an admission decision: a disproven claim is `VERIFIED` with `status` `blocked`, so don't gate on it alone.

Verification still does not detect removal of the newest entries. That needs an externally stored checkpoint of the latest hash.

See [Audit trail verification](https://docs.qwedai.tech/advanced/compliance.md#audit-trail-verification) and [Environment variables reference](https://docs.qwedai.tech/advanced/deployment.md#environment-variables-reference).

***

## QWED Verification — logic DSL compiles IFF and quantifiers and rejects Boolean coercion

**v8.0.0** · [GitHub PR #461](https://github.com/QWED-AI/qwed-verification/pull/461) · [GitHub PR #462](https://github.com/QWED-AI/qwed-verification/pull/462) · [GitHub PR #463](https://github.com/QWED-AI/qwed-verification/pull/463) · Closes [issue #428](https://github.com/QWED-AI/qwed-verification/issues/428) and [issue #432](https://github.com/QWED-AI/qwed-verification/issues/432)

> The logic DSL compiler now handles every operator the parser accepts, and it no longer turns `True`/`False` into `1`/`0`. Python-style constraints with `!=`, `>=`, `<=`, or `*` now verify instead of returning `BLOCKED`.

### What changed

* **`IFF`, `FORALL`, and `EXISTS` compile.** Previously, programs using them failed with `Unknown operator`. `(IFF a b)` requires Boolean operands. `(IFF x y)` on two `Int` variables is a compile error, not `x == y`.
* **Quantifier syntax.** Write `(FORALL x body)` or `(EXISTS x body)`. The binder must be a plain variable name. The parser blocks lists such as `(x)`, numbers, operator names, and literals. The bound variable takes its declared sort (`Int` if undeclared) and is scoped to the body. The body must be Boolean. A solver `unknown` stays `UNKNOWN`/`BLOCKED`.
* **No Boolean coercion.** `EQ`/`NEQ` between a Boolean and a non-Boolean value returns `ERROR`. Ordering comparisons and arithmetic reject Boolean operands. An undeclared name compared with `True`/`False` must be declared `Bool`.
* **Natural-language bridge operator names.** The bridge emits `NEQ`, `GTE`, `LTE`, and `MULT` instead of `NE`, `GE`, `LE`, and `MUL`, so constraints like `x >= 5` verify. `And(...)` and `Or(...)` call forms no longer return `ERROR`.

> [!WARNING]
>
> **Behavior change.** `(EQ flag True)` with `flag` undeclared used to return `SAT` with `flag = 1`. It now returns `ERROR`. Declare Boolean variables as `Bool`.

See [Logic engine type rules](https://docs.qwedai.tech/engines/logic.md#type-rules) and [Quantifiers](https://docs.qwedai.tech/api/dsl-reference.md#quantifiers).

***

## QWED Verification — proven-false math claims are `VERIFIED` with admission `DENY`

**v8.0.0** · [GitHub PR #460](https://github.com/QWED-AI/qwed-verification/pull/460) · Closes [issue #437](https://github.com/QWED-AI/qwed-verification/issues/437), [issue #438](https://github.com/QWED-AI/qwed-verification/issues/438), [issue #429](https://github.com/QWED-AI/qwed-verification/issues/429)

> A math claim proven false, such as `2+2=5`, is now `VERIFIED` with `is_valid: false` and admission `DENY`. It used to come back as `UNVERIFIABLE`, which recorded a definite counter-proof as "could not be proven". Admission was `DENY` either way, so nothing unsafe passed, but audit rows and Verification Context documents carried the wrong meaning.

> [!WARNING]
>
> **Behavior change.** `VerificationEngine` no longer emits `CORRECTION_NEEDED`. A mismatch decided by a concrete computation now returns the legacy status `DISPROVEN`. If your code branches on `status == "CORRECTION_NEEDED"`, update it:
>
> ```python theme={null}
> # Before
> if result["status"] == "CORRECTION_NEEDED":
>     handle_wrong_answer(result)
>
> # After: engine dictionaries
> if result["status"] == "DISPROVEN":
>     handle_wrong_answer(result)
>
> # After: API responses — gate on admission, not on status
> if response["admission"] != "ADMIT":
>     reject(response)
> ```

### What changed

* **Proven false is a decided verdict.** `2+2=5` returns `VERIFIED` with `is_valid: false`, a `proof_ref`, and admission `DENY`. The control plane signs and attests it like a true claim, with `verified: false` in the attestation. The new `DiagnosticResult.disproven()` constructor builds this result.
* **Only decided mismatches are disproven.** Symbolic checks (derivatives, indefinite integrals, limits, identities) return `DISPROVEN` only when SymPy decides the difference is non-zero. An undecided difference stays `UNVERIFIABLE` with admission `DENY`.
* **Domain-restricted identities.** An identity that only holds after cancelling a removable singularity, such as `x/x = 1` or `(x**2-1)/(x-1) = x+1`, returns `UNVERIFIABLE` with `constraint_id: math.domain_restricted_identity`.
* **Float residue and non-finite values.** A mismatch caused by float rounding residue (`0.1 + 0.2 = 0.3`) or by an overflowed or `NaN` value is `INCONCLUSIVE` (`UNVERIFIABLE`), not disproven.
* **Exact matrix, integral, and unit checks.** Matrix operations read float inputs as the decimals you wrote. Unit conversion uses exact unit definitions and returns `BLOCKED` for units of different dimensions, such as `m` to `kg`.
* **`POST /verify/math` returns `admission`.** The audit log row's `is_verified` follows `is_valid`, so a proven-false identity is no longer logged as verified.
* **`POST /verify/natural_language` returns the enforced verdict.** The response carries the control-plane verdict with `admission`, `translated_expression`, `verification_scope: translated_expression_only`, and `query_semantics_verified: false`. Admission is always `DENY` because the LLM translation is unverified.
* **`qwed init` and `qwed test` print the verdict with its admission.** For example, `2+2=5 -> VERIFIED (false) | DENY`. A failed self-check prints `FAILED`, and the closing line reads `All engines ready.`

See [Math engine — Verdicts and admission](https://docs.qwedai.tech/engines/math.md#verdicts-and-admission), [`POST /verify/math`](https://docs.qwedai.tech/api/endpoints#post-%2Fverify%2Fmath), and [CLI reference](https://docs.qwedai.tech/advanced/cli.md).

***

## QWED Verification — GitHub Action scans use mode defaults and deny empty scans

**v8.0.0** · [GitHub PR #472](https://github.com/QWED-AI/qwed-verification/pull/472) · Closes [issue #466](https://github.com/QWED-AI/qwed-verification/issues/466)

> The GitHub Action's `paths` input defaulted to `.`, which the file matcher rejects, so `scan-code`, `verify-shell`, and `scan-secrets` exited with a traceback before scanning. A pattern that matched no files reported `VERIFIED · ADMIT`.

> [!WARNING]
>
> **Behavior change.** Previously green jobs can now fail. A `paths` value under which no file is scanned, or a `scan-code` run that includes Python files that don't parse, now returns `UNVERIFIABLE · DENY` and fails the step unless `fail_on_findings` is `"false"`. Narrow `paths` to the files you want scanned.

### What changed

* **Mode defaults for `paths`.** `paths` now defaults to empty. Unset, empty, `.`, or `./` uses the mode's own patterns: `**/*.py` for `scan-code`, `**/*.sh` for `verify-shell`, and env, JSON, YAML, TOML, and INI files for `scan-secrets`.
* **Empty and partial scans are denied.** A scan is `VERIFIED · ADMIT` only when at least one file was scanned, every pattern expanded without error, every matched file was read and parsed, and nothing was found. Otherwise it is `UNVERIFIABLE · DENY` with no `proof_ref`.
* **Errors instead of skips.** Unreadable files, Python files that don't parse, absolute patterns, and files outside the workspace (including symlinks to files elsewhere) are reported by name and deny the scan.
* **No `scan-secrets` fallback.** `scan-secrets` no longer scans the default files when the given `paths` match nothing.
* **New `files_scanned` output.** Reports the number of files scanned. `0` means the scan was not admitted.

See [Scan paths](https://docs.qwedai.tech/advanced/github-action.md#scan-paths).

***

## QWED Verification — session sign-in and whole-chain check for compliance endpoints

**v8.0.0** · [GitHub PR #497](https://github.com/QWED-AI/qwed-verification/pull/497) · Closes [issue #485](https://github.com/QWED-AI/qwed-verification/issues/485)

> Signed-in owners and admins can call the compliance endpoints without an API key, and a new endpoint checks the whole audit chain in one request. Existing API-key requests work as before.

### What changed

* **Session sign-in.** `GET /admin/compliance/export/csv`, `GET /admin/compliance/verify/{log_id}`, `GET /admin/compliance/report/soc2/{org_id}`, and the new `GET /admin/compliance/verify-trail` accept `Authorization: Bearer <jwt_token>` for an active `owner` or `admin` in an active organization. An invalid token returns `401 Unauthorized`. A wrong role or an inactive user or organization returns `403 Forbidden`. When both credentials are sent, QWED uses the API key. Other admin endpoints still require an API key.
* **Whole-chain check.** `GET /admin/compliance/verify-trail?start=&end=` checks the signature and hash chain of every audit entry in your organization over an optional period. Omit both bounds to check the whole chain. A `start` later than `end` returns `400 Bad Request`.
* **First failing entry.** The trail check and the security activity summary's `audit_trail` return `first_failed_entry_id`, the lowest entry ID that failed.
* **Faster large checks.** QWED reads audit entries in ordered pages of 500 instead of querying per entry.

```bash theme={null}
curl --fail-with-body -H "Authorization: Bearer $QWED_SESSION_TOKEN" \
  "http://localhost:8000/admin/compliance/verify-trail"
```

See [Session sign-in for admin endpoints](https://docs.qwedai.tech/api/endpoints.md#session-sign-in-for-admin-endpoints) and [`GET /admin/compliance/verify-trail`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fcompliance%2Fverify-trail).

***

## QWED Verification — security activity summary replaces the SOC 2 report

**v8.0.0** · [GitHub PR #490](https://github.com/QWED-AI/qwed-verification/pull/490) · Closes [issue #476](https://github.com/QWED-AI/qwed-verification/issues/476)

> `GET /admin/compliance/report/soc2/{org_id}` now returns a security activity summary computed from QWED's records. It previously returned fixed `PASS` strings that nothing checked and called itself a SOC 2 Type II report. The URL and the `owner`/`admin` gate are unchanged.

> [!WARNING]
>
> **Breaking for consumers of this response.** `compliance_status`, `security_controls`, `threats_blocked`, and `block_rate` are removed. `threats_blocked` and `block_rate` were always `0`. Read `audit_trail` and `metrics.security_events_by_type` instead:
>
> ```python theme={null}
> response = requests.get(
>     f"{base_url}/admin/compliance/report/soc2/{org_id}",
>     headers={"X-API-Key": api_key},
>     timeout=30,
> )
> response.raise_for_status()  # 401, 403 or 5xx: no summary to read
> report = response.json()
>
> # Before (report_version "1.0"): fixed strings, never computed.
> # trail_ok = report["compliance_status"]["audit_trail_complete"] == "PASS"
> # blocked = report["metrics"]["threats_blocked"]
>
> # After (report_version "2.0"): computed from the audit trail and recorded events.
> trail_ok = report["audit_trail"]["status"] == "VERIFIED"
> events = report["metrics"]["security_events_by_type"]  # e.g. {"SECRET_LEAK_AUTO_REVOKED": 1}
> ```

### What changed

* **Not a SOC 2 report.** `report_type` is `"Security activity summary"`, `report_version` is `"2.0"`, and a `disclaimer` states that the summary is not a SOC 2 report or an auditor's attestation.
* **Events counted by real type.** `metrics.security_events_by_type` counts security events by the event types QWED actually records.
* **Audit trail check.** `audit_trail` runs `verify_audit_trail` over your organization's last 90 days. `status` is `VERIFIED` only when entries exist and all pass, with `evidence` and a `sha256:` `proof_ref`. `NO_ENTRIES` means the period has no entries. `NOT_VERIFIED` means an entry failed, a legacy entry exists, or the check couldn't run (for example, `QWED_AUDIT_SECRET_KEY` unset). In that last case the entry counts are `null`.
* **Bounded error list.** `errors` lists up to 20 errors. `total_errors` and `errors_truncated` show whether the list was cut.
* **Scope.** The `scope` field states that the check covers recorded entries only. It doesn't show that every request wrote an entry.

See [Security activity summary](https://docs.qwedai.tech/advanced/compliance.md#1-security-activity-summary).

***

## QWED Verification — role checks on API-key and audit routes, hashed agent tokens

**v8.0.0** · [GitHub PR #483](https://github.com/QWED-AI/qwed-verification/pull/483) · [GitHub PR #487](https://github.com/QWED-AI/qwed-verification/pull/487) · Addresses [issue #478](https://github.com/QWED-AI/qwed-verification/issues/478)

### Security

* **Role checks on `/auth/api-keys` and `/audit` (#483).** These routes previously required only a signed-in user, so any role could create keys, list and revoke every key in the organization, and export the audit log. Now creating a key needs `member` or higher. Members and viewers list and revoke only their own keys. Admins can revoke keys of users who don't rank higher, and owners can revoke any key in the organization. Every role can read audit logs. `GET /audit/export` and `POST /audit/seed` need `admin` or `owner`. See [Roles on API-key and audit routes](https://docs.qwedai.tech/api/authentication.md#roles-on-api-key-and-audit-routes).
* **Fail-closed user checks (#483).** An inactive user, an inactive or missing organization, or an unknown role gets `403 Forbidden`. A member or viewer gets `404 Not Found` for another user's key. An invalid key ID returns `404 Not Found` instead of a server error.
* **Agent tokens stored as keyed digests (#487).** QWED stores agent tokens as HMAC-SHA256 digests keyed with `QWED_API_KEY_LOOKUP_SECRET`, instead of plain text. A token is shown only once, at registration or re-issue. At startup, the server hashes existing plaintext tokens in place, so they keep working. Changing `QWED_API_KEY_LOOKUP_SECRET` now invalidates agent tokens as well as API keys. See [Agent token storage](https://docs.qwedai.tech/api/authentication.md#agent-token-storage).
* **New `POST /agents/{agent_id}/token/reissue` (#487).** Members and above can issue a new token for an agent in their own organization. The old token stops working. Another organization's agent returns `404 Not Found`. See [`POST /agents/{agent_id}/token/reissue`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2F%2Ftoken%2Freissue).

***

## QWED Verification — Docker image runs the API server and the Compose stack builds

**v8.0.0** · [GitHub PR #474](https://github.com/QWED-AI/qwed-verification/pull/474) · Closes [issue #467](https://github.com/QWED-AI/qwed-verification/issues/467)

> The `qwedai/qwed-verification` image now runs the API server, and `deploy/docker-compose.yml` builds and starts the full stack.

> [!WARNING]
>
> **Image behavior change.** Docker Hub now publishes the API server as `qwedai/qwed-verification`. `latest` tracks `main`, and versioned tags start at 8.0.0. Earlier tags ran the QWED GitHub Action. If you ran the image as a repository scanner, switch to the [GitHub Action](https://docs.qwedai.tech/advanced/github-action.md).
>
> ```bash theme={null}
> docker run -p 8000:8000 \
>   -v qwed-data:/var/lib/qwed \
>   -e QWED_CORS_ORIGINS="https://app.yourcompany.com" \
>   -e API_KEY_SECRET \
>   -e QWED_JWT_SECRET_KEY \
>   -e QWED_API_KEY_LOOKUP_SECRET \
>   -e QWED_AUDIT_SECRET_KEY \
>   qwedai/qwed-verification:8.0.0
> ```

### What changed

* **`server` image target.** The `Dockerfile` has a `server` target that runs uvicorn on port 8000 as UID 1000, with `/var/lib/qwed` as the working directory for the default SQLite database. A health check calls `/health`. `UVICORN_HOST` and `UVICORN_PORT` set the bind address and port, and the health check follows `UVICORN_PORT`. The GitHub Action stays the default target.
* **Working Compose stack.** Run `docker compose -f deploy/docker-compose.yml up -d` from the repository root after copying `deploy/.env.example` to `deploy/.env`. Compose refuses to start until `QWED_CORS_ORIGINS`, `API_KEY_SECRET`, `QWED_JWT_SECRET_KEY`, `QWED_API_KEY_LOOKUP_SECRET`, `QWED_AUDIT_SECRET_KEY`, and `DATABASE_URL` are set. The Postgres and Grafana containers refuse to start while their passwords are empty. Grafana no longer falls back to `admin`/`admin`.
* **Stack fixes.** Postgres and Grafana receive only their own settings. Grafana ships with Prometheus and Jaeger data sources. Prometheus scrapes the API at `qwed-core:8000`. The API server keeps its data in the `qwed-data` volume.
* **`docker` client in the `server` extra.** `qwed[server]` now includes `docker>=7.0.0`. Previously, a request that reached the code executor failed with an `ImportError` instead of the sandbox's fail-closed result.
* **Kubernetes pins 8.0.0.** The manifest uses `docker.io/qwedai/qwed-verification:8.0.0`, the first server image. The Secret lists `QWED_JWT_SECRET_KEY`, `QWED_API_KEY_LOOKUP_SECRET`, and `QWED_AUDIT_SECRET_KEY`, and ships every value empty.

See [Self-hosting](https://docs.qwedai.tech/advanced/self-hosting.md) and [Deployment](https://docs.qwedai.tech/advanced/deployment.md#docker-deployment).

***

## QWED Verification — Go, TypeScript, and Rust SDKs read the DiagnosticResult envelope

**v8.0.0** · [GitHub PR #479](https://github.com/QWED-AI/qwed-verification/pull/479) · [GitHub PR #481](https://github.com/QWED-AI/qwed-verification/pull/481)

> The Go (`sdk-go/v8`), TypeScript (`@qwed-ai/sdk`), and Rust (`qwed`) SDKs now model the server's [DiagnosticResult](https://docs.qwedai.tech/advanced/diagnostics.md) envelope and gate on admission. Previously, the SDKs read a top-level `verified` field that most endpoints don't send. Go's `Verified`, TypeScript's `verified`, and both `isVerified()` helpers were always false. Rust failed to deserialize every `verify*` response, and Go failed to decode `VerifyMath` and `VerifyLogic` responses.

> [!WARNING]
>
> **Breaking type changes.** Code that reads `verified`, `summary`, `engine`, or `result`, or matches on the old statuses, no longer compiles or no longer behaves the same.
>
> * **Statuses.** `VerificationStatus` is `VERIFIED`, `UNVERIFIABLE`, `BLOCKED`, or `Unknown`. The old `FAILED`, `CORRECTED`, `ERROR`, `TIMEOUT`, and `UNSUPPORTED` values are removed. Unknown server status text is kept in `raw_status` / `RawStatus` / `rawStatus` and is never admitted.
> * **New `Admission` enum.** `ADMIT`, `BLOCKED`, or `Unknown`. It's absent where the endpoint makes no decision: `/verify/logic`, `/verify/fact`, `/verify/rag`, `/verify/process`, and batch logic and fact items.
> * **Envelope fields.** Responses expose `status`, `admission`, `proof_ref`, `agent_message`, `is_authoritative`, `constraint_id`, and `developer_fields`. Endpoint fields such as `is_valid` and `issues` move to `extra` (Rust), `Extra` (Go), or top-level keys (TypeScript).
> * **Batch responses.** `BatchResponse` mirrors the batch job: `job_id`, `status`, `progress_percent`, `total_items`, `completed_items`, `failed_items`, and `items[]` with a per-item `result`. `summary` is removed.
> * **Deprecated.** `is_verified()` / `IsVerified()` / `isVerified()` and the `verified` / `Verified` fields now follow admission.
>
> Read `admission` and call the admission helper instead:
>
> ```typescript theme={null}
> // Before
> if (result.verified) { proceed(); }
>
> // After
> import { isAdmitted } from '@qwed-ai/sdk';
> if (isAdmitted(result)) { proceed(); }
> ```
>
> ```go theme={null}
> // Before
> if result.Verified { proceed() }
>
> // After
> if result.IsAdmitted() { proceed() }
> ```
>
> ```rust theme={null}
> // Before
> if result.verified { proceed(); }
>
> // After
> if result.is_admitted() { proceed(); }
> ```

### What changed

* **Admission gating.** `is_admitted()` (Rust), `IsAdmitted()` (Go), and `isAdmitted(response)` (TypeScript) return `true` only for `ADMIT` with `VERIFIED` and a non-empty `proof_ref`. `VERIFIED · BLOCKED` results, such as a false identity like `2 + 2 = 5` or unsafe code, are not admitted. A missing or unknown admission is never admitted.
* **Batch items.** Rust's `BatchResult` and Go's `BatchResult` have their own `is_admitted()` / `IsAdmitted()`. In TypeScript, call `isAdmitted(item.result)`.
* **Batch math and SQL items carry admission ([PR #503](https://github.com/QWED-AI/qwed-verification/pull/503)).** `/verify/batch` math and SQL items now return the `admission` their single-item route decides, and their audit rows store it. A proven math or SQL item is now admitted by the SDK helpers, as the same answer from `/verify/math` or `/verify/sql` already was. A batch math identity that holds only on a restricted domain, such as `x/x = 1`, is `UNVERIFIABLE`. A batch SQL item without `params.schema_ddl` fails with `Missing 'schema_ddl'`.
* **TypeScript parsers.** `parseVerificationResponse()` and `parseBatchResponse()` are exported for responses you fetch yourself. They throw `QWEDError` with code `QWED-SDK-PARSE-001` or `QWED-SDK-PARSE-002` for a body that doesn't match.
* **Rust errors.** A response body that doesn't match the envelope returns `Error::Serialization`.
* **Go numbers.** Numbers in `Extra` and `DeveloperFields` decode as `json.Number` and keep their exact text.

See the [Rust SDK](https://docs.qwedai.tech/sdks/rust.md#reading-a-result), [Go SDK](https://docs.qwedai.tech/sdks/go.md#reading-a-result), and [TypeScript SDK](https://docs.qwedai.tech/sdks/typescript.md#reading-a-result).

***

## QWED Verification — signed Verification Context documents and attestation checks

**v8.0.0** · [GitHub PR #517](https://github.com/QWED-AI/qwed-verification/pull/517) · [GitHub PR #523](https://github.com/QWED-AI/qwed-verification/pull/523) · Part of [issue #319](https://github.com/QWED-AI/qwed-verification/issues/319)

> **Security fix.** A document's `proof_ref` is a commitment anyone can compute, so anyone could build a document, an `ADMIT` one included, whose `proof_ref` resolves. The server now signs every document it issues, and `validate` and `resolve` check that signature by default.

### What changed

* **Spec v1.1.** A [v1.1 document](https://docs.qwedai.tech/specs/verification-context.md#version-1-1-and-the-issuer-attestation) carries `attestation`: an ES256 JWT of type `qwed-context+jwt` that signs the hash of every other field, with the verdict, admission, and `proof_ref`.
* **Signed documents.** [`POST /verification-context/from-diagnostic`](https://docs.qwedai.tech/api/endpoints#post-%2Fverification-context%2Ffrom-diagnostic) returns v1.1 documents. A failure to sign is a `500`, not an unsigned document. A document the canonical encoding refuses is a `422`.
* **API checks.** `POST /verification-context/validate` and `/resolve` take `require_attestation` (default `true`) and check the attestation with the server's key. A document with none returns `attestation_required`; one that doesn't verify or isn't this document's returns `attestation_invalid`.
* **Python SDK.** `validate_verification_context()` and `resolve_verification_context()` verify the attestation against the server's JWKS first, and raise `KeysUnavailableError` if they can't fetch the keys. `verify_document_attestation()` returns the claims. See [Python SDK](https://docs.qwedai.tech/sdks/python.md#verification-context).
* **CLI.** `qwed context validate` and `resolve` verify the attestation with `--server` or `--jwks-file`. See [CLI reference](https://docs.qwedai.tech/advanced/cli.md#qwed-context-verification-context-utilities).
* **PyJWT 2.8 and 2.9.** The SDK's `verify_attestation` failed with `AttributeError` for every token on these versions. It now works on every version the `pyjwt>=2.8.0` range allows.

### Action required

* **v1.0 documents.** They carry no attestation, so `validate` and `resolve` now refuse them. Pass `require_attestation=False` in the SDK, `"require_attestation": false` to the API, or `--no-require-attestation` to the CLI. Older SDK versions don't send the flag.
* **Signing key.** Set `QWED_SIGNING_KEY` or `QWED_SIGNING_KEY_FILE` so the documents a server issues still verify after a restart or on another replica.

***

## QWED Verification — contacts and links moved to `qwedai.tech`

**v8.0.0** · [GitHub PR #473](https://github.com/QWED-AI/qwed-verification/pull/473)

> `qwedai.tech` is now the only QWED domain. Contact addresses, docs links, and the Verification Context schema `$id` all use it.

### What changed

* **Contacts.** Report security vulnerabilities to `rahul@qwedai.tech`. For support, email `support@qwedai.tech`.
* **Alert email default.** `ALERT_EMAIL_TO` now defaults to `rahul@qwedai.tech`. Set the variable to send alerts to a different address.
* **Links.** Website, docs, and finance links point to `qwedai.tech`, `docs.qwedai.tech`, and `finance.qwedai.tech`. Error messages link straight to the docs page, and `QWEDSyntaxError` now shows its docs link like the other QWED errors.
* **No hosted API.** References to API hosts that never existed are gone. SDK custom base URL examples now use your self-hosted server at `http://localhost:8000`, and the `500` error hint tells you to check the server logs.
* **Schema `$id`.** The Verification Context schema `$id` is now `https://qwedai.tech/schemas/verification-context/v1.0`. Tools that look the schema up by its old `$id` need the new one.
* **`qwed init` signup address.** `qwed init` signs up its local bootstrap account with an `example.com` address. The organization slug is cut to fit the 64-character limit on an email local part, so a long organization name no longer makes signup fail with `422`.

See [Verification Context specification](https://docs.qwedai.tech/specs/verification-context.md), [Go SDK](https://docs.qwedai.tech/sdks/go.md#configuration), and [Rust SDK](https://docs.qwedai.tech/sdks/rust.md#configuration).

***

## v7.3.0 — Five advisories, tenant scoping, and credential hardening

**Released: October 16, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.3.0) · [GitHub PR #458](https://github.com/QWED-AI/qwed-verification/pull/458) · [GitHub PR #453](https://github.com/QWED-AI/qwed-verification/pull/453) · [GitHub PR #454](https://github.com/QWED-AI/qwed-verification/pull/454) · [GitHub PR #455](https://github.com/QWED-AI/qwed-verification/pull/455) · [GitHub PR #457](https://github.com/QWED-AI/qwed-verification/pull/457)

> v7.3.0 is a fail-closed security and soundness release. It covers five advisories published on October 16, 2026: [GHSA-6p94-4f75-g2wq](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-6p94-4f75-g2wq), [GHSA-4phf-6h72-9xfg](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-4phf-6h72-9xfg), [GHSA-j3x4-v3p2-4mq3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-j3x4-v3p2-4mq3), [GHSA-6cpx-8jrq-r7h3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-6cpx-8jrq-r7h3), and [GHSA-g25v-44j8-gg7x](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-g25v-44j8-gg7x). Three of them complete earlier incomplete fixes. They affect all versions up to 7.2.3. Upgrading is recommended. It also rolls up the tenant-scoping, denial-of-service, and credential fixes listed below. No response field is removed or renamed. The soundness eval now covers 84 known-answer claims with 0 false accepts.

> [!WARNING]
>
> **Behaviour changes.** Every engine change is stricter and fails closed.
>
> * **Sandbox:** Stats and consensus code now runs under a module and builtin allowlist. Code that imports a module outside the allowlist (for example `os`, `sys`, or `subprocess`) fails with `ImportError`. See [Runtime module and builtin allowlist](https://docs.qwedai.tech/engines/stats.md#runtime-module-and-builtin-allowlist).
> * **SQL:** DML inside a CTE or subquery, administrative statements (`COPY`, `ATTACH`, `PRAGMA`, `CALL`, ...), and any root that isn't a read-only query (`SELECT`, `UNION`, `INTERSECT`, `EXCEPT`) are now denied. With `schema_ddl`, `SELECT *` is expanded and rejected when it covers a blocked column. Tautologies are folded by value (`OR 'a'='a'`, `OR 2>1`). All of these return `VERIFIED` with `developer_fields.is_valid: false`, which `admission_decision` maps to `BLOCKED`.
> * **Code:** `os.posix_spawn*`, `os.fork`, `pty.fork`, `importlib` and `runpy` dynamic import, `ctypes` loaders, the raw `posix`/`nt` modules, and file and network sinks, including the bare `open` builtin, are blocking findings.
> * **Logic:** The natural-language bridge rejects categorical string constants such as `tier == 'gold'` and returns `UNVERIFIABLE` or an error. Encode categories as integers.

### Security fixes (advisories)

* **SQL statements are classified across the whole query tree** ([GHSA-6p94-4f75-g2wq](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-6p94-4f75-g2wq), high). Destructive DML hidden in a CTE or subquery, administrative statements, and any top-level statement that isn't a read-only query are reported as invalid wherever they appear. Before, only the root statement type was checked. See [SQL engine](https://docs.qwedai.tech/engines/sql.md#statements-are-classified-across-the-whole-query-tree).
* **Code verification blocks alternate process-execution routes and file or network sinks** ([GHSA-4phf-6h72-9xfg](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-4phf-6h72-9xfg), high; completes GHSA-6rxr-gq43-694g). New `process_execution` and `file_or_network_access` findings are `CRITICAL`. See [Code engine](https://docs.qwedai.tech/engines/code.md#file-and-network-access-is-never-admitted).
* **The code-execution sandbox isolates its namespace** ([GHSA-j3x4-v3p2-4mq3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-j3x4-v3p2-4mq3), high; completes GHSA-6rxr-gq43-694g). User code runs with reduced builtins and a guarded import that admits only analysis modules such as numpy, pandas, and scipy. It replaces a static denylist that could be bypassed through package re-exports and reflection.
* **SQL `SELECT *` is expanded against the schema and tautologies are folded by value** ([GHSA-6cpx-8jrq-r7h3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-6cpx-8jrq-r7h3), moderate). See [Schema compliance](https://docs.qwedai.tech/engines/sql.md#schema-compliance).
* **The logic natural-language bridge rejects string constants** ([GHSA-g25v-44j8-gg7x](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-g25v-44j8-gg7x), moderate; completes GHSA-mfh5-3c8f-975p). Contradictory categorical claims could previously come back `VERIFIED`. See [Logic engine](https://docs.qwedai.tech/engines/logic.md#categorical-string-constants-fail-closed).

### Soundness and access control

* **Admin endpoints are scoped to the caller's tenant** and RBAC is enforced (#450). See [tenant-scoped admin endpoints](#qwed-verification-—-tenant-scoped-admin-endpoints-and-working-rbac).
* **`Money` rejects `NaN` and infinite amounts**, and `verify_invoice_total` never raises (#451).
* **The SDK's `verify_fact` fails closed** with `UNVERIFIABLE` when the service is unreachable (#449).

### Hardening and resource limits

* **Request bodies are capped and blocking work runs off the event loop** (#447). See [Request limits](https://docs.qwedai.tech/api/endpoints.md#request-limits).
* **Concurrent sandbox containers are bounded** (#448). See [Availability limits](https://docs.qwedai.tech/advanced/self-hosting.md#availability-limits).
* **API-key rotation is atomic** (#453). QWED revokes the old key and mints the replacement in one transaction, so concurrent rotations of the same key mint exactly one replacement. Rotating a key that is already inactive or revoked mints nothing and returns `404 Not Found`. See [`POST /admin/keys/rotate`](https://docs.qwedai.tech/api/endpoints#post-%2Fadmin%2Fkeys%2Frotate).

### Credential handling

* **Expired and revoked API keys are rejected at the auth boundary** (#454). Before, an expired key that was still marked active kept authenticating on `/verify/*` routes. See [Authentication](https://docs.qwedai.tech/api/authentication.md#api-keys).
* **Redis and Postgres connection URLs are redacted before logging** (#454), so an embedded password is never written to logs.
* **Provider import is https-only** (#454). `qwed provider import` rejects `http://` URLs and any redirect that downgrades to `http://`. See [Custom providers](https://docs.qwedai.tech/getting-started/custom-providers.md#import-a-community-provider).
* **Provider error messages are sanitized** (#455). The Anthropic, Azure OpenAI, Claude Opus, and Gemini providers log full SDK error detail for operators and raise a generic error to callers.

### SDKs

* **The Go module path is versioned** (#457). Import `github.com/QWED-AI/qwed-verification/sdk-go/v7`. Releases are tagged `sdk-go/vX.Y.Z`, so `go get github.com/QWED-AI/qwed-verification/sdk-go/v7@v7.3.0` resolves. See [Go SDK](https://docs.qwedai.tech/sdks/go.md).

### Upgrading

Pin `qwed==7.3.0` on PyPI, `@qwed-ai/sdk@7.3.0` on npm, `qwed = "7.3.0"` on crates.io, `qwedai/qwed-verification:7.3.0` on Docker, or `QWED-AI/qwed-verification@v7.3.0` as a GitHub Action, then redeploy. If your sandboxed stats or consensus code imports modules outside the allowlist, your SQL uses `SELECT *` over sensitive columns or runs non-`SELECT` statements, or your logic claims use string constants, expect those to be rejected after the upgrade.

***

## QWED Verification — tenant-scoped admin endpoints and working RBAC

**Released: October 15, 2026** · [GitHub PR #450](https://github.com/QWED-AI/qwed-verification/pull/450) · Closes [issue #222](https://github.com/QWED-AI/qwed-verification/issues/222)

> **Security fix.** Admin endpoints now authorize by the API key owner's role and act only on the caller's own organization. Previously, role checks never ran, so every admin endpoint returned `401 Unauthorized`. The endpoints also accepted organization, log, and key IDs from the caller without an ownership check, which would have allowed cross-tenant access once role checks worked.

### What changed

* **Working role checks.** QWED authenticates the API key, then checks the owning user's role. The key must be live (not revoked or expired), and the owning user and organization must be active, with the user in the key's organization. Otherwise QWED returns `403 Forbidden`.
* **Tenant scoping with `403`.** `GET /admin/compliance/export/csv`, `GET /admin/compliance/report/soc2/{org_id}`, and `GET /admin/security/threats/{org_id}` return `403 Forbidden` for another organization's ID.
* **Tenant scoping with `404`.** `GET /admin/compliance/verify/{log_id}` and `POST /admin/keys/rotate` return `404 Not Found` for an ID in another organization, without revealing that it exists.
* **Key rotation rules.** Members can rotate only their own keys. Owners and admins can rotate other users' keys only when the key owner doesn't hold a higher role. Revoked or inactive keys return `404 Not Found`. The new secret is returned only for a key in the caller's organization.

See [Admin endpoints](https://docs.qwedai.tech/api/endpoints.md#admin-endpoints).

***

## QWED Verification — `verify_fact` fails closed offline and `Money` rejects non-finite amounts

**Released: October 14, 2026** · [GitHub PR #449](https://github.com/QWED-AI/qwed-verification/pull/449) · [GitHub PR #451](https://github.com/QWED-AI/qwed-verification/pull/451)

> `QWEDClient.verify_fact` no longer reports a claim as verified when the QWED service can't be reached. `Money` rejects NaN and Infinity amounts, and `verify_invoice_total` reports invalid inputs as a failed check instead of raising.

> [!WARNING]
>
> **Behavior change.** `verify_fact` now returns `UNVERIFIABLE` when verification doesn't complete:
>
> ```python theme={null}
> # Before: status="VERIFIED", is_verified=True after only a local PII scan.
> # After: status="UNVERIFIABLE", is_verified=False.
> result = client.verify_fact(claim=claim, context=context)
> if result.status == "UNVERIFIABLE":
>     reject(claim)
> ```

### What changed

* **`verify_fact` fails closed when the service is unreachable (#449).** On a connection failure or a connect or read timeout, the Python SDK returns `status="UNVERIFIABLE"` and `is_verified=False` with a message explaining that remote verification was unavailable. Previously, a connection failure returned `VERIFIED` after only the local PII scan, and a timeout raised an exception. See [`verify_fact`](https://docs.qwedai.tech/sdks/python#verify_fact-claim-context).
* **`Money` rejects non-finite amounts (#451).** `Money("NaN")`, `Money("Infinity")`, and `Money(float("inf"))` raise `ValueError`.
* **`verify_invoice_total` never raises on invalid input (#451).** Invalid amounts, a negative or non-finite `tolerance`, or a non-`Decimal` `tolerance` return `(False, reason)` instead of raising. Currency mismatches still return `(False, "Currency mismatch: ...")`.

```python theme={null}
from decimal import Decimal
from qwed_new.core.money import Money, verify_invoice_total

subtotal = Money("1000.00", "USD")
tax = Money("180.00", "USD")
claimed_total = Money("1180.00", "USD")

ok, reason = verify_invoice_total(subtotal, tax, claimed_total, tolerance=Decimal("-1"))
# (False, "Invalid tolerance: Decimal('-1') must be a finite, non-negative Decimal")
```

***

## QWED Verification — request body caps, timeouts, and sandbox concurrency limits

**Released: October 14, 2026** · [GitHub PR #447](https://github.com/QWED-AI/qwed-verification/pull/447) · [GitHub PR #448](https://github.com/QWED-AI/qwed-verification/pull/448)

> Availability hardening for self-hosted servers. Oversized bodies, slow providers, unbounded batch jobs, and concurrent sandbox containers can no longer stall or exhaust the API. Verdicts and fail-closed behavior are unchanged.

### What changed

* **Body cap on every `/verify` route.** JSON routes accept up to 1,000,000 bytes. `/verify/stats` and `/verify/image` keep their larger upload caps. Oversized bodies return `413` before processing. See [Request limits](https://docs.qwedai.tech/api/endpoints.md#request-limits).
* **Blocking work runs off the event loop.** `/verify/code` runs under `QWED_CODE_VERIFY_TIMEOUT` (default 10 seconds) and returns `503` on timeout. Provider calls for `natural_language`, `logic`, and `fact` run under `QWED_PROVIDER_TIMEOUT` (default 30 seconds) and fail closed on timeout.
* **Bounded batch job store.** `/verify/batch` keeps at most `QWED_BATCH_MAX_JOBS` jobs (default 1000) and evicts finished jobs first. When every stored job is active, new submissions return `503`. Items that exceed `QWED_BATCH_ITEM_TIMEOUT` are marked failed.
* **Sandbox concurrency limit.** Each server process runs at most `QWED_SANDBOX_MAX_CONCURRENT` sandbox containers (default 4). A request that can't get a slot within `QWED_SANDBOX_ACQUIRE_TIMEOUT` (default 10 seconds) is rejected with `Sandbox capacity reached — retry shortly.`

See [Availability limits](https://docs.qwedai.tech/advanced/self-hosting.md#availability-limits) for the full configuration reference.

***

## v7.2.3 — Code, secret, and SQL verification security release

**Released: October 13, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.2.3) · [GitHub PR #441](https://github.com/QWED-AI/qwed-verification/pull/441) · [GitHub PR #444](https://github.com/QWED-AI/qwed-verification/pull/444)

> v7.2.3 is a fail-closed security release covering three advisories published on October 13, 2026: [GHSA-6rxr-gq43-694g](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-6rxr-gq43-694g), [GHSA-64xf-wffc-63fm](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-64xf-wffc-63fm), and [GHSA-q5g6-6cw2-cjm3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-q5g6-6cw2-cjm3). They affect all versions up to 7.2.2. Upgrading is recommended. No breaking wire changes.

> [!WARNING]
>
> **Behaviour changes.** All three fixes make the engines stricter. No response field is removed or renamed.
>
> * **Code:** code that runs a shell command or subprocess is no longer admitted by the code verifier, even with a constant argument or through an aliased import. Run such commands through a reviewed, sandboxed tool instead.
> * **Secrets:** OpenAI `sk-proj-`, `sk-svcacct-`, and `sk-admin-` keys are now detected, so content that previously passed may now be flagged.
> * **SQL:** MySQL and MariaDB executable comments (`/*! ... */`) are expanded and analysed as SQL, whatever the declared dialect. The new field `developer_fields.executable_comments_expanded` reports when this happened. With `schema_ddl`, a reference to an undeclared table now fails admission with `is_valid: false` and the new constraint `sql_verifier.schema_violation`. Without `schema_ddl`, tables are not checked, as before.

### Security fixes

* **Code verification never admits shell or process execution** ([GHSA-6rxr-gq43-694g](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-6rxr-gq43-694g)). `os.system`, `os.popen`, the `os.exec*` and `os.spawn*` families, `os.posix_spawn*`, `os.startfile`, `subprocess.*`, `pty.spawn`, and `asyncio.create_subprocess_*` are now blocking `process_execution` findings. This includes calls through aliased imports such as `import subprocess as sp`, `from os import system as run_it`, and `from os import *`. Before, they were warnings and the code was admitted. `verify_code`, `verify_python_deep`, and `POST /verify/code` are covered. See [Code engine](https://docs.qwedai.tech/engines/code.md#shell-and-process-execution-is-never-admitted).
* **Project-scoped OpenAI keys are detected** ([GHSA-64xf-wffc-63fm](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-64xf-wffc-63fm)). `ConfigGuard` and the code verifier detect `sk-proj-`, `sk-svcacct-`, and `sk-admin-` keys as well as legacy keys. See [ConfigGuard](https://docs.qwedai.tech/sdks/guards.md#configguard).
* **SQL in executable comments and tables outside the schema are rejected** ([GHSA-q5g6-6cw2-cjm3](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-q5g6-6cw2-cjm3)). Executable comments (`/*! ... */`, `/*!NNNNN ... */`, `/*M! ... */`) are expanded before analysis, so the SQL inside them is checked. With `schema_ddl`, a query on an undeclared table is `BLOCKED` with `sql_verifier.schema_violation` instead of being admitted with a warning. CTE names are not treated as tables. See [SQL engine](https://docs.qwedai.tech/engines/sql.md#schema-compliance).

### Soundness eval

From v7.2.3 on, each GitHub release includes a soundness eval report (`eval-results.json`). The eval runs claims with a known answer through the Math, Logic, SQL, Code, and Schema engines, including reproductions of advisories where an engine returned a false `VERIFIED`. The v7.2.3 report covers 76 claims with 0 false accepts.

### Version bumps

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `7.2.2` | `7.2.3` |
| `qwed_sdk` (Python) | `7.2.2` | `7.2.3` |
| `@qwed-ai/sdk` (npm) | `7.2.2` | `7.2.3` |
| `qwed` (crates.io) | `7.2.2` | `7.2.3` |
| API version marker | `7.2.2` | `7.2.3` |

### Upgrading

Pin `qwed>=7.2.3` on PyPI, `@qwed-ai/sdk@^7.2.3` on npm, and `qwed = "7.2.3"` on crates.io, then redeploy. If your generated code runs shell commands, or your SQL queries reference tables outside the `schema_ddl` you pass, expect those to be blocked after the upgrade.

***

## QWED-A2A — request size limits, engine budget, and code guard hardening

**Released: October 12, 2026** · [GitHub PR #164](https://github.com/qwed-ai/qwed-a2a/pull/164) · [GitHub PR #165](https://github.com/qwed-ai/qwed-a2a/pull/165) · [GitHub PR #166](https://github.com/qwed-ai/qwed-a2a/pull/166) · [GitHub PR #169](https://github.com/qwed-ai/qwed-a2a/pull/169)

> Four fail-closed fixes to the A2A runtime. Oversized requests are rejected before they reach an engine. Engines run off the event loop under a wall-clock budget. The code guard sees through aliases and re-bindings. `TrustBoundary` is safe to share across threads.

### What changed

* **`max_payload_size_bytes` is enforced.** It was declared but never read. `POST /a2a/intercept` returns `413` for bodies over the limit plus a 64 KiB envelope allowance, before buffering. An invalid `Content-Length` returns `400`. `intercept()` returns `blocked` with engine `size_limit` for oversized payloads, which also covers library callers. The code guard blocks code over 256 KiB before parsing and returns `unverifiable` for non-string code. Keep a reverse-proxy body limit too.
* **Engine execution budget.** New `engine_timeout_seconds` config (float, default `5.0`, greater than 0 and at most 60). Engines run on a dedicated pool (4 running, 32 waiting), so health, metrics, and JWKS stay responsive. A timeout returns `error` with engine `engine_timeout`. Overload returns `error` with engine `engine_busy`. Call the new `A2AVerificationInterceptor.close()` from your shutdown hook. A run that already started cannot be killed, and long calls that hold the GIL can still delay the loop.
* **Code guard alias detection.** The AST layer resolves import aliases, alias chains, re-bound functions, and annotated, walrus, and tuple-unpacking bindings. It also blocks `posix`, `nt`, `builtins`, and `__builtins__` receivers, `os` exec/spawn/`posix_spawn`/`startfile`, star imports from dangerous modules, and `getattr` on a dangerous module. Known gaps are documented. A clean scan stays a non-admissible `heuristic_pass`.
* **Thread-safe `TrustBoundary`.** Every public method runs under a re-entrant lock. Rate limits hold under concurrency, and eviction no longer races. Trust scopes are snapshotted as frozensets at grant time.

See [Request size limits](https://docs.qwedai.tech/a2a/interceptor.md#request-size-limits), [Engine execution budget](https://docs.qwedai.tech/a2a/interceptor.md#engine-execution-budget), [Code guard](https://docs.qwedai.tech/a2a/interceptor.md#code-guard), and [Thread safety](https://docs.qwedai.tech/a2a/trust-boundary.md#thread-safety).

***

## QWED-Tax v0.2.1 — classification claim vocabulary and strict facts

**Released: October 10, 2026** in `qwed-tax` 0.2.1 (PyPI and npm `@qwed-ai/tax`) · [GitHub PR #81](https://github.com/QWED-AI/qwed-tax/pull/81) · [Changelog](https://github.com/QWED-AI/qwed-tax/blob/main/CHANGELOG.md)

> `qwed-tax` v0.2.1 is a security patch. Upgrading is recommended for all users of `NexusGuard` (Python and npm) and `ClassificationGuard`. The release also ships the structured nexus claim described in the [August 31](#qwed-tax-—-explicit-boolean-claim-required-for-economic-nexus-verification) and [September 1](#qwed-tax-—-npm-verifier-enforces-structured-nexus-claims-fail-closed) entries.

> [!WARNING]
>
> **Behavior change.** Classification claims and worker facts that earlier versions interpreted are now rejected:
>
> ```python theme={null}
> # Before: "1099 contractor" was matched by substring and treated as 1099.
> # After: verified=False with an INVALID_CLAIM audit trace outcome.
> guard.verify_classification_claim(
>     llm_claim="1099 contractor",
>     facts={"provides_tools": False, "reimburses_expenses": False, "indefinite_relationship": False},
> )
>
> # After: pass a canonical claim and boolean facts.
> guard.verify_classification_claim(
>     llm_claim="1099",
>     facts={"provides_tools": False, "reimburses_expenses": False, "indefinite_relationship": False},
> )
> ```

### What changed

* **Closed claim vocabulary.** `ClassificationGuard.verify_classification_claim` maps the claim through a fixed set of values instead of substring matching. `W2`, `W-2`, and `EMPLOYEE` map to employee. `1099`, `1099-NEC`, `CONTRACTOR`, and `INDEPENDENT CONTRACTOR` map to contractor. Matching ignores case, spaces, hyphens, and underscores. Any other text fails closed with `INVALID_CLAIM`.
* **Strict worker facts.** `provides_tools`, `reimburses_expenses`, and `indefinite_relationship` must all be present booleans. Otherwise the result fails closed with `INVALID_FACTS`. Strings such as `"no"` are rejected.
* **`TaxPreFlight` `hire` intents.** The same vocabulary and fact rules apply to `worker_type` and `worker_facts`.
* **npm `NexusGuard` fails closed.** The `@qwed-ai/tax` `NexusGuard` blocks states that are not in its threshold table and rejects invalid sales data, including non-object, non-finite, and negative values. The transaction-count prong applies only to states that have one.
* **npm classification unchanged.** The npm SDK still compares `worker_type` exactly. Pass `"W2"` or `"1099"`.

### Migration

Translate the AI's classification into `"W2"` or `"1099"` and pass each worker fact as a real boolean — map known values explicitly (`"yes"`/`"true"` → `True`, `"no"`/`"false"` → `False`) and reject anything else. Do not wrap a string in `bool()`: `bool("no")` is `True`, which would recreate the bug this release fixes. Nexus callers that relied on `llm_decision` strings must pass `claimed_collects_tax` as a boolean. See [ClassificationGuard](https://docs.qwedai.tech/tax/guards.md#classificationguard-irs-common-law) and [NexusGuard](https://docs.qwedai.tech/tax/guards.md#nexusguard-economic-nexus).

***

## QWED-Legal v0.5.1 — DeadlineGuard signing-anchor allowlist

**Released: October 9, 2026** in `qwed-legal` 0.5.1 (PyPI and npm `@qwed-ai/legal`) · [GitHub PR #102](https://github.com/QWED-AI/qwed-legal/pull/102) · [GitHub PR #103](https://github.com/QWED-AI/qwed-legal/pull/103)

> Security patch. `DeadlineGuard` now computes a deadline from `signing_date` only for terms measured forward from signing. Upgrade if you use `DeadlineGuard` through the Python API, the npm SDK, or the GitHub Action.

> [!WARNING]
>
> **Behavior change.** Terms that are measured backward or anchored to another event now return `UNVERIFIABLE` instead of `VERIFIED`:
>
> ```python theme={null}
> # Before: computed signing + 30 days and could return verified=True.
> # After: UNVERIFIABLE with an UNSUPPORTED trace step.
> guard.verify(signing_date="2026-01-15", term="30 days before signing", claimed_deadline="2026-02-14")
> guard.verify(signing_date="2026-01-15", term="30 days after closing", claimed_deadline="2026-02-14")
>
> # Migration for a forward term anchored to another event:
> # confirm the event date, pass it as signing_date, and rewrite the term.
> guard.verify(signing_date="2026-03-02", term="30 days", claimed_deadline="2026-04-01")
> ```
>
> Changing `signing_date` alone does not make an event-anchored term supported. You must also rewrite the term to a bare-relative form such as `"30 days"`.

### What changed

* **Signing-anchor allowlist.** `DeadlineGuard` computes from `signing_date` only when the term is bare-relative (`"30 days"`) or explicitly anchored to signing or execution (`"30 days from signing"`, `"15 days after execution"`). Every other term fails closed with `UNVERIFIABLE` and an `UNSUPPORTED` trace step.
* **Directional terms fail closed.** Terms using `before`, `prior to`, `ahead of`, or `preceding` were previously computed forward from signing, which could certify the opposite direction.
* **Any non-signing event anchor fails closed.** Anchors outside the earlier event list, such as `"after closing"`, `"after completion"`, or `"within 30 days of request"`, are now rejected. The open-ended event-noun list is replaced by an allowlist of signing-anchored phrasings.
* **Check runs before date arithmetic.** Rejected business-day terms never consult the holiday calendar.
* **npm engine version gate.** `DeadlineVerifier` in `@qwed-ai/legal` rejects its promise when the importable Python `qwed-legal` is older than 0.5.1. An npm-only upgrade cannot silently keep the old deadline engine. Upgrade both packages:

```bash theme={null}
pip install "qwed-legal>=0.5.1"
npm install @qwed-ai/legal@0.5.1
```

See [Supported term forms](https://docs.qwedai.tech/legal/guards.md#supported-term-forms) and [Event-anchored terms](https://docs.qwedai.tech/legal/guards.md#event-anchored-terms).

***

## v7.2.2 — Verification soundness and safety hardening

**Released: October 9, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.2.2) · [GitHub PR #434](https://github.com/QWED-AI/qwed-verification/pull/434) · Advisories GHSA-4v5r-g7f4-vvgc, GHSA-mfh5-3c8f-975p, GHSA-j622-qffp-rc27, GHSA-mxwv-x5qm-mrmf

> v7.2.2 is a fail-closed security release covering four coordinated advisories. They affect all versions up to `v7.2.1`. No new capability and no breaking wire changes. Upgrade is recommended for any deployment on `v6.0.0` through `v7.2.1`.

### Security fixes

* **Local SDK math verification.** The SymPy expression validator in `QWEDLocal` now rejects string literals nested inside container arguments. This closes an allow-list bypass that could route untrusted model output to SymPy's string-evaluation path.
* **Logic verification soundness.** The Z3 constraint evaluator no longer accepts Python boolean operators (`and`, `or`, `not`) or chained comparisons (`a < b < c`) on symbolic objects. The DSL compiler now rejects string literals instead of materializing them as variables. Both cases could previously yield a `VERIFIED` verdict that did not follow from the input.
* **Large-number math verification.** `verify_math` now compares results at a precision scaled to the operands. Values beyond 15 significant digits are no longer rounded before the comparison, so a wrong claim on a large number can no longer pass as `VERIFIED`.
* **Computational cost bounds.** The math parser applies its compute-cost gates to implicit-multiplication input (`2x`, `3(x+1)`). The logic and DSL engines bound statically known integer powers. Short requests can no longer exhaust a worker.

### Version bumps

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `7.2.1` | `7.2.2` |
| `qwed_sdk` (Python) | `7.2.1` | `7.2.2` |
| `@qwed-ai/sdk` (npm) | `7.2.0` | `7.2.2` |
| `qwed` (crates.io) | `7.2.1` | `7.2.2` |
| API version marker | `7.2.1` | `7.2.2` |

### Upgrading

> [!WARNING]
>
> **Upgrading from before v7.2:** re-issue your API keys. Self-hosted deployments must also set `QWED_API_KEY_LOOKUP_SECRET` to a value distinct from `QWED_JWT_SECRET_KEY` before starting the server. Without these steps, old keys return `401 Unauthorized` and a server without the secret refuses to start. See [Authentication](https://docs.qwedai.tech/api/authentication.md#api-key-storage-and-migration-v7-2).

Patch release with no code changes required for valid inputs. Pin the new version and redeploy:

```bash theme={null}
pip install "qwed==7.2.2"
npm install @qwed-ai/sdk@7.2.2
cargo add qwed@7.2.2
```

Inputs that relied on Python `and`/`or`/`not`, chained comparisons on symbolic variables, or string literals in DSL expressions now fail closed instead of returning `VERIFIED`. Rewrite Z3 constraints with `And(...)`, `Or(...)`, `Not(...)` and separate comparisons, and write DSL logic with the [`AND`/`OR`/`NOT` operators](https://docs.qwedai.tech/engines/logic).

***

## QWED Open Responses v0.6.1 — ToolGuard verifies client action items

**Released: October 9, 2026** in `qwed-open-responses` 0.6.1 (PyPI and npm) · [GHSA-xhq6-w3f2-m5w6](https://github.com/QWED-AI/qwed-open-responses/security/advisories/GHSA-xhq6-w3f2-m5w6) · [PR #71](https://github.com/QWED-AI/qwed-open-responses/pull/71)

> `ToolGuard` now checks Responses API items that ask your client to run shell commands, apply patches, or control a computer. Before v0.6.1, these items read as tool-free content and passed. Upgrade from v0.6.0.

> [!WARNING]
>
> **Breaking change.** The default blocklist adds `local_shell`, `apply_patch`, and `computer_use`. A default `ToolGuard` now blocks `local_shell_call`, `shell_call`, `apply_patch_call`, and `computer_call` items. To allow them, turn off the default blocklist and allowlist the tools you run:
>
> ```python theme={null}
> # Before: local_shell_call items passed a default ToolGuard
> ToolGuard()
>
> # After: opt out of the defaults and allowlist the action tools you run
> ToolGuard(
>     use_default_blocklist=False,
>     blocked_tools=["bash", "sh", "powershell"],
>     allowed_tools=["local_shell", "apply_patch"],
> )
> ```
>
> ```typescript theme={null}
> new ToolGuard({
>   useDefaultBlocklist: false,
>   blockedTools: ["bash", "sh", "powershell"],
>   allowedTools: ["local_shell", "apply_patch"],
> });
> ```
>
> `use_default_blocklist=False` drops every default entry, shells included. Re-add the names you still want blocked.

### What changed

* **Client action items are tool calls.** `ToolGuard` normalizes `local_shell_call`, `shell_call`, `apply_patch_call`, `computer_call`, `custom_tool_call`, and `mcp_approval_request` items into tool calls. Blocklist, allowlist, dangerous-pattern, and validator checks apply. This covers direct items, `output[]` of a response object, `tool_calls[]` entries, and the streaming middleware.
* **Canonical tool names.** Action items are checked as `local_shell`, `shell`, `apply_patch`, and `computer_use`. Custom tool and MCP approval items use their own `name`. `local_shell` argv lists are also scanned as a joined command line.
* **Unknown executable items fail closed.** Any other `*_call` or `*_request` item in a streamed item, `output_item` event, or `output[]` of a response object is blocked. Provider-hosted items (`web_search_call`, `file_search_call`, `code_interpreter_call`, `image_generation_call`, `mcp_call`) keep their existing behavior.
* **Ambiguous action items fail closed.** An action item that also carries a `tool_call`, `function_call`, `function`, or `tool_calls` envelope is blocked. So is a named item whose `tool_name` or `toolName` disagrees with `name`.

### What this means for you

* If your agent runs local shell, patch, or computer-use tools, configure `ToolGuard` with `use_default_blocklist=False` and an `allowed_tools` allowlist.
* If you rely on the default blocklist for shells, re-add those names through `blocked_tools` when you opt out.

See [Responses API client action items](https://docs.qwedai.tech/open-responses/guards.md#responses-api-client-action-items) in the guards reference.

***

## QWED-Finance v3.0.1 — ISO 20022, sanctions, and QueryGuard security fixes

**Released: October 9, 2026** in `qwed-finance` 3.0.1 (PyPI, npm `@qwed-ai/finance`, and GitHub Action `QWED-AI/qwed-finance@v3.0.1`) · [Changelog](https://github.com/QWED-AI/qwed-finance/blob/main/CHANGELOG.md)

> `qwed-finance` v3.0.1 is the current release. It fixes three security advisories. Upgrade from v2.1.0 or v3.0.0.

### What changed

* **ISO 20022 business rules read the parsed document** ([GHSA-mrrj-6m2q-jch9](https://github.com/QWED-AI/qwed-finance/security/advisories/GHSA-mrrj-6m2q-jch9)). `CrossGuard.check_business_rules`, `verify_iso20022_with_rules`, and `UCPIntegration.verify_iso20022_payment` take amounts and currencies from the parsed XML tree instead of regex over raw text. Amounts with child nodes, exponents, or grouping separators fail closed.
* **Sanctions screening resists in-word perturbations** ([GHSA-mv2c-jwm9-pfrq](https://github.com/QWED-AI/qwed-finance/security/advisories/GHSA-mv2c-jwm9-pfrq)). Diacritics and look-alike letters are folded, and separator-free names are compared, so `E.V.I.L. CORP` or `ÉVIL CORP` no longer clears an `EVIL CORP` entry.
* **QueryGuard rejects MySQL executable comments** ([GHSA-q8r4-6gpp-5fx2](https://github.com/QWED-AI/qwed-finance/security/advisories/GHSA-q8r4-6gpp-5fx2)). Queries containing `/*! ... */` or `/*M! ... */` are unsafe in every QueryGuard check and in `CrossGuard.verify_query_with_pii_protection`.

### Included from v3.0.0 (September 27, 2026)

* **Breaking (npm):** the `@qwed-ai/finance` bridge sends JSON over stdin with runtime validation. Malformed inputs are rejected instead of falling back to defaults.
* `VerificationReceipt.get_signature(key)`: keyed HMAC-SHA256 over all 15 receipt fields, with no default key. The unkeyed `get_signature()` was removed.
* ISO currency and amount business limits in receipts and UCP. `UCPIntegration.verify_iso20022_payment` takes `kyc_verified` and requires it to approve.
* Fail-closed AML, sanctions, amount, and message-validation fixes.

See [QWED Finance overview](https://docs.qwedai.tech/finance/overview.md), [the 11 guards](https://finance.qwedai.tech/finance/guards), and [GitHub Action](https://finance.qwedai.tech/finance/action).

***

## QWED-Legal — fail-closed date handling and input bounds

**Released: October 7, 2026** in `qwed-legal` 0.5.0 · [GitHub PR #89](https://github.com/QWED-AI/qwed-legal/pull/89) · [GitHub PR #90](https://github.com/QWED-AI/qwed-legal/pull/90) · [GitHub PR #91](https://github.com/QWED-AI/qwed-legal/pull/91) · [GitHub PR #92](https://github.com/QWED-AI/qwed-legal/pull/92) · [GitHub PR #93](https://github.com/QWED-AI/qwed-legal/pull/93)

> `DeadlineGuard` and `StatuteOfLimitationsGuard` no longer guess what a date means. Order-ambiguous dates, partial dates, and event-anchored terms now return `UNVERIFIABLE` instead of a confident verdict. `ClauseGuard` and `ContradictionGuard` gain input bounds.

> [!WARNING]
>
> **Behavior change.** Some inputs that used to verify now return `UNVERIFIABLE`:
>
> ```python theme={null}
> # Before: "03/04/2026" was read month-first (March 4) and the claim verified.
> # After: UNVERIFIABLE with an AMBIGUITY_NOTED trace step.
> guard.verify(signing_date="03/04/2026", term="30 days", claimed_deadline="2026-04-03")
>
> # Before: computed 30 days from signing.
> # After: UNVERIFIABLE, because the clock starts at receipt, not signing.
> guard.verify(signing_date="2026-01-01", term="30 days after receipt", claimed_deadline="2026-01-31")
> ```
>
> Pass ISO `YYYY-MM-DD` dates. ISO year-first and named-month dates still verify.

### What changed

* **Order-ambiguous numeric dates fail closed (#90).** `DeadlineGuard` and `StatuteOfLimitationsGuard`, including computation-only mode, return `UNVERIFIABLE` with an `AMBIGUITY_NOTED` trace step for dates such as `03/04/2026` that read differently month-first and day-first.
* **Partial dates fail closed (#91).** Inputs such as `"March 2024"`, `"Friday"`, or `"23:00"` would be completed from the system clock, so both guards reject them. In both cases, parsed datetimes in the result are `None` and the raw strings stay in the trace. `DeadlineGuard.calculate_business_days_between()` raises `ValueError` for these dates.
* **Event-anchored terms fail closed (#92).** `DeadlineGuard` returns `UNVERIFIABLE` for terms that start the clock at receipt, notice, delivery, occurrence, demand, invoice, breach, termination, service of process, or payment. It no longer computes those deadlines from the signing date.
* **Deadline comparison by calendar date (#92).** Claimed and computed deadlines are compared by date, not timestamp. Timezone-aware claims are converted to the computed deadline's timezone. Mixed timezone-aware and naive inputs return `UNVERIFIABLE`. `tolerance_days` stays in whole days.
* **ClauseGuard input bounds (#89).** `check_consistency()` returns `invalid_input` for more than 200 clauses or any clause over 4,096 characters. It reports at most 100 conflict pairs and adds a truncation notice when it stops.
* **Digit-run bound (#89).** Digit runs longer than 18 characters fail closed in `DeadlineGuard` terms, `ClauseGuard` day counts, and `ContradictionGuard` operands.
* **Faster US citation patterns (#89).** `CitationGuard` US patterns no longer slow down on adversarial input.
* **Added — npm SDK (#93).** `JurisdictionVerifier.verifyChoiceOfLaw()` in `@qwed-ai/legal` accepts an optional fourth `contractType` argument and passes it to the guard as `contract_type`. `JurisdictionResult` adds optional `contractType` and `contractClassification` (`goods`, `declared_non_goods`, or `unclassified`) fields. Omitting `contractType` keeps the existing partial-coverage warning. The 0.5.0 npm package is published as `@qwed-ai/legal`.

### What this means for you

* Normalize dates to ISO `YYYY-MM-DD` before calling the deadline and statute guards.
* For event-anchored deadlines, pass the event date as `signing_date` and drop the anchor from the term. The result reports that date as `signing_date`, so keep the real signing date separately.
* Treat `invalid_input` from `check_consistency()` on oversized clause lists as a rejection, not a pass.

See [ambiguous and partial dates](https://docs.qwedai.tech/legal/guards.md#fail-closed-behavior-on-ambiguous-and-partial-dates), [event-anchored terms](https://docs.qwedai.tech/legal/guards.md#event-anchored-terms), [date-granularity comparison](https://docs.qwedai.tech/legal/guards.md#date-granularity-comparison), [StatuteOfLimitationsGuard](https://docs.qwedai.tech/legal/guards.md#6-statuteoflimitationsguard), and [ClauseGuard input validation](https://docs.qwedai.tech/legal/guards.md#input-validation).

***

## QWED Open Responses v0.6.0 — fail-closed hardening

**Released: October 2, 2026** in `qwed-open-responses` 0.6.0 · [PR #48](https://github.com/QWED-AI/qwed-open-responses/pull/48) · [PR #49](https://github.com/QWED-AI/qwed-open-responses/pull/49) · [PR #50](https://github.com/QWED-AI/qwed-open-responses/pull/50) · [PR #51](https://github.com/QWED-AI/qwed-open-responses/pull/51) · [PR #52](https://github.com/QWED-AI/qwed-open-responses/pull/52) · [PR #55](https://github.com/QWED-AI/qwed-open-responses/pull/55) · [PR #57](https://github.com/QWED-AI/qwed-open-responses/pull/57) · [PR #58](https://github.com/QWED-AI/qwed-open-responses/pull/58) · [PR #59](https://github.com/QWED-AI/qwed-open-responses/pull/59)

> SchemaGuard now rejects undeclared root fields by default, verification is resource-bounded, and ToolGuard, the streaming middleware, and the Express middleware close several bypasses. The Python and npm packages both move to 0.6.0.

> [!WARNING]
>
> **Breaking change.** SchemaGuard adds `additionalProperties: false` to the root object when your schema doesn't set it. Responses with undeclared top-level fields now fail. An explicit `additionalProperties` in your schema still wins. To keep the old behavior, opt out in the constructor:
>
> ```python theme={null}
> # Before: extra root fields passed silently
> SchemaGuard(schema)
>
> # After: opt out explicitly to allow them
> SchemaGuard(schema, allow_additional_properties=True)
> ```
>
> ```typescript theme={null}
> new SchemaGuard(schema, { allowAdditionalProperties: true });
> ```

### What changed

* **SchemaGuard cross-language parity.** JSON Schema `format` validation for `email`, `uuid`, `date-time`, `uri`, and `ipv4` now matches across Python and npm.
* **Resource-bounded verification.** JSON responses over 100,000 characters or 100 nesting levels, and responses with oversized integers, return a failed verdict instead of raising an exception. SafetyGuard scanning fails closed above 10,000 nodes, 100,000 characters, or 12 nesting levels. Email PII detection uses a linear-time scan.
* **SchemaGuard reports the first error only.** Failed results carry one error, with the total error count reported as unknown (`total_errors=None`, `errors_truncated=True`).
* **ToolGuard raw argument scanning.** Dangerous patterns run on the raw parsed argument strings, including nested values and JSON-encoded calls, so custom patterns match across tabs and newlines. Cyclic or too-deep arguments still fail closed.
* **ToolGuard name and command normalization.** Blocked tool names are normalized for case, surrounding whitespace, dashes, underscores, and camelCase. The default blocklist adds `run_command`, `execute_command`, `command_line`, and `terminal`. Command checks catch comment-separated keywords, spaced dots, and reordered `rm` flags, including inside JSON- and base64-encoded arguments. Allowlist entries still require the exact configured separators. Custom validator keys match case and whitespace but don't merge distinct names.
* **Streaming tool-call verification.** `OpenResponsesMiddleware.verify_stream` verifies each tool item in its original shape before yielding it. Tool-call types are normalized for case and whitespace. Unknown, ambiguous, or nested tool-shaped items are blocked. `tool_result` and `function_call_output` items pass through when their correlation metadata is valid. ArgumentGuard and TaxGuard checks cover batched and nested calls.
* **SafetyGuard scans field names and quoted credentials.** Object field names are now checked for PII and prompt injection, not only values. Quoted JSON credentials such as `"api_key": "sk-..."` are blocked. Documented placeholders stay exempt.
* **npm SafetyGuard configuration parity.** The TypeScript `SafetyGuard` accepts `customPatterns` and `piiAllowList`. Each matched custom pattern blocks and reports its own `custom_pattern` finding. Invalid patterns throw at construction, and a pattern that exceeds its 25 ms execution limit fails the check. `piiAllowList` accepts camelCase and snake\_case names, such as `ipAddress` and `ip_address`.
* **ToolGuard envelope hardening.** Envelopes that carry conflicting tool identities at the root, in a nested call, or in a `function` wrapper are blocked. Configured ToolGuards now check `structured_output` responses too. Nested scans that reach the depth limit block instead of reporting "No tool calls".
* **Verification context propagation.** A `context` with usage caps now flows through `verify_tool_call`, `verify_structured_output`, and the OpenAI, streaming, LangChain, and LlamaIndex integrations. The OpenAI wrapper takes it as `verification_context` and doesn't send it to OpenAI. The streaming middleware carries running `total_cost` and `total_tokens` forward, so later items can hit SafetyGuard budget limits.
* **Express middleware fails closed on unparsed bodies.** The npm `verifyRequestBody` middleware returns HTTP 422 when a request declares a body but has no parsed body, even with `blockOnFailure: false`. Bodyless requests and parsed empty JSON continue.

### What this means for you

* Review SchemaGuard schemas for responses that carry extra root fields, and declare them or opt out.
* If you use the Express middleware, register a body parser such as `express.json()` before it.
* If ToolGuard now blocks your structured outputs or wrapped tool calls, give each call a single tool identity.
* To enforce budgets across a stream or agent run, pass usage totals in the verification context.

See the [guards reference](https://docs.qwedai.tech/open-responses/guards.md), [streaming middleware](https://docs.qwedai.tech/open-responses/overview.md#streaming-middleware), [OpenAI integration](https://docs.qwedai.tech/open-responses/openai.md#budget-limits-with-verification-context), and [troubleshooting](https://docs.qwedai.tech/open-responses/troubleshooting.md).

***

## QWED-Legal — fail-closed hardening for guards, diagnostics, and GitHub Action

**Released: October 7, 2026** in `qwed-legal` 0.5.0 (merged September 29, 2026) · [GitHub PR #83](https://github.com/QWED-AI/qwed-legal/pull/83) · [GitHub PR #84](https://github.com/QWED-AI/qwed-legal/pull/84) · [GitHub PR #85](https://github.com/QWED-AI/qwed-legal/pull/85) · [GitHub PR #87](https://github.com/QWED-AI/qwed-legal/pull/87) · [GitHub PR #88](https://github.com/QWED-AI/qwed-legal/pull/88)

> Five QWED-Legal paths that could report success over empty, blank, or unclassified input now fail closed. The QWED Legal GitHub Action now passes only on deterministic proofs.

> [!WARNING]
>
> **Breaking change for the QWED Legal GitHub Action.** Every block is now gated on `to_diagnostic().status == VERIFIED`. These runs previously passed and now fail with `verified=false` and a non-zero exit:
>
> * Unknown or misspelled `mode` values.
> * Runs where missing inputs mean zero checks execute.
> * Clause runs, including heuristic `"consistent"` results and single-clause inputs (now `insufficient_input`, `consistent=False`).
> * Citation runs, including format-valid citations.
>
> **Before:** `mode: citation` with a well-formed citation, or `mode: clause` with no detected conflicts, returned `verified=true`.
>
> **After:** Only deterministic deadline and liability exact matches keep `verified=true`. If limited coverage is acceptable for your workflow and the run should still finish green, mark the QWED Legal step `continue-on-error: true`. That matters because a failed step both skips the later steps *and* fails the job: adding `if: always()` to a later step lets it read the output, but it cannot make the run pass. With `continue-on-error: true`, branch on the `results` JSON using `steps.<id>.outputs.results` — `results.citation.valid` for citation format checks and `results.clause.status` for clause results. Use `verify_using_z3()` from the Python API for formal clause proofs.

### What changed

* **`LiabilityGuard.verify_tiered_liability`: empty tier lists.** `tiers=[]` returns `UNVERIFIABLE` with `total_computed=None` and no `proof_ref`, whether `claimed_total` is zero or not.
* **`ProvenanceGuard`: strict required fields.** `content_hash`, `model_id`, and `generation_timestamp` must be non-empty strings. `None`, `0`, `False`, `[]`, and whitespace now give `INCOMPLETE_PROVENANCE`. Checks that cannot run are recorded as `hash_integrity_skipped`, `timestamp_valid_skipped`, and `model_allowed_skipped` instead of `CONTENT_TAMPERED` or `UNAUTHORIZED_MODEL`.
* **`JurisdictionGuard`: blank party entries.** `check_convention_applicability()` returns `UNVERIFIABLE` for blank entries such as `[""]`. Genuine negatives such as `["XX"]` stay deterministic.
* **`JurisdictionGuard`: three-way `contract_type` classification for CISG.** Goods contracts (separator and case variants accepted, bare `"sale"` excluded) get the CISG warning. Declared non-goods contracts such as `"services"` get no CISG warning. Missing, blank, or unknown values get a partial-coverage warning and are not verified. The US and non-US party pair no longer triggers the CISG warning by itself. The classification is recorded in trace inputs.
* **Diagnostics: no `proof_ref` without a deterministic step.** `LegalDiagnosticResult.verified()` raises unless the evidence trace has a `DETERMINISTIC` step. Traceless or transport-stripped `"consistent"` verdicts and empty `claim_texts` are demoted to `UNVERIFIABLE`. Blank messages fall back to defaults instead of crashing.

See [LiabilityGuard](https://docs.qwedai.tech/legal/guards.md#fail-closed-behavior-on-non-finite-and-out-of-range-inputs), [CISG detection](https://docs.qwedai.tech/legal/guards.md#cisg-detection), [ProvenanceGuard](https://docs.qwedai.tech/legal/guards.md#verification-checks), [ClauseGuard input validation](https://docs.qwedai.tech/legal/guards.md#input-validation), and [No proof without a deterministic step](https://docs.qwedai.tech/legal/guards.md#no-proof-without-a-deterministic-step).

***

## QWED Verification — `qwed init` server identity handshake

**Released: September 23, 2026** · [GitHub PR #388](https://github.com/QWED-AI/qwed-verification/pull/388) · Closes [issue #376](https://github.com/QWED-AI/qwed-verification/issues/376)

> `qwed init` now proves that the local server holds this project's secrets before it bootstraps an API key. Previously, a healthy `/health` only proved that something answered on the port. A foreign process or a stale server running older secret values could receive the bootstrap and issue a key that stops working after a restart.

### What changed

* **New `GET /health/identity` endpoint.** Takes a caller-supplied `nonce` (1–128 characters) and returns HMAC-SHA256 proofs over it under `QWED_JWT_SECRET_KEY` and `QWED_API_KEY_LOOKUP_SECRET`. Returns `400` for a missing or oversized nonce and `503` when server secrets aren't configured. The secrets never leave the server. See [`GET /health/identity`](https://docs.qwedai.tech/api/endpoints#get-%2Fhealth%2Fidentity).
* **Nonce-bound, replay-safe handshake.** `qwed init` generates a fresh nonce on every run and compares the proofs against locally computed values in constant time. A captured response is useless for any later handshake.
* **Runs on reused and freshly spawned servers.** A reused server can be foreign or stale even when secrets were retained. A fresh spawn can lose a port race after the health poll.
* **Fails closed.** A mismatch, a `404` from an older server, any other non-`200` status, a malformed response, an unreachable server, or missing local secrets stops `qwed init` with exit status `1` before any key is issued. Each message tells you to restart the local server or re-run `qwed init`.

See [`qwed init`](https://docs.qwedai.tech/advanced/cli#qwed-init-onboarding-and-bootstrap) and [troubleshooting](https://docs.qwedai.tech/troubleshooting.md#qwed-init-fails-with-%E2%80%9Cserver-identity-check-failed%E2%80%9D).

***

## QWED Verification — leaked API key auto-revocation and false-positive feedback

**Released: September 22, 2026** · [GitHub PR #380](https://github.com/QWED-AI/qwed-verification/pull/380) · [GitHub PR #386](https://github.com/QWED-AI/qwed-verification/pull/386) · Closes [issue #368](https://github.com/QWED-AI/qwed-verification/issues/368) and [issue #369](https://github.com/QWED-AI/qwed-verification/issues/369)

> Verified reports from `POST /webhooks/secret-scanning` now trigger enforcement. QWED automatically revokes any matching active API key, records a security event, and emails the key owner. An opt-in reporter can send true-positive and false-positive labels back to the scanning service.

### What changed

* **Automatic revocation.** A verified match against an active key sets `is_active=False` and `revoked_at`, and writes a `SECRET_LEAK_AUTO_REVOKED` security event. The event records only the `key_preview`. The plaintext token is never persisted, logged, or emailed.
* **Owner notification.** QWED emails the key's user, falling back to the organization's first active owner or admin. SMTP phases time out after 10 seconds. Revocation is committed first and is never rolled back if notification fails.
* **Idempotent handling.** Already-revoked keys and unknown tokens are not acted on. Redelivered or concurrent reports revoke and notify exactly once. Registered scanner patterns cover `qwed_live_` keys only, so `qwed_test_` keys are never reported.
* **False-positive feedback reporter (opt-in).** Set `QWED_LEAK_FEEDBACK_ENABLED=true` and `QWED_LEAK_FEEDBACK_URL` to POST one label per match after the revocation attempt. Items carry SHA-256 token hashes by default. `QWED_LEAK_FEEDBACK_SEND_RAW=true` is a separate explicit opt-in to send plaintext tokens, and only works with an HTTPS URL.

See [Security hardening — Automatic revocation of leaked keys](https://docs.qwedai.tech/advanced/security-hardening.md#automatic-revocation-of-leaked-keys) for the full behavior and configuration.

***

## QWED Verification — canonical API-key format spec

**Released: September 22, 2026** · [GitHub PR #387](https://github.com/QWED-AI/qwed-verification/pull/387) · Closes [issue #370](https://github.com/QWED-AI/qwed-verification/issues/370)

> QWED now publishes a canonical, machine-readable specification of its API-key formats, with lookaround-anchored scanning regexes and synthetic test vectors. Use it to build secret scanners, leak detectors, and SDK-side format validators.

### What changed

* **v2 checksummed format documented.** Current keys match `qwed_live_[A-Za-z0-9]{36}`: 30 random Base62 characters followed by a 6-character Base62 CRC32 checksum that you can validate offline.
* **Legacy v1 still accepted.** Keys matching `qwed_live_[A-Za-z0-9_-]{43}` keep working and are never force-rotated.
* **`qwed_test_` exclusion.** Scanners must exclude test keys from high-confidence production-secret matching.
* **Lookaround-anchored regexes.** Published patterns use charset-complement lookarounds instead of `\b`, because a v1 body can end in `-`. RE2-compatible delimiter-class alternatives are included.
* **Masking guidance.** Masked previews show the prefix, asterisks, and only the last 4 characters.
* **Machine-readable spec and test vectors.** `spec/api-key-format.json` and `spec/api-key-test-vectors.json` in the `qwed-verification` repository.

See [API key format](https://docs.qwedai.tech/api/authentication.md#api-key-format) for patterns, checksum details, and scanning rules.

***

## QWED Verification — signed secret-scanning webhook endpoint

**Released: September 20, 2026** · [GitHub PR #374](https://github.com/QWED-AI/qwed-verification/pull/374) · Closes [issue #367](https://github.com/QWED-AI/qwed-verification/issues/367)

> New `POST /webhooks/secret-scanning` endpoint receives signed reports of leaked QWED API keys from GitHub secret scanning and compatible partner scanners. The endpoint is unauthenticated at the transport layer by design — external scanners have no QWED credentials — and the ECDSA-NIST-P256V1-SHA256 signature over the raw request body is the only credential. Verification runs before any JSON parsing, logging, or database work, and every failure mode fails closed to a distinct status code.

### What changed

* **New public endpoint.** `POST /webhooks/secret-scanning` accepts a JSON array of leaked-token matches, verifies the `Github-Public-Key-Identifier` and `Github-Public-Key-Signature` headers against GitHub's published signing keys, and hands verified batches to the revocation sink in the background. See [`POST /webhooks/secret-scanning`](https://docs.qwedai.tech/api/endpoints#post-%2Fwebhooks%2Fsecret-scanning) for the full request/response contract.
* **Raw-body signature verification.** The signature is verified over the exact bytes received; the body is never re-serialized. Nothing in the payload is parsed or logged until the signature is proven genuine.
* **P-256 pinning at key-load.** Signing keys are pinned to `secp256r1` when the PEM is loaded, so a future keys-list entry on another curve is dropped at load rather than silently trusted at verify time.
* **6-hour key cache with rotation retry.** Signing keys are cached in-process for 6 hours with an `ETag`-conditional refetch. On an unknown `key_identifier`, QWED refetches the list exactly once (throttled to one forced refetch per 60 s after a success, 10 s after a failure) and retries. A dead or throttled key service fails closed to `503`.
* **Timing-safe verdict comparison.** Success and failure branches run through `hmac.compare_digest` on a fixed-shape verdict, and every failure returns the same generic detail so the failing step is not observable.
* **Fail-closed status separation.** `401` for missing headers, `403` for a proven-bad signature against a fresh anchor, `503` for any state where verification was impossible (signing-key service down, anchor unusable, rotation retry throttled). `413` for over-cap bodies (1 MiB) or batches (500 matches); `400` for a malformed envelope. Tokens never appear in logs.
* **Optional keys-fetch token.** `GITHUB_KEYS_TOKEN` (or `GITHUB_TOKEN` as a fallback) can be set to raise the outbound rate limit on the keys request. Neither token grants any authority over the incoming webhook — only the signature does.

See [Security hardening — Secret-scanning webhook intake](https://docs.qwedai.tech/advanced/security-hardening.md#6-secret-scanning-webhook-intake) for the full trust model and operator configuration.

***

## QWED Verification — `qwed init` provisions `QWED_API_KEY_LOOKUP_SECRET`

**Released: September 21, 2026** · [GitHub PR #375](https://github.com/QWED-AI/qwed-verification/pull/375) · Closes [issue #372](https://github.com/QWED-AI/qwed-verification/issues/372)

> Fresh `qwed init` runs no longer crash the child server at boot on the fail-closed `QWED_API_KEY_LOOKUP_SECRET` import check. The CLI now generates the lookup secret alongside `QWED_JWT_SECRET_KEY`, writes it to `.env`, and threads it into the local server it starts.

### What changed

* **Auto-provisioned on first run.** `qwed init` generates a URL-safe random `QWED_API_KEY_LOOKUP_SECRET` (at least 48 bytes) whenever one is not already set, persists it to `.env` with the same `0600` permissions as other credentials, and exports it into the child server's environment.
* **Retained on re-runs, never rotated.** If `.env`, the process environment, or the project-root `.env` already holds a value, `qwed init` reuses it verbatim. Rotating this secret invalidates every stored API-key lookup digest, so the CLI will not do it for you.
* **Refuses to persist an unbootable value.** If the existing `QWED_API_KEY_LOOKUP_SECRET` equals `QWED_JWT_SECRET_KEY`, `qwed init` exits with an error instead of writing a value the server would refuse to start with. Remove the offending line (a fresh distinct value is generated) or set a distinct value manually.
* **Stale-secret guard around the local server.** If `qwed init` regenerates a missing secret but a QWED server is already running with older values, the CLI stops before bootstrapping a new API key — issuing one against a server that cannot verify it would produce a key that dies on the next restart.

See [`qwed init`](https://docs.qwedai.tech/advanced/cli#qwed-init-onboarding-and-bootstrap) and [API key storage and migration](https://docs.qwedai.tech/api/authentication.md#api-key-storage-and-migration-v7-2) for the full contract.

***

## QWED-A2A — `/a2a/intercept` requires per-agent API keys (breaking)

**Released: September 9, 2026** · [GitHub PR #106](https://github.com/qwed-ai/qwed-a2a/pull/106)

> The FastAPI gateway's `POST /a2a/intercept` endpoint previously trusted the body-declared `sender_agent_id` — any caller could impersonate any agent for trust evaluation, rate limits, and attestation. The endpoint now authenticates every request with a per-agent API key in the `X-API-Key` header, validated against the `QWED_A2A_API_KEYS` environment variable. The authenticated identity overrides the body's `sender_agent_id` everywhere it matters. The dead `AgentMessage.signature` field, which was never verified, is removed.

> [!WARNING]
>
> **Fail-closed behavior change.** Deployments that do not set `QWED_A2A_API_KEYS` now deny every `/a2a/intercept` request with `401 API authentication is not configured for this service.` There is no anonymous access and no opt-out. Configure a key for each calling agent before upgrading.

### What changed

* **Per-agent API-key authentication** — `QWED_A2A_API_KEYS` holds a JSON object mapping each key to its agent ID, e.g. `{"key-abc123": "procurement-agent"}`. Missing, unknown, or unconfigured keys return `401`. Keys are compared in constant time.
* **Authenticated identity wins** — trust-boundary evaluation, token-bucket rate limiting, telemetry, and the attestation JWT all bind to the key's agent ID. If the body's `sender_agent_id` differs, the gateway logs a warning and overrides it. Direct library callers of `intercept()` get no override and own identity binding themselves.
* **No-restart key rotation** — the key map is read fresh on every request. Update the environment variable and old keys stop matching immediately.
* **Agent-ID normalization** — map entries are trimmed and validated against the `AgentMessage` contract (max 256 chars, no control characters); invalid entries are dropped and their keys denied.
* **`AgentMessage.signature` removed** — the optional field was documented as "JWT signature for tamper detection" but was never read by any verification path. Attestation integrity comes from the verdict JWT, not a caller-supplied field.

### Migration

Before (now returns `401`):

```bash theme={null}
curl -X POST http://localhost:8000/a2a/intercept \
  -H "Content-Type: application/json" \
  -d '{"sender_agent_id": "agent-A", "receiver_agent_id": "agent-B", "payload_type": "general", "payload": {"message": "Hello!"}}'
```

After — configure a key map on the gateway and send the header:

```bash theme={null}
export QWED_A2A_API_KEYS='{"key-for-A": "agent-A"}'

curl -X POST http://localhost:8000/a2a/intercept \
  -H "Content-Type: application/json" \
  -H "X-API-Key: key-for-A" \
  -d '{"sender_agent_id": "agent-A", "receiver_agent_id": "agent-B", "payload_type": "general", "payload": {"message": "Hello!"}}'
```

If any client still sends `signature` in the message body, remove the field. See [Gateway authentication](https://docs.qwedai.tech/a2a/deployment.md#gateway-authentication) for the full key format and rotation guidance.

## QWED-A2A — cross-deployment trusted issuers and jti replay lifecycle (security release)

**Released: September 9, 2026** · [GitHub PR #108](https://github.com/QWED-AI/qwed-a2a/pull/108) · [GitHub PR #109](https://github.com/QWED-AI/qwed-a2a/pull/109) · Closes [issue #84](https://github.com/QWED-AI/qwed-a2a/issues/84) and [issue #85](https://github.com/QWED-AI/qwed-a2a/issues/85)

> Two fixes to `A2ACryptoService` attestation verification. Peer deployments can now be registered as trusted issuers, so verifying another deployment's attestations no longer tempts operators into sharing one private key across deployments. And the jti replay registry now separates issuance from consumption: an issuer can verify its own token once, duplicate `trace_id`s are rejected at signing, and the consumption registry is injectable for cross-worker replay protection.

### Cross-deployment verification (PR #108)

* **Trusted issuers.** `verify_attestation` accepts an optional `trusted_issuers` mapping of issuer IDs to `{"deployment_id": ..., "jwks": ...}`, with the `QWED_A2A_TRUSTED_ISSUERS` environment variable as fallback. Register a peer by copying its `/.well-known/jwks.json` entry verbatim. The env var is re-read on every verification, so key rotation needs no restart.
* **Fail-closed on every mismatch.** Unknown issuers, `kid` mismatches, and `deployment_id` mismatches all deny. Routing never trusts unverified claims: header and payload are read only after a candidate key verifies the signature, and one public key registered under two issuer identities keeps only the first identity.
* **Verifier-only nodes.** A node with no local signing key can verify peer tokens against trusted issuers. Only signing, JWKS publishing, and the interceptor still require `QWED_A2A_SIGNING_KEY_PEM`. With neither a local key nor trusted issuers, verification returns `No verification keys available` instead of raising.
* **Peer jti namespacing.** Replay tracking for peer tokens is namespaced per issuer, so two deployments reusing the same trace ID never shadow each other.

### jti registry lifecycle (PR #109)

* **Issuance/consumption split.** `sign_verdict` records the `trace_id` in a separate per-instance issuance record instead of the consumption registry, so an issuer can verify its own token once — the second presentation is a replay.
* **Duplicate trace IDs rejected at signing.** Reusing a `trace_id` within its validity window raises `ValueError` at `sign_verdict` instead of minting two tokens under one `jti`.
* **Injectable consumption registry.** `A2ACryptoService(jti_registry=...)` accepts a shared registry (Redis, database) for cross-worker replay protection. The default remains process-local: two workers in two processes share nothing. Injected registries must provide atomic insert-if-absent, retention covering token lifetime, and a numeric `ttl_seconds` at least `validity_seconds` — anything less raises `ValueError` at construction.

### Upgrading

> [!WARNING]
>
> **Behavior changes.** Code that relied on signing twice with the same `trace_id` now gets `ValueError` at the second `sign_verdict`. Code that verified the same token twice on one instance now gets `Replay detected: jti already seen` on the second call — previously issuers could not self-verify at all, while consumers could sometimes replay. Verifier-only nodes that expected `verify_attestation` to raise `RuntimeError` without a signing key now get a `(False, None, ...)` tuple or, with trusted issuers configured, successful peer verification. Multi-worker deployments should inject a shared `jti_registry` — the default registry does not detect replays across processes.

See [Crypto attestations](https://docs.qwedai.tech/a2a/crypto-attestations.md#cross-deployment-verification-trusted-issuers) for configuration and the full replay-scope documentation.

***

## v7.2.1 — Math answer comparison security patch

**Released: September 14, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.2.1) · [GitHub PR #365](https://github.com/QWED-AI/qwed-verification/pull/365) · Advisory [GHSA-xmm6-8r3x-j567](https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-xmm6-8r3x-j567)

> v7.2.1 is a fail-closed security patch. The math answer comparison path previously fed the untrusted model answer to raw `sympy.sympify`, which is an eval sink that can execute dunder-traversal gadgets smuggled in the answer turn. The answer is now parsed only through the hardened `safe_parse_expr`, and unparseable input fails closed. No new capability and no breaking wire changes. Upgrade is recommended for any deployment on `v6.0.0` through `v7.2.0`.

### Security fix

* **Hardened answer parsing.** `QWEDLocal._math_answers_match` no longer calls `sympy.sympify` on the model's answer. It now parses through `safe_parse_expr`, which applies a character allow-list, an AST allow-list, strips builtins, and enforces the compute-cost bounds introduced in v7.2.0. Unparseable answers and a missing parser both return `False`, so the comparison never treats an attacker-controlled string as "equal" by accident.
* **Release boundary gate covers the SDK.** `scripts/check_boundary.py` now scans `qwed_sdk/` and forbids raw `sympify` outside approved wrappers, so the eval sink cannot reappear in a future refactor.

### Version bumps

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `7.2.0` | `7.2.1` |
| `qwed_sdk` (Python) | `7.2.0` | `7.2.1` |
| `@qwed-ai/sdk` (npm) | `7.2.0` | `7.2.1` |
| `qwed` (crates.io) | `7.2.0` | `7.2.1` |
| API version marker | `7.2.0` | `7.2.1` |

### Upgrading

Patch release with no code changes required. Pin `qwed>=7.2.1` on PyPI, `@qwed-ai/sdk@^7.2.1` on npm, and `qwed = "7.2.1"` on crates.io, then redeploy. Wire formats and public APIs are unchanged.

***

## v7.2.0 — Precision advisory and security hardening batch

**Released: September 7, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.2.0) · [GitHub PR #362](https://github.com/QWED-AI/qwed-verification/pull/362)

> v7.2.0 unifies the precision advisory capability and the security hardening batch across all SDKs. One additive capability — an advisory flag for binary floating-point constants in math and stats verification — plus fail-closed fixes: bounded engine-call waits, sandbox containment, and event-loop offload. The release is a semver minor with no breaking wire changes.

### Precision advisory (new capability)

Math and stats verification inputs may contain binary floating-point constants (`0.1 + 0.2`, `1e9`) whose evaluation can be inexact relative to decimal arithmetic. v7.2.0 surfaces that signal as a [`DiagnosticResult` advisory](https://docs.qwedai.tech/advanced/diagnostics#advisory-checks) without ever affecting the verdict (PR #348):

* **`AdvisoryCheck.float_precision()`** parses the verification source and, when float or complex constants are present, returns an advisory with `constraint_id: "precision.float-constants"` listing the offending constants and suggesting `decimal.Decimal` or SymPy exact rationals where exact arithmetic matters.
* **`/verify/math`** attaches the advisory to the result on all branches, before trust enforcement.
* **`StatsVerifier.verify_stats`** carries the advisory on completed analyses when the generated code uses floats — the expected shape for numpy/pandas code, so it flags without disrupting.
* **Advisory, never a gate.** Execution-safety gates decide what may run, not what is exact. Documented inputs like `1000 * (1 + 0.05)**2` legitimately contain floats and still verify. Unparsable input returns no advisory; parse failures belong to the security gates.

```json theme={null}
{
  "developer_fields": {
    "advisory_checks": [
      {
        "name": "floating-point-constants",
        "advisory_only": true,
        "constraint_id": "precision.float-constants",
        "details": {
          "constants": ["0.1", "0.2"],
          "note": "Binary floating-point values can be inexact; results may differ from exact decimal arithmetic.",
          "suggestion": "Use decimal.Decimal or SymPy exact rationals (sympy.Rational) where exact arithmetic matters."
        }
      }
    ]
  }
}
```

### Engine-call bounds (PR #354)

Every engine-call wait is now bounded so a single request cannot stall or exhaust the service:

* **SymPy compute-cost gate.** The safe expression parser adds a computational-cost layer: integer-literal cap (10^300), static exponent bound (10^4), exact-expansion bounds on `factorial`/`binomial` calls, caret-chain fold bounds, and a nested-power base-magnitude budget. Expressions like `2**factorial(10000)` or `((9**9)**9999)**9999` are rejected before they can reach SymPy's eager exact expansion. All cost comparisons use exact arithmetic, never binary floats.
* **Provider HTTP timeouts.** All six LLM provider clients are pinned to a 30-second timeout with zero SDK-level retries. The previous SDK defaults allowed roughly 30 minutes per stalled call. Retry policy belongs to the caller.
* **Stats upload cap.** `/verify/stats` gains a byte-counting body-limit middleware with a 30-second read deadline and an in-flight concurrency cap, a 10 MB file cap with header preflight, and an early abort at 1 million cells.

### Sandbox containment (PR #351)

The Docker code-execution sandbox can no longer leak host resources (CWE-400/401):

* **Log rotation and process caps.** Sandbox containers run with json-file log rotation (10 MB, single file) and `pids_limit=128`.
* **Guaranteed container removal.** Containers are force-removed in a `finally` block covering the whole post-creation lifecycle, with create-then-start ordering so start failures cannot leak, one retry for transient daemon races, and a warn-only fallback so cleanup failure never discards a valid verification result.
* **Result size caps.** `result.json` is capped at 2 MB both inside the container wrapper (streamed, aborts at cap) and at host read-back before parsing. `observed_result` and every `VerificationLog.result` site are bounded by a shared pre-encode traversal with per-string caps, an aggregate budget, and cycle markers, and remain valid JSON for the audit integrity verifier.

### Event-loop offload (PR #352)

`/verify/consensus` and `/verify/stats` declared `async def` but ran synchronous verification chains inline on the event loop, so a single low-rate tenant could stall the whole service:

* **Consensus** now awaits an async path with a per-call executor sized to the engine list, one aggregate deadline, and per-engine timeouts. Timed-out, errored, or circuit-open engines degrade to partial `BLOCKED` results with correct circuit-breaker recording instead of hanging the request.
* **Stats** offloads CSV parsing and the verification chain to a worker thread, and every Docker daemon call is bounded at 30 seconds.

### Version bumps

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `7.1.0` | `7.2.0` |
| `qwed_sdk` (Python) | `7.1.0` | `7.2.0` |
| `@qwed-ai/sdk` (npm) | `7.1.0` | `7.2.0` |
| `qwed` (crates.io) | `7.1.0` | `7.2.0` |
| API version marker | `7.1.0` | `7.2.0` |

### Included PRs

| PR | Summary |
| - | - |
| [#348](https://github.com/QWED-AI/qwed-verification/pull/348) | Advisory flag for binary floating-point constants |
| [#351](https://github.com/QWED-AI/qwed-verification/pull/351) | Sandbox containment: log rotation, pids\_limit, container removal, result size caps |
| [#352](https://github.com/QWED-AI/qwed-verification/pull/352) | Consensus and stats verification offloaded from the event loop |
| [#354](https://github.com/QWED-AI/qwed-verification/pull/354) | Bounded engine-call waits: sympy compute-cost gate, provider timeouts, stats upload cap |
| [#362](https://github.com/QWED-AI/qwed-verification/pull/362) | v7.2.0 release preparation |

The v7.2.0 release also rolls up the security fixes released individually since v7.1.0. See the entries below for the [metrics operator allowlist](#qwed-verification-—-all-tenant-metrics-restricted-to-explicit-platform-operators-security-release), the [math parser and sandbox gate hardening](#qwed-verification-—-structural-math-parser-and-sandbox-gate-hardening-security-release), and the [API-key lookup migration](#qwed-verification-—-api-key-lookup-migration-and-per-ip-auth-throttling).

### Upgrading

No action required for the changes in this entry: the precision advisory is additive, and the hardening fixes restore intended behavior with no wire changes. Requests that previously stalled or over-consumed resources — unbounded exponent expressions, oversized stats uploads, oversized sandbox results — now return `BLOCKED` or `413`-class rejections instead. If you upgrade from v7.1.0 directly, also review the API-key migration notes in the [September 2 entry](#qwed-verification-—-api-key-lookup-migration-and-per-ip-auth-throttling).

***

## QWED Verification — all-tenant metrics restricted to explicit platform operators (security release)

**Released: September 4, 2026** · [GitHub PR #349](https://github.com/QWED-AI/qwed-verification/pull/349) · Closes [issue #337](https://github.com/QWED-AI/qwed-verification/issues/337) (CWE-284, CVSS 7.1)

> `GET /metrics` and `GET /metrics/prometheus` exposed every organization's request volumes, latencies, provider usage, and per-tenant breakdowns to any self-service signup. The access gate treated the organization-scoped `owner`/`admin` role as platform-wide authority, and since signup is the only user-creation path — hardcoding `role="owner"` for every new account — the gate degenerated to "possess any account," one anonymous request away. Access is now an explicit operator allowlist, fail-closed when unset.

### What changed

* **Operator allowlist.** All-tenant metrics require the caller's user ID to be listed in `QWED_METRICS_OPERATOR_USER_IDS` (comma-separated). The list is read per request, so granting or rotating operators takes effect without a restart.
* **Org roles are no longer platform authority.** `owner` and `admin` remain meaningful inside their own organization; they confer no cross-tenant access anywhere.
* **API-key path hardened.** A key resolves through its owning user, who must be on the allowlist. Expired (`expires_at`) or revoked (`revoked_at`) keys are no longer usable credentials — but they also no longer preempt a valid operator JWT presented in the same request. Key-only callers without a valid credential are still denied.
* **Fail-closed by default.** With the variable unset, both endpoints deny every caller. Per-tenant metrics (`GET /metrics/{organization_id}`) are unchanged.

### Behavior changes

> [!WARNING]
>
> Deployments that scrape the Prometheus endpoint (`/metrics/prometheus`) without the operator allowlist and valid scraper credentials will start receiving 401/403 after upgrading. Create a dedicated operator account, mint it an API key, and add its user ID to `QWED_METRICS_OPERATOR_USER_IDS` before deploying. The `deploy/prometheus.yml` in the [qwed-verification repository](https://github.com/QWED-AI/qwed-verification) documents the scrape bootstrap steps. See [Security Hardening](https://docs.qwedai.tech/advanced/security-hardening.md#all-tenant-metrics-authorization).

## QWED Verification — float precision advisory for math and stats verification

**Released: September 3, 2026** · [GitHub PR #348](https://github.com/QWED-AI/qwed-verification/pull/348) · Closes [issue #347](https://github.com/QWED-AI/qwed-verification/issues/347)

> Math and stats inputs may contain binary floating-point constants (`0.1 + 0.2`, `1e9`) whose evaluation can be inexact relative to decimal arithmetic. A new advisory surfaces that signal without gating anything: it is a precision advisory, never a rejection, because execution-safety gates decide what may run, not what is exact.

### What changed

* **`precision.float-constants` advisory.** When an expression or generated statistics code contains float or complex constants, the result carries an `AdvisoryCheck` in `developer_fields.advisory_checks` listing the offending constants and suggesting `decimal.Decimal` or exact SymPy rationals.
* **`/verify/math`.** The advisory is attached on all result branches. The submitted expression is checked lexically, so equations (`0.1 + 0.2 = 0.3`, `0.5x = 0.5x`) and collapsing forms retain their float literals even when symbolic simplification would eliminate them. Scientific notation and complex literals (`1.0j`) are flagged too.
* **Stats verification.** The completed-analysis result carries the advisory when the generated code uses floats — the expected shape for numpy/pandas code, so it flags without disrupting.
* **Structurally non-verdict-affecting.** `AdvisoryCheck` enforces `advisory_only=True` at construction. The advisory cannot change the verification `status` or `proof_ref`. Unparsable input yields no advisory; parse failures belong to the security gates.

See [Math engine — Float precision advisory](https://docs.qwedai.tech/engines/math.md#float-precision-advisory) and [Stats engine — Float precision advisory](https://docs.qwedai.tech/engines/stats.md#float-precision-advisory).

## QWED Verification — structural math parser and sandbox gate hardening (security release)

**Released: September 3, 2026** · [GitHub PR #344](https://github.com/QWED-AI/qwed-verification/pull/344) · [GitHub PR #346](https://github.com/QWED-AI/qwed-verification/pull/346)

> Two security fixes close sandbox-escape bypasses in math expression parsing and sandboxed code execution. The math parser now validates expressions structurally (NFKC normalization, ASCII charset gate, AST node allowlist) instead of relying on a denylist, and the consensus and stats execution paths gained a single-expression structural gate plus a module-indirection blocklist.

### Math expression parser (PR #344)

The safe expression parser applies layered structural validation before any SymPy evaluation:

* **NFKC normalization first.** CPython normalizes identifiers at compile time, so Unicode look-alike codepoints (mathematical bold letters, fullwidth forms) could previously slip past string filters. All checks now see exactly what the compiler sees.
* **ASCII charset allowlist.** Only letters, digits, underscore, whitespace, and `+ - * / ( ) . , ^ %` are accepted. A `.` is legal only as a decimal point with digits on both sides, which makes attribute access structurally impossible on the implicit-multiplication path.
* **AST node-type allowlist.** Python-parseable input may contain only arithmetic operators, function calls, names, and numeric constants. Attribute access, subscripts, string constants, lambdas, comprehensions, and comparisons are rejected before evaluation.
* **Caret exponentiation fixed.** `convert_xor` joined the default transformation pipeline, so `x^2` parses as `x**2` instead of failing at evaluation time.
* The regex denylist is retained as defense in depth.

### Execution gates (PR #346)

* **Single-expression gate on translated math.** A translated math expression must parse as exactly one Python expression (`ast.parse` in `eval` mode). Multi-statement, import-bearing, and multi-line-smuggled input fails by construction. Expressions over 500 characters are rejected before parsing.
* **Module-indirection blocklist.** The pre-execution AST check on sandboxed code now inspects every dotted segment of imports and attribute chains. Blocked module roots were extended (`posix`, `nt`, `importlib`, `ctypes`, `builtins`), OS-primitive call names (`system`, `popen`, `import_module`, the `exec*`/`spawn*` families, `fork`) are matched on bare names and attribute targets, and package-internal re-export gadgets through pandas/numpy internals are caught.
* **`sys` read-only allowlist in the stats executor.** Only named read-only interpreter metadata is accessible; frame introspection and every other `sys` member fail closed.

### Behavior changes

> [!WARNING]
>
> Math expression inputs that previously parsed may now be rejected before evaluation:
>
> * Bare decimals are rejected — write `0.5` and `2.0`, not `.5` and `2.`.
> * Non-ASCII symbols are rejected — write `alpha`, not `α`.
> * String constants, attribute access, and comparisons inside math expressions are rejected.
> * Multi-statement or import-bearing expressions fail by construction.
>
> Legitimate arithmetic, implicit multiplication (`2x`, `sin x`), and pandas/numpy public APIs in generated stats code (`np.linalg.norm`, `pd.Timestamp.now`) are unaffected.

### What this means for you

A character denylist can never defend an `eval` sink — these releases replace pattern-matching known attacks with structural guarantees that reject entire vulnerability classes by construction. See [Math engine — Expression input rules](https://docs.qwedai.tech/engines/math.md#expression-input-rules), [Consensus engine — Secure execution gates](https://docs.qwedai.tech/engines/consensus.md#secure-execution-gates), and [Stats engine — Pre-execution security validation](https://docs.qwedai.tech/engines/stats.md#pre-execution-security-validation).

***

## QWED Verification — API-key lookup migration and per-IP auth throttling

**Released: September 2, 2026** · [GitHub PR #345](https://github.com/QWED-AI/qwed-verification/pull/345)

> API-key lookup digests move from PBKDF2 to HMAC-SHA256 keyed by a new required `QWED_API_KEY_LOOKUP_SECRET`, removing \~67ms of CPU cost from every unauthenticated request. Anonymous `/auth/*` routes gain a per-IP rate limit, and password hashing no longer blocks the event loop.

### Breaking changes

> [!WARNING]
>
> **API keys issued before v7.2 stop working.** There is no PBKDF2 fallback — a legacy fallback would re-introduce the denial-of-service the migration fixes. Each key must be re-issued once; the old raw key is never required:
>
> ```bash theme={null}
> # Before (pre-v7.2 key): now rejected
> curl -X POST http://localhost:8000/verify/math \
>   -H "X-API-Key: qwed_live_old_key" \
>   -H "Content-Type: application/json" \
>   -d '{"expression": "1 + 1 = 2"}'
> # -> 401 Unauthorized
>
> # After: sign in with email/password (no API key needed), then mint a new key
> curl -X POST http://localhost:8000/auth/signin \
>   -H "Content-Type: application/json" \
>   -d '{"email": "user@example.com", "password": "..."}'
>
> curl -X POST http://localhost:8000/auth/api-keys \
>   -H "Authorization: Bearer eyJhbG..." \
>   -H "Content-Type: application/json" \
>   -d '{"name": "Production Key"}'
> ```
>
> Keys can also be rotated by key ID through `/admin/keys/rotate` using any already-working key. Newly issued and rotated keys are HMAC digests automatically.

### What changed

* **HMAC-SHA256 API-key lookup.** The previous PBKDF2 digest (100,000 iterations, \~67ms) ran on every request carrying an `x-api-key` header, valid or not, letting \~15 garbage requests per second saturate the service. API keys are high-entropy random tokens, so the KDF cost bought no brute-force resistance. The replacement keyed MAC costs microseconds.
* **Dedicated lookup secret (required).** Digests are keyed by `QWED_API_KEY_LOOKUP_SECRET`. Self-hosted servers fail closed at startup if it is missing or equal to `QWED_JWT_SECRET_KEY` — reusing the JWT secret would silently break every API-key lookup on the next JWT-secret rotation. Set it before issuing v7.2 keys; changing it later requires a one-time re-issue.
* **Per-IP rate limit on `/auth/*`.** Anonymous auth routes (signup, signin) are throttled per client IP: `QWED_RATE_LIMIT_PER_IP`, default 10 requests per minute. Over-limit requests get `429 Too Many Requests` with a `Retry-After` header of at least 1 second. `X-Forwarded-For` is honored only when the direct peer is listed in `QWED_AUTH_TRUSTED_PROXIES` (comma-separated CIDRs, default empty), and only the rightmost hop is used, so clients cannot choose their own bucket. The IP table is hard-bounded against spoofed-address floods.
* **Password hashing off the event loop.** Signup and signin bcrypt calls run in a worker thread instead of blocking the server. Signin also performs a dummy verify on unknown emails, so response timing no longer reveals which addresses are registered.
* **Atomic signup.** The organization and user rows commit in one transaction; a failure mid-signup rolls back both instead of stranding an orphaned organization.

### What this means for you

Re-issue any API key created before v7.2. Self-hosted operators must add `QWED_API_KEY_LOOKUP_SECRET` to their environment (distinct from `QWED_JWT_SECRET_KEY`) and, if running behind a proxy, set `QWED_AUTH_TRUSTED_PROXIES` so per-IP throttling keys on real client addresses. See [Authentication](https://docs.qwedai.tech/api/authentication.md#api-key-storage-and-migration-v7-2) for the migration path and [Rate limits](https://docs.qwedai.tech/api/rate-limits.md#per-ip-limits-on-authentication-endpoints) for the throttle contract.

***

## QWED-Tax — npm verifier enforces structured nexus claims fail-closed

**Released: September 1, 2026** · [GitHub PR #65](https://github.com/QWED-AI/qwed-tax/pull/65)

> The `@qwed-ai/tax` npm verifier now requires the structured boolean `claimed_collects_tax` claim for economic-nexus checks, matching the Python guard's contract. Unknown states, non-boolean claims, and malformed sales facts fail closed instead of passing as no-nexus.

### What changed

* **Structured claim required** — `TaxPreFlight.audit` and `NexusGuard.checkNexus` verify the boolean `claimed_collects_tax` against the independently computed nexus. Non-boolean values (including strings like `"false"`) are rejected with `"Computed nexus liability only. Provide claimed_collects_tax as a boolean for deterministic verification."`.
* **Legacy fallback narrowed** — `tax_decision: 'no_tax'` maps to `claimed_collects_tax: false` only when the intent does not contain a `claimed_collects_tax` key. A present-but-invalid structured claim is blocked even when the legacy string is also set.
* **Unknown states fail closed** — states not in the npm threshold table (currently NY, CA, TX, FL) return `verified: false` with a block-pending-configuration error instead of being verified as no-nexus.
* **Input hardening** — nexus validation runs whenever `sales_data` is present (including falsy or explicitly `undefined` values). Non-string `state`, non-finite amounts (`NaN`, `Infinity`), and negative sales or transaction counts are rejected before threshold math.

### What this means for you

If your frontend or Node integration relied on unknown states passing, on truthy strings as claims, or on omitting `sales_data` fields to skip the nexus check, those intents now block. Pass `claimed_collects_tax` as an explicit boolean and treat unknown-state blocks as hold-and-escalate signals. See [Economic nexus in the TypeScript SDK](https://docs.qwedai.tech/tax/integration.md#economic-nexus-in-the-typescript-sdk) for the full contract.

***

## QWED-Tax — explicit boolean claim required for economic nexus verification

**Released: August 31, 2026** · [GitHub PR #62](https://github.com/QWED-AI/qwed-tax/pull/62)

> Economic nexus verification now fails closed unless the caller provides an explicit `claimed_collects_tax` boolean. Free-form `tax_decision` / `llm_decision` text is no longer interpreted as a verification claim — free-form model output cannot be a verification substrate.

### What changed

* **`NexusGuard.check_nexus_liability`** — Gains a keyword-only `claimed_collects_tax: bool` parameter. The guard computes the state's threshold independently and verifies the boolean against the computed nexus. When the parameter is omitted, the guard returns `{"verified": False, "computed_only": True, "has_nexus": <bool>, "error": "Computed nexus liability only. Provide claimed_collects_tax as a boolean for deterministic verification."}`. Non-boolean values (including truthy strings) return `verified=False` with an invalid-claim error.
* **`llm_decision` deprecated** — The parameter is retained for positional compatibility but is never parsed. Passing it has no effect on the verdict.
* **`TaxPreFlight` `economic_nexus` intents** — The required claim field changed from `tax_decision` (string) to `claimed_collects_tax` (boolean). Intents that still send `tax_decision` are blocked as incomplete claims before `NexusGuard` runs.

### Breaking changes

> [!WARNING]
>
> Callers that previously passed a free-form decision string — `check_nexus_liability(state, sales, count, llm_decision="No tax needed")` or a `TaxPreFlight` intent with `"tax_decision": "No tax needed"` — now receive a fail-closed result instead of a verified verdict. Translate the AI's decision into an explicit boolean before calling:
>
> ```python theme={null}
> # Before
> guard.check_nexus_liability("NY", 600000, 10, llm_decision="No tax needed")
>
> # After
> guard.check_nexus_liability("NY", 600000, 10, claimed_collects_tax=False)
> ```

### What this means for you

An LLM can no longer "verify" its own nexus decision by phrasing it in text — the claim must be a machine-checkable boolean that the guard compares against the deterministically computed threshold. See [NexusGuard](https://docs.qwedai.tech/tax/guards.md#nexusguard-economic-nexus) for the full parameter contract and [TaxPreFlight integration](https://docs.qwedai.tech/tax/integration.md#economic-nexus-requires-an-explicit-boolean-claim) for the intent migration.

***

***

## QWED Open Responses — fail-closed verification and expanded guard coverage

**Released: August 30, 2026** · [GitHub PR #33](https://github.com/QWED-AI/qwed-open-responses/pull/33)

> `verify()` now fails closed when no guards are configured, ToolGuard recognizes and polices more tool-call envelope shapes, and SafetyGuard scans nested content. All three changes ship in both the Python and TypeScript packages.

> [!WARNING]
>
> **Breaking behavior change.** `verify()` with zero guards now returns `verified=False` with the guard result message "No guards configured", and `blocked=True` in strict mode. Previously it returned `verified=True` because no guard failed. If you use `ResponseVerifier()` or `VerifiedOpenAI` without configuring guards, those calls now block: set `default_guards` (Python) / `defaultGuards` (TypeScript) or pass guards per call.

### What changed

* **Zero-guard verify fails closed.** Absence of verification is not success. A `verify()` call with no guards returns a `ResponseVerifier` guard result with `severity="error"` and the block reason "No guards configured — fail-closed (zero-guard verify)."
* **ToolGuard envelope coverage.** ToolGuard now extracts tool calls from Anthropic `content[].type=tool_use` blocks and matches `type` values case-insensitively, so a `Tool_Use` block is policed instead of ignored. OpenAI `function` wrappers and JSON-encoded argument strings are normalized before blocklist and dangerous-pattern checks.
* **ToolGuard fail-closed rejections.** Tool-like content in an unrecognized shape, non-object entries in `tool_calls`/`choices`/`content`, hybrid envelopes that mix a direct tool call with a sibling collection, nameless tool calls, and argument payloads over 10,000 characters or 128 nesting levels are all blocked instead of passing silently.
* **SafetyGuard recursive extraction.** SafetyGuard scans string content nested up to 12 levels deep, including the canonical `choices[].message.content` shape, so PII, injection, and harmful patterns inside nested structures are detected.

### What this means for you

If your agent relied on `verify()` passing with no guards, or on responses in unrecognized tool-call shapes passing as "no tool calls", those calls now block. Configure the guards you need and emit tool calls in a recognized envelope.

See the [guards reference](https://docs.qwedai.tech/open-responses/guards.md) and [troubleshooting](https://docs.qwedai.tech/open-responses/troubleshooting.md) for the updated contracts.

***

**Released: August 31, 2026** · [GitHub PR #62](https://github.com/QWED-AI/qwed-tax/pull/62)

> Economic nexus verification now fails closed unless the caller provides an explicit `claimed_collects_tax` boolean. Free-form `tax_decision` / `llm_decision` text is no longer interpreted as a verification claim — free-form model output cannot be a verification substrate.

### What changed

* **`NexusGuard.check_nexus_liability`** — Gains a keyword-only `claimed_collects_tax: bool` parameter. The guard computes the state's threshold independently and verifies the boolean against the computed nexus. When the parameter is omitted, the guard returns `{"verified": False, "computed_only": True, "has_nexus": <bool>, "error": "Computed nexus liability only. Provide claimed_collects_tax as a boolean for deterministic verification."}`. Non-boolean values (including truthy strings) return `verified=False` with an invalid-claim error.
* **`llm_decision` deprecated** — The parameter is retained for positional compatibility but is never parsed. Passing it has no effect on the verdict.
* **`TaxPreFlight` `economic_nexus` intents** — The required claim field changed from `tax_decision` (string) to `claimed_collects_tax` (boolean). Intents that still send `tax_decision` are blocked as incomplete claims before `NexusGuard` runs.

### Breaking changes

> [!WARNING]
>
> Callers that previously passed a free-form decision string — `check_nexus_liability(state, sales, count, llm_decision="No tax needed")` or a `TaxPreFlight` intent with `"tax_decision": "No tax needed"` — now receive a fail-closed result instead of a verified verdict. Translate the AI's decision into an explicit boolean before calling:
>
> ```python theme={null}
> # Before
> guard.check_nexus_liability("NY", 600000, 10, llm_decision="No tax needed")
>
> # After
> guard.check_nexus_liability("NY", 600000, 10, claimed_collects_tax=False)
> ```

### What this means for you

An LLM can no longer "verify" its own nexus decision by phrasing it in text — the claim must be a machine-checkable boolean that the guard compares against the deterministically computed threshold. See [NexusGuard](https://docs.qwedai.tech/tax/guards.md#nexusguard-economic-nexus) for the full parameter contract and [TaxPreFlight integration](https://docs.qwedai.tech/tax/integration.md#economic-nexus-requires-an-explicit-boolean-claim) for the intent migration.

***

## QWED-MCP v0.2.2 — hardened math expression sandbox (security release)

**Released: August 27, 2026** · [GitHub PR #48](https://github.com/QWED-AI/qwed-mcp/pull/48) · [GHSA-2p69-jpm6-jrxh ↗](https://github.com/QWED-AI/qwed-mcp/security/advisories/GHSA-2p69-jpm6-jrxh)

> `qwed-mcp` v0.2.2 fixes **GHSA-2p69-jpm6-jrxh** (CWE-94), a residual sandbox-escape bypass in the math expression parser. The previous regex denylist could be evaded through unlisted dunder names, string-literal splitting, and NFKC-equivalent Unicode identifiers. The parser now validates expressions structurally before any evaluation.

### What changed

The math expression sandbox in `qwed-mcp` now applies layered validation before an expression reaches SymPy's `parse_expr`:

* **AST node allowlist.** Only arithmetic node types survive (binary and unary operations, calls, names, numeric constants). The allowlist rejects attribute access, subscripts, lambdas, and comprehensions before parsing. Every sandbox-escape traversal needs an attribute or subscript node; legitimate math never does.
* **String and bytes literals rejected.** The validator fails expressions containing string or bytes constants with `SafeParserError`, closing the concatenation-gadget class (for example `'__glo'+'bals'+'__'`).
* **NFKC normalization before validation.** CPython NFKC-normalizes identifiers at compile time, so Unicode look-alike characters (mathematical bold letters, fullwidth forms) could previously bypass the ASCII denylist. The sandbox now normalizes input first, so the checks see exactly what the compiler sees.
* **Implicit-multiplication charset guard.** Inputs that are not valid Python (such as `2x` or `sin x`) cannot pass AST checks, so the guard restricts their character set instead: it bans quotes, brackets, and separators, and allows `.` only as a decimal point. `2x`, `sin x`, `x^2`, `2.5x`, and `2(x+1)` still parse; the guard blocks `2x.__class__`.
* **Regex denylist retained** as defense in depth.

### What this means for you

Upgrade to `qwed-mcp` 0.2.2 or later:

```bash theme={null}
pip install --upgrade qwed-mcp
```

No API changes. Legitimate mathematical expressions parse exactly as before. Expressions containing attribute access, subscripts, string literals, or other non-arithmetic syntax now fail with a `SafeParserError` instead of reaching the evaluator.

See the [MCP tools reference](https://docs.qwedai.tech/mcp/tools.md) for the current tool surface.

***

## QWED-Infra v0.3.0 — Verification Context v1.0 and attestation trust boundary

**Released: August 24, 2026**

> Every `qwed-infra` guard now emits a portable [Verification Context v1.0 document](https://docs.qwedai.tech/infra/guards.md#verification-context-documents) via `to_verification_context()`, and `ADMIT` decisions now require a cryptographically valid ES256 attestation bound to the exact claim and evidence.

### Added

* **`to_verification_context()` on all four guards** — [IamGuard (#45)](https://github.com/QWED-AI/qwed-infra/pull/45), [NetworkGuard (#46)](https://github.com/QWED-AI/qwed-infra/pull/46), [CostGuard (#48)](https://github.com/QWED-AI/qwed-infra/pull/48), and [ArtifactBoundaryGuard (#50)](https://github.com/QWED-AI/qwed-infra/pull/50) each produce a schema-valid, tamper-evident VC document with claim, verifier identity, `sha256`-bound evidence, and an `ADMIT`/`DENY` admission decision, built on the shared bridge module from [PR #44](https://github.com/QWED-AI/qwed-infra/pull/44).
* **Attestation trust boundary** ([PR #54](https://github.com/QWED-AI/qwed-infra/pull/54)) — ES256 (ECDSA P-256) JWT attestation service with a never-`None` fail-closed `AttestationResult` contract, revocation registry, and a single consumption-side gate validating signature, issuer, expiry, and revocation plus claim bindings (status match, `query_hash == sha256(formal_statement)`, `proof_hash == proof_ref`).
* **`mint_diagnostic_attestation()`** — issues a token bound to a `VERIFIED` diagnostic's own evidence commitment.

### Breaking changes

> [!WARNING]
>
> **Signature migration (pre-1.0 API).** Guard adapters no longer accept pre-computed result objects. `to_verification_context()` takes raw verification inputs and runs the guard's own deterministic solver internally. A result-accepting signature is forgeable, so it was removed before release. Pass raw inputs: `NetworkGuard.to_verification_context(resources, source, destination, port, ...)`, `IamGuard.to_verification_context(policy, action, resource, context=None, ...)`, `CostGuard.to_verification_context(resources, budget_monthly, ...)`, `ArtifactBoundaryGuard.to_verification_context(package_dir, ...)`.

* **Admission semantics** — `VERIFIED` results admit only with a cryptographically valid attestation bound to the exact claim and evidence. Arbitrary non-empty attestation strings are rejected as forged tokens and produce `BLOCKED`. A missing token demotes `VERIFIED` to `UNVERIFIABLE`/`DENY`. Callers previously passing placeholder tokens must mint via `create_verification_attestation()` or `mint_diagnostic_attestation()`.
* **New runtime dependencies** — `pyjwt` and `cryptography` for attestation signing and validation.

### Fixed

* **Fail-closed on malformed inputs at every VC boundary** ([PR #51](https://github.com/QWED-AI/qwed-infra/pull/51) and follow-ups) — undecimal budgets, non-string build backends, malformed topology, policy, or package inputs, symlink escapes and loops, and wheel entries outside the scanned boundary all map to `BLOCKED`/`DENY` documents instead of exceptions or guessed approval.

See the [guards reference](https://docs.qwedai.tech/infra/guards.md) and [usage examples](https://docs.qwedai.tech/infra/examples.md) for the updated contracts.

***

## v7.1.0 — Verification Context v1.0 Rollout

**Released: August 16, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.1.0) · [GitHub PR #317](https://github.com/QWED-AI/qwed-verification/pull/317)

> v7.1.0 ships [Verification Context v1.0](https://docs.qwedai.tech/specs/verification-context.md) end-to-end: a formal specification with a machine-readable JSON Schema, a typed document model with fail-closed invariants, public `proof_ref` generation and resolution, and exposure across the API, Python SDK, CLI, verifiers, and the Docker GitHub Action. The release is additive — a semver minor with no breaking wire changes.

### Spec and core model

* **Verification Context v1.0 specification** — the atomic JSON record of a verification: verified object, four context layers (interpretation / proof / evidence / decision), verdict, and admission, with canonical RFC 8785 encoding for the `proof_ref` evidence commitment (PR #302). See the [Verification Context specification](https://docs.qwedai.tech/specs/verification-context.md).
* **`VerificationContextDocument` model + schema validation** — typed `VerificationContext`, `VerificationContextDocument`, `Verdict`, `Admission`, and nested layer types with fail-closed invariants enforced at construction: `VERIFIED` requires a `sha256:<64-hex>` `proof_ref`; `UNVERIFIABLE`/`BLOCKED` require `proof_ref: null` with admission `DENY` (PR #308).
* **Public `proof_ref` generation and resolution** — `compute_document_proof_ref()` and `resolve_document_proof_ref()` derive and verify the content-bound SHA-256 commitment over the canonical document. Resolution fails closed: a `proof_ref` that cannot be resolved confers no authority (PR #309).

### Surface exposure

* **API endpoints** — [`POST /verification-context/from-diagnostic`, `/validate`, and `/resolve`](https://docs.qwedai.tech/api/endpoints#verification-context-endpoints) convert `DiagnosticResult` records into VC documents, validate documents against the schema, and resolve `proof_ref` commitments (PR #311).
* **CLI commands** — the [`qwed context`](https://docs.qwedai.tech/advanced/cli#qwed-context-verification-context-utilities) group: `validate`, `resolve`, and `from-diagnostic` (PR #311).
* **SDK re-exports** — all VC v1.0 types and helpers importable directly from `qwed_sdk`, plus `create_verification_context_from_diagnostic()`, `validate_verification_context()`, and `resolve_verification_context()` on both sync and async clients. See the [Python SDK](https://docs.qwedai.tech/sdks/python.md#verification-context) (PRs #311, #315).
* **`to_verification_context()` across the verifier classes** — implemented on the Code, Consensus, DSL Logic, Fact, Graph Fact, Image, Logic, Reasoning, Schema, SQL, Stats, and Symbolic verifiers plus the `BatchFactVerifier` and `MultiVLMVerifier` helpers, each mapping a `DiagnosticResult` to a VC document. Math and the secure code executor have no such method; their results convert through `POST /verification-context/from-diagnostic` (PRs #310, #316).
* **Docker action VC outputs** — the GitHub Action emits `verdict`, `admission`, `proof_ref`, and `verification_context` [outputs](https://docs.qwedai.tech/advanced/github-action.md#verification-context-outputs) in every mode (PR #313).

### Fail-closed behavior

* A `VERIFIED` diagnostic without an attestation token fails closed to `BLOCKED` (`trust_gate.mandatory_attestation_missing`) when converted to a VC document — attestation is enforced through the same trust boundary as `/verify/*`.
* Malformed diagnostic payloads convert to `BLOCKED` documents instead of crashing, so the audit trail records the failure.
* `resolve` returns `true` only for a schema-valid `VERIFIED` document whose stored `proof_ref` matches the re-derived commitment.

### Version bumps

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `7.0.0` | `7.1.0` |
| `qwed_sdk` (Python) | `7.0.0` | `7.1.0` |
| `@qwed-ai/sdk` (npm) | `7.0.0` | `7.1.0` |
| `qwed` (crates.io) | `7.0.0` | `7.1.0` |
| API version marker | `7.0.0` | `7.1.0` |

### Included PRs

| PR | Summary |
| - | - |
| [#302](https://github.com/QWED-AI/qwed-verification/pull/302) | Verification Context Specification v1.0 + JSON Schema |
| [#308](https://github.com/QWED-AI/qwed-verification/pull/308) | `VerificationContext` model and schema validation helpers |
| [#309](https://github.com/QWED-AI/qwed-verification/pull/309) | Public `proof_ref` generation and resolution |
| [#310](https://github.com/QWED-AI/qwed-verification/pull/310) | `StatsVerifier` `DiagnosticResult` → Verification Context mapping |
| [#311](https://github.com/QWED-AI/qwed-verification/pull/311) | Verification Context exposed across SDK, API, and CLI |
| [#313](https://github.com/QWED-AI/qwed-verification/pull/313) | Docker action emits VC v1.0 outputs |
| [#315](https://github.com/QWED-AI/qwed-verification/pull/315) | VC v1.0 types re-exported from `qwed_sdk` |
| [#316](https://github.com/QWED-AI/qwed-verification/pull/316) | `to_verification_context()` on all remaining verifiers |
| [#317](https://github.com/QWED-AI/qwed-verification/pull/317) | v7.1.0 release preparation |

### Upgrading

No action required. Every change is additive: existing `/verify/*` responses, SDK methods, CLI commands, and action outputs are unchanged. Adopt Verification Context by calling the new endpoints, importing the new types from `qwed_sdk`, or reading the new action outputs.

***

***

## v7.0.0 — Full DiagnosticResult engine conformance

**Released: August 8, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v7.0.0) · [GitHub PR #300](https://github.com/QWED-AI/qwed-verification/pull/300)

> v7.0.0 completes the engine migration to the unified [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) contract (META #216). `SchemaVerifier`, `SQLVerifier`, `CodeVerifier`, `SecureCodeExecutor`, and `StatsVerifier` — plus the fact and image batch entry points — now all return `DiagnosticResult` (`status` / `agent_message` / `developer_fields` / `proof_ref`). The release also completes the truth-vs-admission separation: verification answers *"is this claim provably true?"* while a separate `AdmissionDecision` answers *"should this be allowed at this boundary?"*.

> [!WARNING]
>
> **Two breaking wire changes.** `POST /verify/code` now returns `status: "VERIFIED"` for proven-unsafe code, with admission driven by the new `admission` field. `POST /verify/stats` now returns `status: "UNVERIFIABLE"` on successful execution instead of the legacy `SUCCESS` shape — execution is not verification. Migration examples below.

### Breaking: `POST /verify/code` — `VERIFIED` is truth, `admission` is policy

Proving a snippet is unsafe is a successful proof. Unsafe code is therefore `VERIFIED` (previously `BLOCKED`), with `developer_fields.is_valid: false`, a bound `proof_ref`, and a non-null `critical_count`. `BLOCKED` is reserved for cases where verification itself failed (empty code, non-string `language`, internal errors), and blocked results carry no `proof_ref`. The response attaches an explicit `admission` field (`ADMIT` / `BLOCKED`) so authority-only consumers reading `status == "VERIFIED"` cannot admit unsafe code.

**Before (v6.x) — unsafe code:**

```json theme={null}
{
  "status": "BLOCKED",
  "agent_message": "The code failed security verification and is not safe to use.",
  "developer_fields": { "is_safe": false },
  "proof_ref": null
}
```

**After (v7.0.0) — unsafe code:**

```json theme={null}
{
  "status": "VERIFIED",
  "agent_message": "The code failed security verification and is not safe to use.",
  "developer_fields": {
    "constraint_id": "code_verifier.code_unsafe",
    "is_safe": false,
    "is_valid": false,
    "critical_count": 1,
    "issues": [ ... ]
  },
  "proof_ref": "sha256:...",
  "is_authoritative": true,
  "admission": "BLOCKED"
}
```

**Migrating:** consumers that branched on `status == "BLOCKED"` or `status == "VERIFIED"` for safety gating must switch to the `admission` field or `developer_fields.is_valid`. `status == "VERIFIED"` alone must never be treated as "safe to execute".

```python theme={null}
# Before (v6.x)
if response["status"] == "VERIFIED":
    execute(code)

# After (v7.0.0)
if response["admission"] == "ADMIT":
    execute(code)
```

### Breaking: `POST /verify/stats` — execution success is never `VERIFIED`

A run that executes cleanly in the Docker sandbox and returns an observed statistic is `UNVERIFIABLE` (`stats_verifier.claim_not_verified`) — the engine has no deterministic claim-proof, so it cannot attest the original natural-language claim. Execution evidence (`observed_result`, `generated_code`, `columns`, a deterministic `dataset_sha256`, sandbox type, timing, and security checks) is retained in `developer_fields` for audit. `BLOCKED` is reserved for failure states: `stats_verifier.validation_error`, `stats_verifier.execution_failure`, and `stats_verifier.runtime_unavailable`.

**Before (v6.x) — successful execution:**

```json theme={null}
{
  "status": "SUCCESS",
  "result": 52340.5,
  "code": "df['salary'].mean()"
}
```

**After (v7.0.0) — successful execution:**

```json theme={null}
{
  "status": "UNVERIFIABLE",
  "agent_message": "Statistical analysis completed, but the claim could not be deterministically verified.",
  "developer_fields": {
    "constraint_id": "stats_verifier.claim_not_verified",
    "is_valid": false,
    "observed_result": 52340.5,
    "generated_code": "df['salary'].mean()",
    "dataset_sha256": "9f2c...",
    "sandbox_type": "docker"
  },
  "proof_ref": null,
  "is_authoritative": false
}
```

**Migrating:** read the computed value from `developer_fields.observed_result` instead of `result`, and treat the verdict as advisory — a legitimate execution is not a proven claim. `compute_statistics()` and `get_sandbox_info()` are utilities, not verification boundaries, and keep their existing dict return shape.

### `SchemaVerifier` → `DiagnosticResult` ([#294](https://github.com/QWED-AI/qwed-verification/pull/294))

* `verify()` and `verify_ucp_transaction()` return `DiagnosticResult`. A schema violation is `VERIFIED` (as-invalid) with `developer_fields.is_valid: false`; `BLOCKED` (`schema_verifier.parse_error` / `schema_verifier.validation_error`) is reserved for schemas that cannot be parsed or validated.
* `proof_ref` is computed deterministically from canonical JSON of the schema + instance evidence; unsupported values and cyclic structures fail closed to `BLOCKED`.
* Recursive schema meta-validation: malformed keyword shapes (non-dict `properties`, invalid `required` entries, invalid or non-finite numeric constraints, negative size constraints) return `BLOCKED` instead of being silently treated as empty. Oversized integer bounds (e.g. `10**1000`) no longer raise `OverflowError`.
* UCP hardening: money arithmetic uses `Decimal` quantized to the currency precision (no more `0.01` float tolerance), `tax` is selected by key presence so `tax: 0` is honored, verdict fields (`transaction_type`, `currency`, `schema_verifier.ucp_*` constraint ids) are complete on every path, and string/`None` amounts no longer raise.
* `agent_message` is sanitized — no rule IDs, issue types, or schema internals leak into agent-facing output.

See the updated [Schema verifier](https://docs.qwedai.tech/engines/schema.md) page.

### `SQLVerifier` → `DiagnosticResult` ([#295](https://github.com/QWED-AI/qwed-verification/pull/295))

* `verify_sql()` returns `DiagnosticResult`. A safe query is `VERIFIED` (`sql_verifier.sql_valid`, `is_valid: true`, `proof_ref` from the AST). A proven-malicious query is `VERIFIED`-as-malicious (`sql_verifier.malicious`, `is_valid: false`, `malicious_classification: true`) — proving malice is a successful proof, so it retains its `proof_ref`.
* `BLOCKED` is reserved for incomplete or failed analysis: `sql_verifier.parse_error`, `sql_verifier.schema_parse_error` (takes precedence over malice detection — no authoritative proof from incomplete analysis), `sql_verifier.complexity_limit_exceeded`, and `sql_verifier.execution_error`.
* `POST /verify/sql` attaches the `admission` field (`ADMIT` / `BLOCKED`) alongside the verdict.

See the updated [SQL engine](https://docs.qwedai.tech/engines/sql.md) page.

### `CodeVerifier` & `SecureCodeExecutor` → `DiagnosticResult` ([#296](https://github.com/QWED-AI/qwed-verification/pull/296))

* `verify_code()`, `verify_python_deep()`, and `verify_batch()` return `DiagnosticResult` with the truth-vs-admission semantics described above.
* `verify_batch()` returns per-item `verdicts` plus a `summary` (`safe` / `unsafe` / `blocked` counts, `total_critical`) and an overall `is_valid` that is `true` only when **all** snippets are safe — the batch is otherwise non-admissible.
* `SecureCodeExecutor.execute()` no longer executes code wholesale on the verifier verdict: an unconditional OWASP LLM06 dangerous-pattern gate blocks execution with `CONSTRAINT_DANGEROUS_PATTERN`. The scan is AST-aware, so dangerous keywords appearing only in comments, docstrings, or string literals do not cause false denials.
* `ConsensusVerifier` and `StatsVerifier` code stages now require `is_verified` **and** `developer_fields.is_valid is True`, so consensus results can no longer admit unsafe code.

See the updated [Code engine](https://docs.qwedai.tech/engines/code.md#security-scanning-codeverifier) page.

### `StatsVerifier` → `DiagnosticResult` ([#297](https://github.com/QWED-AI/qwed-verification/pull/297))

* `verify_stats()` returns `DiagnosticResult` with the execution-is-not-verification semantics described above.
* The API boundary is a thin pass-through: `POST /verify/stats` forwards the engine's `DiagnosticResult` through `enforce_trust_decision()` unchanged.
* Logging is fail-closed and claim-aware: a non-authoritative result (`BLOCKED` / `UNVERIFIABLE`, `proof_ref: null`) can never be persisted as verified, even if mutable `developer_fields.is_valid` metadata is `true`.
* A non-serializable sandbox result (e.g. a DataFrame) is coerced to a JSON-safe value before entering `developer_fields`, so a legitimate `UNVERIFIABLE` verdict is not silently downgraded to `BLOCKED`.

See the updated [Stats engine](https://docs.qwedai.tech/engines/stats.md) page.

### Fact & image batch verification fail closed ([#297](https://github.com/QWED-AI/qwed-verification/pull/297))

`BatchFactVerifier.verify_batch()` and `ImageVerifier.verify_batch()` — the last two public engine entry points returning ad-hoc dicts — now return a single `DiagnosticResult` with per-claim verdicts in `developer_fields.results` and a `summary`:

* The batch is authoritative (`VERIFIED` + `proof_ref`) only when **every** claim is deterministically verified.
* Any refuted or blocked claim fails the whole batch closed (`fact_verifier.batch_blocked` / `image_verifier.batch_blocked`).
* An empty batch is `BLOCKED` (`*.empty_batch`).
* The batch `proof_ref` binds full claim digests **and the shared input** (image digest for image batches, context digest for fact batches), never truncated display text.
* Aggregation is shared via `diagnostics.aggregate_batch_diagnostic()` so the fail-closed logic cannot drift between engines.

### Version bumps

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `6.0.0` | `7.0.0` |
| `qwed_sdk` (Python) | `6.0.0` | `7.0.0` |
| `@qwed-ai/sdk` (npm) | `6.0.0` | `7.0.0` |
| `qwed` (crates.io) | `6.0.0` | `7.0.0` |
| API version marker | `6.0.0` | `7.0.0` |

### Included PRs

| PR | Summary |
| - | - |
| [#294](https://github.com/QWED-AI/qwed-verification/pull/294) | `SchemaVerifier` → `DiagnosticResult` |
| [#295](https://github.com/QWED-AI/qwed-verification/pull/295) | `SQLVerifier` → `DiagnosticResult` |
| [#296](https://github.com/QWED-AI/qwed-verification/pull/296) | `CodeVerifier` and `SecureCodeExecutor` → `DiagnosticResult` |
| [#297](https://github.com/QWED-AI/qwed-verification/pull/297) | `StatsVerifier.verify_stats` → `DiagnosticResult` (+ fact/image batch) |
| [#300](https://github.com/QWED-AI/qwed-verification/pull/300) | Release: v7.0.0 — Full DiagnosticResult Engine Conformance |

### Upgrading

If you only consume the high-level SDK clients and gate on `admission` / `developer_fields.is_valid`, bump the version and you are done. If your code branches on `status` from `POST /verify/code` for safety gating, or parses the legacy `SUCCESS` / `ERROR` shape from `POST /verify/stats`, apply the migrations shown above before upgrading.

***

## v6.0.0 — Trust Boundary Completion

**Released: August 2, 2026** · [GitHub Release ↗](https://github.com/QWED-AI/qwed-verification/releases/tag/v6.0.0) · [GitHub PR #291](https://github.com/QWED-AI/qwed-verification/pull/291)

> v6.0.0 closes the **Trust Boundary Completion** epic (Issue #263, 12/12 sub-issues). Every verification pathway now returns a unified [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) and routes through `enforce_trust_decision`. The trust boundary is no longer advisory: the control plane requires and verifies attestation before admitting `VERIFIED` results, and `VERIFIED` is a protocol guarantee backed by a deterministic `proof_ref` — never by execution, agreement, confidence, or provenance.

> [!WARNING]
>
> **Breaking change.** `/verify/*` API responses now use the unified [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) schema (`status` / `agent_message` / `developer_fields` / `proof_ref`). Consumers that parsed the previous ad-hoc dict responses must migrate to the unified 3-layer contract. The high-level SDK clients (`qwed`, `qwed_sdk`, `@qwed-ai/sdk`, and the `qwed` Rust crate) already surface `DiagnosticResult` at v6.0.0 and require no code change beyond the version bump.

### Architecture: observation vs. admission

The API is the **observation surface** — an honest witness that returns what verification found. The control plane is the **admission authority** — the judge that decides what is admitted as `VERIFIED`. `QWED_RULES.md` now codifies this separation (#13 Separation of Responsibilities, #14 Verification Semantics, #15 Truth Before Policy; rules #7/#8 updated for admission-boundary and deterministic-proof semantics).

* **All `/verify/*` endpoints return `DiagnosticResult`** — unified response contract across every verification surface (PR #276)
* **Control plane enforces mandatory attestation** — `require_attestation=True`, attestation issued and verified at the admission boundary, and the enforced status drives the HTTP response status (PR #278). See [Cryptographic attestations](https://docs.qwedai.tech/advanced/attestations.md).
* **Batch math routes through the trust boundary** — `/verify/batch` results carry `DiagnosticResult`, `proof_ref`, and attestation, and pass through `enforce_trust_decision` (PR #282). See [`POST /verify/batch`](https://docs.qwedai.tech/api/endpoints#post-%2Fverify%2Fbatch).
* **Attestation scope alignment** — attestations bind to the translated expression, not the natural-language query, so `query_hash` binds to what was actually verified (PR #285).

### `VERIFIED` is a protocol guarantee

No engine emits `VERIFIED` without a deterministic `proof_ref`. Heuristic and advisory analysis now reports `UNVERIFIABLE` with structured `advisory_checks` instead of masquerading as verified.

* **`ConsensusResult`** uses the `DiagnosticStatus` enum with `proof_ref` and `verified_evidence` (PR #280). See [Consensus engine](https://docs.qwedai.tech/engines/consensus.md).
* **`FactVerifier`** heuristic SUPPORTED verdict → `UNVERIFIABLE` with `advisory_checks` (PR #283).
* **Consensus code execution** advisory-only — `VERIFIED` → `UNVERIFIABLE` (PR #281).
* **Consensus stats** advisory-only — never `VERIFIED` (PR #277).
* **`LogicVerifier`** migrated to `DiagnosticResult` (PR #262 — details in the entry below).
* **`AgentStateGuard`** `proof_ref` is now a real `sha256` of committed bytes, not a static sentence (PR #284). See [Agent state guard](https://docs.qwedai.tech/advanced/agent-state-guard.md).

### Engineering and security hardening

* **TOCTOU closure in `enforce_trust_decision`** — `developer_fields` snapshotted via recursive rebuild (no `deepcopy` alias window); fail-closed snapshot (PR #290)
* **Attestation signature verified before claim decode** — silent generic error for every failure mode (PR #287)
* **Tenant-isolated verification cache** — `VerificationCache` keys namespaced by normalized `tenant_id` (PR #286)
* **Unicode normalization** in `AgentStateGuard` canonicalization — NFC collisions rejected (PR #288)
* **Mandatory proof artifact** for `VERIFIED` attestations at both issuance and consumption (PR #248)
* **Credential / JWT / dockerignore security alerts** resolved (PR #249)
* **Math whitelist injection bypass** removed (PR #251)
* **Engine classification docs** — Proof / Policy Enforcement / Advisory (PR #247)

### Version bumps

Every SDK, container, and manifest ships at 6.0.0:

| Surface | From | To |
| - | - | - |
| `qwed` (PyPI) | `5.3.0` | `6.0.0` |
| `qwed_sdk` (Python) | `5.3.0` | `6.0.0` |
| `@qwed-ai/sdk` (npm) | `5.3.0` | `6.0.0` |
| `qwed` (crates.io) | `5.3.0` | `6.0.0` |
| Docker image `qwedai/qwed-verification` | `5.3.0` | `6.0.0` |
| Kubernetes deployment image tag | `5.3.0` | `6.0.0` |
| API version marker | `5.3.0` | `6.0.0` |

### Included PRs

The following 21 PRs shipped in the v6.0.0 release:

| PR | Summary |
| - | - |
| [#247](https://github.com/QWED-AI/qwed-verification/pull/247) | docs: engine classification — Proof / Policy Enforcement / Advisory |
| [#248](https://github.com/QWED-AI/qwed-verification/pull/248) | Enforce mandatory proof artifact on `VERIFIED` attestations (issuance + consumption) |
| [#249](https://github.com/QWED-AI/qwed-verification/pull/249) | Resolve credential / JWT / dockerignore security alerts |
| [#251](https://github.com/QWED-AI/qwed-verification/pull/251) | Remove math whitelist injection bypass |
| [#260](https://github.com/QWED-AI/qwed-verification/pull/260) | Hybrid engine advisory-only — never `VERIFIED` without proof |
| [#261](https://github.com/QWED-AI/qwed-verification/pull/261) | `FactVerifier` advisory-only |
| [#262](https://github.com/QWED-AI/qwed-verification/pull/262) | `LogicVerifier` migrated to `DiagnosticResult` |
| [#276](https://github.com/QWED-AI/qwed-verification/pull/276) | Migrate all `/verify/*` endpoints to return `DiagnosticResult` |
| [#277](https://github.com/QWED-AI/qwed-verification/pull/277) | Consensus stats advisory-only, never `VERIFIED` |
| [#278](https://github.com/QWED-AI/qwed-verification/pull/278) | Control plane trust enforcement mandatory |
| [#280](https://github.com/QWED-AI/qwed-verification/pull/280) | `ConsensusResult` `DiagnosticStatus` enum + `proof_ref` + `verified_evidence` |
| [#281](https://github.com/QWED-AI/qwed-verification/pull/281) | Consensus code execution advisory-only |
| [#282](https://github.com/QWED-AI/qwed-verification/pull/282) | Batch math `DiagnosticResult` → `proof_ref` + attestation + `enforce_trust_decision` |
| [#283](https://github.com/QWED-AI/qwed-verification/pull/283) | `FactVerifier` `SUPPORTED` → `UNVERIFIABLE` with heuristic `advisory_checks` |
| [#284](https://github.com/QWED-AI/qwed-verification/pull/284) | `AgentStateGuard` `proof_ref` real `sha256` |
| [#285](https://github.com/QWED-AI/qwed-verification/pull/285) | Attest translated expression, not natural-language query |
| [#286](https://github.com/QWED-AI/qwed-verification/pull/286) | `VerificationCache` tenant isolation |
| [#287](https://github.com/QWED-AI/qwed-verification/pull/287) | Attestation verify-before-decode + silent generic error |
| [#288](https://github.com/QWED-AI/qwed-verification/pull/288) | NFC-normalize `AgentStateGuard` canonicalization |
| [#289](https://github.com/QWED-AI/qwed-verification/pull/289) | Mock network in secret redaction tests (CI) |
| [#290](https://github.com/QWED-AI/qwed-verification/pull/290) | Close TOCTOU in `enforce_trust_decision` |

### Upgrading

If you are on v5.3.x and only consume the high-level SDK clients or attestations, bump the version and you are done — every engine already returns `DiagnosticResult`. If your code parses raw HTTP responses from `/verify/*`, migrate to the [3-layer `DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) shape before upgrading. Direct callers of `LogicVerifier` should also review the [`LogicVerifier` migration notes](#v6-0-0-—-logicverifier-conforms-to-diagnosticresult) below.

***

## v6.0.0 — LogicVerifier conforms to `DiagnosticResult`

**Released: August 2, 2026** · [GitHub PR #262](https://github.com/QWED-AI/qwed-verification/pull/262) · part of the [v6.0.0 release](https://github.com/QWED-AI/qwed-verification/releases/tag/v6.0.0)

> `LogicVerifier` is the second engine — after `FactVerifier` — to conform to the unified 3-layer [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) contract introduced in v5.2.0. All nine public methods now return a `DiagnosticResult` with `.status`, `.developer_fields`, `.is_verified`, `.agent_message`, and `.proof_ref`. The legacy `LogicResult` dataclass has been removed. Two fail-closed input-strictness fixes ship with it.

> [!WARNING]
>
> **Breaking change for direct `LogicVerifier` callers.** The `LogicResult` dataclass is gone. Code that reads `result.status == "SAT"`, `result.model`, `result.error`, `result.proof_summary`, or `result.explanation` must migrate to `result.is_verified` and `result.developer_fields`. The high-level SDK client (`client.verify_logic()`) and the `/verify/logic` API endpoint response shape are unchanged.

### What changed

* **All nine methods return `DiagnosticResult`.** `verify_logic`, `verify_with_quantifiers`, `verify_bitvector`, `verify_array`, `prove_theorem`, `check_implication`, `check_equivalence`, `verify_optimization`, and `check_vacuity` all now return a `DiagnosticResult` with the three diagnostic layers populated.
* **Per-method SAT/UNSAT disambiguation.** Z3's `sat`/`unsat` outcome is mapped to a `DiagnosticStatus` per method (for example, `verify_logic` `sat` → `VERIFIED`, but `prove_theorem` `sat` → `BLOCKED` with a counterexample; `prove_theorem` `unsat` → `VERIFIED` because the theorem was proved by contradiction). The raw solver verdict is preserved on `developer_fields["deterministic_verdict"]`.
* **`symbol_table` on every `VERIFIED` result.** `developer_fields["symbol_table"]` is a sorted list of `{"name": ..., "type": ...}` entries for every declared variable, so audit logs capture exactly what was proven.
* **`proof_ref` from the Z3 assertion stack.** Every `VERIFIED` result carries a deterministic `sha256:...` `proof_ref` computed from the solver's assertions — the [authority bit](https://docs.qwedai.tech/advanced/diagnostics.md#layer-3-—-proof-diagnostics) that downstream gates use for admissibility.
* **Empty `variables` dict now fails closed.** The `_infer_variables` type-guessing heuristic has been removed. Calls with an empty `variables` dict return `BLOCKED` with `constraint_id = "logic_verifier.explicit_declarations_required"`. Declare every variable explicitly.
* **Malformed `BitVec[N]` declarations now fail closed.** Type strings such as `"BitVec"`, `"BitVec[]"`, or `"BitVec[abc]"` no longer silently default to 32 bits. They return `BLOCKED` with `constraint_id = "dsl_compiler.type_validation"`.
* **`agent_message` is sanitized.** No raw Z3 output leaks into the agent-facing layer. Structured diagnostic details live under `developer_fields`.

### Before and after

**Reading a satisfiability result**

```python theme={null}
# Before — LogicResult with a solver-level status string
result = verifier.verify_logic({"x": "Int"}, ["x > 0"])
if result.status == "SAT":
    solution = result.model

# After — DiagnosticResult
result = verifier.verify_logic({"x": "Int"}, ["x > 0"])
if result.is_verified:
    solution = result.developer_fields["model"]
    verdict  = result.developer_fields["deterministic_verdict"]  # "SAT"
```

**Proving a theorem**

```python theme={null}
# Before — SAT meant "theorem is valid"
result = verifier.prove_theorem(variables, premises, conclusion)
if result.status == "SAT":
    ...  # theorem valid
elif result.status == "UNSAT":
    counterexample = result.model

# After — VERIFIED means "theorem proved", BLOCKED means "counterexample found"
result = verifier.prove_theorem(variables, premises, conclusion)
if result.is_verified:
    ...  # theorem proved by contradiction
elif result.status.value == "BLOCKED":
    counterexample = result.developer_fields["model"]
```

**Empty `variables` dict**

```python theme={null}
# Before — types were guessed from constraint syntax
verifier.verify_logic({}, ["x > 5", "P and Q"])
# Inferred: x -> Int, P/Q -> Bool

# After — BLOCKED
result = verifier.verify_logic({}, ["x > 5", "P and Q"])
result.developer_fields["constraint_id"]
# "logic_verifier.explicit_declarations_required"
```

### What this means for you

Callers that already treat non-`VERIFIED` results as unverified continue to work — they just need to migrate the field names (`result.is_verified` in place of `result.status == "SAT"`, `result.developer_fields["model"]` in place of `result.model`). Callers that relied on implicit variable inference or on `"BitVec"` defaulting to 32-bit must now declare every variable explicitly. See [`LogicVerifier` returns `DiagnosticResult`](https://docs.qwedai.tech/engines/logic#logicverifier-returns-diagnosticresult-v6-0-0) for the full status matrix, `constraint_id` list, and migration snippets, and the [Verification Diagnostics guide](https://docs.qwedai.tech/advanced/diagnostics.md) for the 3-layer model.

***

## QWED Control Plane â€” mandatory attestation admission on `/verify/math`, translated-expression attestation scope

***

**Released: July 31, 2026** Â· [GitHub PR #278](https://github.com/QWED-AI/qwed-verification/pull/278) Â· [GitHub PR #285](https://github.com/QWED-AI/qwed-verification/pull/285)

> The control plane now treats attestation as an admission gate on the math verification path â€” the final response status is set by the enforcement step after signing, not by the raw verifier verdict. In the same release, the attestation `qwed.query_hash` now binds to the translated deterministic expression, not to the user's natural-language query.

### What changed

* `enforce_trust_decision(..., require_attestation=True)` is now the single source of truth for the math response `status` and `trust_boundary.overall_status`. Both fields are set from the enforced decision after attestation is issued, never from the raw verifier verdict.
* A `VERIFIED` math result is only surfaced once `create_verification_attestation()` returns `ISSUED`. Any attestation signing failure downgrades the response to `BLOCKED` â€” the error code is echoed under `trust_boundary.attestation_error` and no token is returned.
* `trust_boundary.attestation_policy` is now always `"mandatory"`. The previous advisory mode has been removed.
* The attestation `qwed.query_hash` binds to the **translated expression** the engine actually evaluated, not to the natural-language query. The natural-language query is still returned in `response.user_query` for display, and `trust_boundary.verification_scope = "translated_expression_only"` continues to disclose the narrowed scope.

### What this means for you

* Downstream consumers verifying a QWED attestation must hash the translated expression when re-checking `qwed.query_hash`. Hashing `user_query` will not match â€” that is intentional, because QWED does not attest to the LLM translation step.
* If your integration branched on `trust_boundary.overall_status`, it now reflects the post-attestation decision. Signing outages surface as `BLOCKED` with `attestation_error`, not as a raw `VERIFIED` verdict.
* See [Trust boundary](https://docs.qwedai.tech/engines/math.md#trust-boundary) and [`POST /verify/natural_language`](https://docs.qwedai.tech/api/endpoints#post-%2Fverify%2Fnatural_language) for the full response contract, and [Cryptographic attestations](https://docs.qwedai.tech/advanced/attestations.md) for the `AttestationResult` fail-closed contract.

## v5.3.0 — SymbolicVerifier: `DiagnosticResult` reference implementation

**Released: July 25, 2026** · [GitHub Release](https://github.com/QWED-AI/qwed-verification/releases/tag/v5.3.0) · Minor · [PR #244](https://github.com/QWED-AI/qwed-verification/pull/244)

> QWED-Verification v5.3.0 makes `SymbolicVerifier` the **first fully `DiagnosticResult`-conformant engine**. The unified 3-layer diagnostic model introduced in [v5.2.0](https://docs.qwedai.tech/changelog.md#v5-2-0-—-structured-verification-diagnostics) is no longer aspirational — the Code engine is the reference implementation the remaining engines will migrate to.

> [!WARNING]
>
> **Breaking change for the Code engine.** All six `SymbolicVerifier` public methods now return a [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) instead of a dict. Callers that read `result["status"]`, `result.verified`, `result["issues"]`, `result["complexity"]`, `result["loop_depth"]`, or `result["recursive"]` must migrate. See the [Code engine migration guide](https://docs.qwedai.tech/engines/code.md#migrating-from-the-legacy-dict-api) for the field-by-field mapping.

### What changed

* **Six methods migrated to `DiagnosticResult`** — `verify_code`, `verify_function_contract`, `verify_safety_properties`, `verify_bounded`, `analyze_complexity`, and `get_verification_budget` now return the unified [3-layer diagnostic](https://docs.qwedai.tech/advanced/diagnostics.md) type. Every result carries `status` (`DiagnosticStatus`), `agent_message` (Layer 1), `developer_fields` (Layer 2), and `proof_ref` (Layer 3, always `None` for this engine).
* **`verification_mode` on every result** — `developer_fields["verification_mode"]` is `"symbolic"` for standard runs and `"bounded_symbolic"` for `verify_bounded()`, so callers can distinguish the two verification modes without inspecting the call site.
* **`VERIFIED` is intentionally never emitted** — CrossHair's search is timeout-bounded, not a completeness proof, so a clean run maps to `UNVERIFIABLE` with `constraint_id = "symbolic_verifier.no_counterexample_found"`. The `DiagnosticResult` contract structurally requires a `proof_ref` for `VERIFIED`, and this engine has no proof artifact to bind. Downstream policy gates must reject symbolic-engine output for control flow.
* **`verify_code()` no longer accepts `check_assertions`** — the parameter was never wired up. The current signature is `verify_code(self, code: str) -> DiagnosticResult`.
* **Advisory checks throughout** — `verify_safety_properties`, `analyze_complexity`, and `get_verification_budget` attach structured `AdvisoryCheck` entries to `developer_fields["advisory_checks"]`. Advisory checks never influence the verdict.

### Before and after

**Before**

```python theme={null}
result = verifier.verify_code(code)

if result["status"] == "verified":
    admit(payload)
elif result["status"] == "counterexamples_found":
    for issue in result["issues"]:
        log(issue["description"])
```

**After**

```python theme={null}
from qwed_new.core.diagnostics import DiagnosticStatus

result = verifier.verify_code(code)

# This engine never emits VERIFIED — treat every result as unverified for
# control flow, and use developer_fields for the specific reason.
reject(payload, reason=result.agent_message)

if result.status is DiagnosticStatus.UNVERIFIABLE:
    if result.developer_fields["constraint_id"] == "symbolic_verifier.counterexample_found":
        for issue in result.developer_fields["issues"]:
            log(issue["description"])
```

### Field cheat sheet

| Legacy field | Replacement |
| - | - |
| `result.verified` / `result["verified"]` | `result.is_verified` (always `False` for this engine) |
| `result["status"]` (string) | `result.status` (`DiagnosticStatus` enum) and `developer_fields["constraint_id"]` |
| `result["message"]` | `result.agent_message` |
| `result["issues"]` | `result.developer_fields["issues"]` |
| `result["complexity"]` / `"loop_depth"` / `"recursive"` | `developer_fields["complexity_score"]`, `"max_loop_depth"`, `"total_recursive_functions"` |
| `result["is_safe"]` | `result.developer_fields["is_safe"]` |
| `result["bounds_applied"]` | `result.developer_fields["bounds_applied"]` |
| n/a | `result.developer_fields["verification_mode"]` — new (`"symbolic"` or `"bounded_symbolic"`) |
| n/a | `result.proof_ref` — always `None` for this engine |

### Version propagation

| Artifact | Previous | This release |
| - | - | - |
| `qwed` (PyPI) | 5.2.0 | 5.3.0 |
| `qwed_sdk` (Python) | 5.2.0 | 5.3.0 |
| `@qwed-ai/sdk` (npm) | 5.2.0 | 5.3.0 |
| `qwed` (Rust crate) | 5.2.0 | 5.3.0 |
| API version marker | 5.2.0 | 5.3.0 |
| K8s deployment image | 5.2.0 | 5.3.0 |

### Included PRs

* [#212](https://github.com/QWED-AI/qwed-verification/pull/212) — feat: migrate SymbolicVerifier to DiagnosticResult (Phase 1)
* [#220](https://github.com/QWED-AI/qwed-verification/pull/220) — fix: remove unused `check_assertions` parameter from `verify_code`
* [#239](https://github.com/QWED-AI/qwed-verification/pull/239) — feat: add `verification_mode` to SymbolicVerifier DiagnosticResults
* [#240](https://github.com/QWED-AI/qwed-verification/pull/240) — feat: migrate `verify_bounded` to return `DiagnosticResult`
* [#241](https://github.com/QWED-AI/qwed-verification/pull/241) — feat: migrate `get_verification_budget` to return `DiagnosticResult`
* [#242](https://github.com/QWED-AI/qwed-verification/pull/242) — feat: migrate `analyze_complexity` to return `DiagnosticResult`
* [#243](https://github.com/QWED-AI/qwed-verification/pull/243) — feat: migrate `verify_safety_properties` to return `DiagnosticResult`
* [#244](https://github.com/QWED-AI/qwed-verification/pull/244) — release: v5.3.0 — SymbolicVerifier: `DiagnosticResult` reference implementation

### What this means for you

If your integration reads any dict-shape field from a `SymbolicVerifier` result, update it before upgrading — the return type has changed and the old keys are gone. The [Code engine reference](https://docs.qwedai.tech/engines/code.md) documents the new contract, and [Symbolic execution limits](https://docs.qwedai.tech/advanced/symbolic-limits.md) has been rewritten around `DiagnosticResult`. Callers that gated control flow on `is_verified` for symbolic-engine output must move to `result.proof_ref` (the authority bit) instead — this engine intentionally never sets it, so symbolic results must not admit downstream execution.

***

## QWED-A2A — `UNVERIFIABLE` verdicts carry no JWT, and five README behaviors realigned with the code

**Released: July 27, 2026** · [GitHub PR #40](https://github.com/QWED-AI/qwed-a2a/pull/40) · [GitHub PR #57](https://github.com/QWED-AI/qwed-a2a/pull/57)

> The interceptor no longer produces a signed `FORWARDED` verdict for empty or malformed `FINANCIAL_TRANSACTION` and `LOGIC_ASSERTION` payloads, and no longer silently forwards `GENERAL` / `DATA_QUERY` messages. Both cases now return `verdict.status = "unverifiable"` with `attestation_jwt = None` — signing a token for content that was never verified would be a false cryptographic claim. The A2A docs are updated to match the shipping behavior on five points that previously overstated what the interceptor does.

### What changed

* **Empty/malformed finance and logic payloads → `UNVERIFIABLE`, no JWT.** A `FINANCIAL_TRANSACTION` missing `data`, `line_items`, or `claimed_total`, or with non-numeric amounts, now returns `status=unverifiable` from `finance_guard`. A `LOGIC_ASSERTION` with a missing, non-list, or empty `assertions` array, or malformed entries, returns `status=unverifiable` from `logic_guard`. Earlier releases collapsed these cases to `verified=True` + a signed `FORWARDED` verdict.
* **`GENERAL` and `DATA_QUERY` are no longer silently forwarded.** The passthrough branch now returns an `unverifiable` verdict with `engine_used="passthrough"`, `attestation_jwt=None`, and reason `"No verification engine available for this payload type"`. Callers must decide their own downstream policy for unverified traffic.
* **No `Bypass` verification stage.** The pipeline has four stages, not five. `config.trusted_agents` is pre-added to the `TrustBoundary` allowlist at interceptor construction time — trusted agents still flow through engine routing and receive a normal verdict. The [Verification interceptor](https://docs.qwedai.tech/a2a/interceptor.md#verification-pipeline) and [Architecture](https://docs.qwedai.tech/a2a/architecture.md) pages have been corrected.
* **`CodeGuard` is AST-first, regex-second.** Code payloads are first parsed into an AST and inspected for direct dangerous constructs (`eval(`, `exec(`, `subprocess.run(`, `import subprocess`, etc.). Only if the AST layer is clean does a regex heuristic scan run to catch obfuscation patterns (`getattr(__builtins__, ...)`, base64-encoded exec, dynamic `__import__`). The verdict is `BLOCKED` if either layer triggers, or `HEURISTIC_PASS` if both are clean — not a proof of safety.
* **JWT attestation expiry is 300 seconds (5 minutes), not 24 hours.** The default `validity_seconds` on `A2ACryptoService` is `300` — one A2A hop lifetime. The [Crypto attestations](https://docs.qwedai.tech/a2a/crypto-attestations.md) page and its signing example were updated.

### New verdict-status contract

| Status | Meaning | JWT attestation |
| - | - | - |
| `forwarded` | Engine verified the payload. | Signed |
| `blocked` | Engine detected a violation. | Signed |
| `heuristic_pass` | Code guard found no known dangerous constructs. Not a proof of safety. | Signed |
| `unverifiable` | No engine could evaluate the payload (`GENERAL`/`DATA_QUERY`, empty or malformed finance/logic payload). | **None** |

> [!WARNING]
>
> **Behavior change.** Downstream code that assumed every `VerificationVerdict` carries an `attestation_jwt` will see `None` on `UNVERIFIABLE` verdicts and must handle it. Callers that treated a signed `FORWARDED` verdict as proof that finance/logic content had been checked should also branch on `verdict.status` — an empty payload no longer produces a signed `FORWARDED`, and a `GENERAL`/`DATA_QUERY` message no longer produces one at all. Note that `VerdictStatus.ERROR` exists in the public enum but is not emitted by `intercept()` — internal exceptions surface as `BLOCKED` (or `FORWARDED` when `block_on_error=False`), never as an `error` verdict.

### What this means for you

If you integrate QWED-A2A: audit any caller that reads `attestation_jwt` or trusts `status == "forwarded"` without also checking `engine_used`. Legitimate agent traffic on unsupported payload types is not blocked — it is marked `unverifiable` so your policy layer can decide whether to route it. See the updated [Verification interceptor](https://docs.qwedai.tech/a2a/interceptor.md) and [Quick start](https://docs.qwedai.tech/a2a/quickstart.md) pages for the full verdict contract and example payload shapes.

***

## QWED verification — signature-first attestation validation, uniform rejection error, and TOCTOU-safe trust-boundary snapshot

**Released: August 1, 2026** · [PR #287](https://github.com/qwed-ai/qwed-verification/pull/287) · [PR #290](https://github.com/qwed-ai/qwed-verification/pull/290)

> Two follow-ups harden the attestation trust boundary introduced last week. `verify_attestation()` now verifies the JWT signature before applying any issuer authorization or revocation checks, and every rejection path returns the same generic `"Invalid token"` error so callers cannot enumerate trusted issuers, probe token expiry, or confirm revocation state through response text. `enforce_trust_decision()` now returns a fully detached `DiagnosticResult` snapshot, closing a TOCTOU window where a concurrent caller could mutate `developer_fields` between the validation read and the admission decision.

### What changed

* **Signature-first verification order.** `verify_attestation()` cryptographically verifies the JWT (signature + expiration + required claims) before it applies the trusted-issuer authorization check or the revocation check. An unknown-issuer token that is not correctly signed is rejected on signature grounds and the trust-list check never runs.
* **Uniform `"Invalid token"` rejection.** Every failure mode — expired, revoked, untrusted issuer, unsupported external issuer, malformed, oversized, malformed base64 — returns the same `(is_valid=False, claims=None, error="Invalid token")` triple. Detailed reasons are still recorded in the server-side `attestation.rejected` audit log; only the caller-facing error is generic. This closes the trusted-issuer enumeration side channel.
* **Detached-result snapshot in `enforce_trust_decision()`.** Before validation, `developer_fields` is recursively rebuilt into an isolated snapshot that admits only immutable scalars, `AdvisoryCheck`, and JSON-safe containers. The value you validate is the value you return, so a concurrent mutation of the caller's `DiagnosticResult` can neither skew the decision nor leak into the returned result.
* **Fail-closed snapshot failure.** When `developer_fields` contains an unsupported value type (custom class instance, generator, open file handle) or an object that resists copying, `enforce_trust_decision()` returns a `BLOCKED` result with `constraint_id="trust_gate.diagnostic_snapshot_failed"`. Only the exception type is logged — never the exception message, which could embed caller data.
* **Runtime dependency.** `cryptography` is now a runtime dependency (previously an optional extra), so ES256 signature verification runs by default.

### Before and after

**Rejection error — before**

```python theme={null}
# Distinct messages leaked expiration, revocation, and trusted-issuer identity.
is_valid, claims, error = client.verify_attestation(bad_jwt)
# error = "Untrusted issuer: did:qwed:node:staging"
# error = "Attestation has expired"
# error = "Attestation has been revoked"
# error = "External issuer key resolution not implemented"
```

**Rejection error — after**

```python theme={null}
# Every rejection path returns the same string.
is_valid, claims, error = client.verify_attestation(bad_jwt)
# is_valid = False, claims = None, error = "Invalid token"
```

**Trust-boundary snapshot — before**

```python theme={null}
result = engine.verify(query)  # DiagnosticResult(developer_fields={"score": 0.9})
decision = enforce_trust_decision(result, attestation_token=token, ...)

# Concurrent code holding `result` could mutate developer_fields AFTER
# validation but BEFORE the caller admitted the decision — the returned
# reference aliased the input.
result.developer_fields["score"] = -1.0
assert decision.developer_fields["score"] == -1.0  # TOCTOU: mutation leaked in
```

**Trust-boundary snapshot — after**

```python theme={null}
result = engine.verify(query)
decision = enforce_trust_decision(result, attestation_token=token, ...)

# `decision.developer_fields` is a detached snapshot. Mutations to the
# original never reach the enforced result.
result.developer_fields["score"] = -1.0
assert decision.developer_fields["score"] == 0.9  # unchanged
```

### What this means for you

Callers of `verify_attestation()` must not branch on the `error` string any more — treat any `is_valid=False` result as a hard block and rely on the server-side audit log for the actual reason. Callers of `enforce_trust_decision()` should keep `developer_fields` values to JSON-safe types (strings, numbers, booleans, `None`, lists, dicts) plus `AdvisoryCheck`; anything else will be rejected by the snapshotter and blocked at the trust boundary with `trust_gate.diagnostic_snapshot_failed`. See [Uniform rejection error](https://docs.qwedai.tech/advanced/attestations.md#uniform-rejection-error) and [Detached result snapshot](https://docs.qwedai.tech/advanced/attestations.md#detached-result-snapshot) for the full behavior.

***

## QWED verification — mandatory proof artifact for VERIFIED attestations and a single trust-boundary entry point

**Released: July 28, 2026** · [PR #248](https://github.com/qwed-ai/qwed-verification/pull/248)

> Two related enforcement-boundary changes ship together. Issuance side, the crypto layer now refuses to sign a `VERIFIED` verdict that has no proof artifact. Consumption side, a new `enforce_trust_decision()` function is the single trust-boundary entry point that release gates route through — it verifies the attestation JWT, binds its claims to the result (status, query hash, proof hash), and fails closed on any mismatch.

### What changed

* **`create_verification_attestation()` blocks VERIFIED without proof.** Calling with `verified=True` (or `status="VERIFIED"`) and an empty or missing `proof_data` now returns `AttestationResult(status=BLOCKED, token=None, error_code="VERIFIED_WITHOUT_PROOF", error=...)`. The crypto layer will not sign a `VERIFIED` result that has no evidence to hash into the token's `proof_hash` claim. `UNVERIFIABLE` and `BLOCKED` verdicts are unaffected.
* **New `enforce_trust_decision()` trust-boundary gate.** Exported from `qwed_new.core`, this is the single consumption-side entry point every release gate must route through. It takes the engine's `DiagnosticResult` plus the attestation token, verifies the JWT (signature, expiry, trusted issuer), and blocks the decision when the token's `qwed.result.status`, `qwed.query_hash`, or `qwed.proof_hash` claims do not match the result. `UNVERIFIABLE`/`BLOCKED` results pass through unchanged.
* **`require_attestation` policy toggle.** `enforce_trust_decision(result, require_attestation=True, ...)` (default) is the mandatory policy — `VERIFIED` without a valid token is downgraded to `BLOCKED`. `require_attestation=False` is the advisory policy for staged rollouts: a missing token still passes as `VERIFIED`, but a present-and-invalid token still blocks. Both modes emit structured `trust_gate.blocked` audit logs on rejection.
* **Control plane integration.** The math control plane now routes every verification through `enforce_trust_decision()` and records the outcome and policy on the trust boundary (`trust_enforced`, `attestation_policy`) so operators can watch the mandatory rollout from telemetry before flipping the switch.

### Before and after

**Issuance — `VERIFIED` without a proof artifact**

```python theme={null}
# Before — the crypto layer signed a VERIFIED verdict even with no proof_data,
# emitting an attestation whose proof_hash pointed at an empty artifact.
create_verification_attestation(
    status="VERIFIED",
    verified=True,
    engine="math",
    query="2+2=4",
)
# AttestationResult(status=ISSUED, token="eyJ...", error_code=None)

# After — issuance is refused; there is no way to obtain a signed
# attestation for a VERIFIED claim without evidence.
create_verification_attestation(
    status="VERIFIED",
    verified=True,
    engine="math",
    query="2+2=4",
)
# AttestationResult(
#     status=BLOCKED,
#     token=None,
#     error_code="VERIFIED_WITHOUT_PROOF",
#     error="VERIFIED status requires proof_data — cannot sign attestation without proof artifact",
# )
```

**Consumption — release gate on a VERIFIED result**

```python theme={null}
# Before — release gates inspected result.status directly and could admit
# a VERIFIED result whose attestation was missing, invalid, or bound to a
# different query. There was no single enforcement point.
if result.status == "VERIFIED":
    proceed()

# After — one call gates the boundary. Missing/invalid tokens or
# mismatched claims downgrade the decision to BLOCKED with a
# structured audit event.
from qwed_new.core import enforce_trust_decision

decision = enforce_trust_decision(
    result,
    attestation_token=token,
    require_attestation=True,
    trusted_issuers=["did:qwed:node:production"],
    query="2+2=4",
)

if decision.status.name == "VERIFIED":
    proceed()
else:
    reject(decision)
```

### What this means for you

If you call `create_verification_attestation()` directly, always pass `proof_data` (typically `json.dumps(evidence, sort_keys=True)`) when signing a `VERIFIED` verdict, and check `result.is_issued` before treating the token as valid. If you consume verification results in a release gate, route them through `enforce_trust_decision()` — start in advisory mode (`require_attestation=False`) while your engines are being migrated to emit proof artifacts, watch the `trust_boundary.trust_enforced` telemetry, then flip to mandatory once every `VERIFIED` path is signing with proof. See [Fail-closed contract](https://docs.qwedai.tech/advanced/attestations.md#fail-closed-contract) and [Enforce the trust boundary](https://docs.qwedai.tech/advanced/attestations.md#enforce-the-trust-boundary) for the full parameter reference and fail-closed matrix.

***

## QWED-A2A — `verify_attestation()` now requires an `AttestationContext` (breaking)

**Released: July 26, 2026** · [GitHub PR #41](https://github.com/qwed-ai/qwed-a2a/pull/41)

> `A2ACryptoService.verify_attestation()` used to accept a token on its own — a cryptographically valid signature was treated as sufficient proof. A detached attestation could therefore be lifted from one exchange and replayed against a different sender, receiver, or payload without detection. The method now requires a second argument, an `AttestationContext` describing the current request, and rejects the token if the sender, receiver, payload hash, or session claim does not match.

> [!WARNING]
>
> **Breaking change.** The single-argument form `verify_attestation(token)` has been removed — there is no context-free overload. Every call site must construct an `AttestationContext` and pass it as the second positional argument, or the call raises `TypeError`. See the migration example under "What this means for you" below for the exact before/after.

### What changed

* **New `AttestationContext` dataclass** in `qwed_a2a.security.crypto` with fields `sender_agent_id: str`, `receiver_agent_id: str`, `payload: Any`, and optional `session_id: str | None`. The payload is hashed internally with the same deterministic method as `sign_verdict()`, so callers never handle raw hashes.
* **New context-binding verification step** — sender, receiver, payload hash, and (when supplied) `session_id` are compared against the token's `qwed_a2a` claims and `sub` claim. Any mismatch returns a structured rejection: `"Attestation sender mismatch: expected=..., got=..."`, `"Attestation receiver mismatch: ..."`, `"Attestation payload hash mismatch — detached attestation rejected"`, or `"Attestation session mismatch: ..."`.
* **Verification order updated.** Context binding now runs **after** the deployment-context check but **before** the `jti` replay check. This is deliberate: an out-of-context token no longer pollutes the `jti` registry, so a legitimate future presentation of the same token in its correct context is not locked out.
* **Signing is unchanged.** `sign_verdict()` still takes the same arguments — only the verify side gained a parameter.

### What this means for you

Every caller of `verify_attestation()` must be updated. Build an `AttestationContext` from the same identifiers and payload the receiving side is about to act on, then pass it as the second argument:

```python theme={null}
from qwed_a2a.security.crypto import AttestationContext

context = AttestationContext(
    sender_agent_id="procurement-agent",
    receiver_agent_id="treasury-agent",
    payload=request_payload,
    session_id=request_session_id,  # optional
)
is_valid, claims, error = crypto.verify_attestation(token, context)
```

See [Verifying an attestation](https://docs.qwedai.tech/a2a/crypto-attestations.md#verifying-an-attestation) for the full field-by-field reference, the updated ordered verification steps, and the complete list of rejection messages.

***

## QWED Math Engine — fail-closed on mode ambiguity, eigenvalue cardinality, and IRR convergence

**Released: July 24, 2026** · [Jump to details](#qwed-math-engine-—-fail-closed-on-mode-ambiguity-eigenvalue-cardinality-and-irr-convergence) · [PR #217](https://github.com/qwed-ai/qwed-verification/pull/217) · [PR #218](https://github.com/qwed-ai/qwed-verification/pull/218) · [PR #219](https://github.com/qwed-ai/qwed-verification/pull/219)

> Three core-verifier fixes tighten the math engine's fail-closed contract. `verify_statistics(statistic="mode")`, `verify_matrix_operation(operation="eigenvalues")`, and `verify_irr()` no longer produce `VERIFIED` when the underlying claim is ambiguous, under-specified, or numerically unproven. Callers see `BLOCKED` or `CORRECTION_NEEDED` with structured diagnostics instead of a best-effort answer.

> [!WARNING]
>
> **Behavior change.** Inputs that previously received `VERIFIED` may now receive `BLOCKED` or `CORRECTION_NEEDED` — treat both as unverified. If your code branched on `result.verified` or `result.status == "VERIFIED"` alone, it will continue to work; if you consumed `calculated`/`calculated_irr`/`calculated_eigenvalues` from a `BLOCKED` result as a fallback, those fields are not present on `BLOCKED` responses. They remain available on `CORRECTION_NEEDED` responses for diagnostic use. Consume the new structured fields (`ambiguous_modes`, `calculated_count`/`claimed_count`, `converged`, `iterations_used`) for richer diagnostics.

### What changed

* **`verify_statistics(statistic="mode")` requires a unique mode.** When two or more values tie for the maximum frequency, the engine returns `BLOCKED` with an `ambiguous_modes` list instead of heuristically picking one. Only a unique mode can produce `VERIFIED`.
* **`verify_matrix_operation(operation="eigenvalues")` requires cardinality match.** The claimed eigenvalue list length must equal the calculated count (counting algebraic multiplicity). Mismatched lengths return `CORRECTION_NEEDED` with `calculated_count`/`claimed_count`. Previously the value comparison used `zip`, which silently truncated to the shorter list.
* **`verify_irr()` requires proof of Newton-Raphson convergence.** `BLOCKED` is now returned when cash flows have more than one sign change (multi-root ambiguity per Descartes' rule), zero sign changes (no real IRR), all-zero cash flows (IRR undefined), the Newton derivative stalls at zero, or the method fails to converge within 100 iterations. Successful results include `converged: true` and `iterations_used`.

### Before and after

**Ambiguous mode**

```python theme={null}
# Before — heuristically picked one of the tied values and could return VERIFIED
client.verify_statistics(statistic="mode", data=[1, 1, 2, 2, 3], expected=1)
# status: "VERIFIED"

# After — BLOCKED with the full list of tied values
client.verify_statistics(statistic="mode", data=[1, 1, 2, 2, 3], expected=1)
# status: "BLOCKED"
# ambiguous_modes: [1, 2]
```

**Incomplete eigenvalue claim**

```python theme={null}
# Before — zip truncated the comparison to length 1 and returned VERIFIED
client.verify_matrix_operation(operation="eigenvalues", matrix=[[2, 0], [0, 3]], expected=[2])
# status: "VERIFIED"

# After — CORRECTION_NEEDED with explicit cardinality diagnostics
client.verify_matrix_operation(operation="eigenvalues", matrix=[[2, 0], [0, 3]], expected=[2])
# status: "CORRECTION_NEEDED"
# calculated_count: 2, claimed_count: 1
# calculated_eigenvalues: [2.0, 3.0]
```

**IRR with multiple sign changes**

```python theme={null}
# Before — Newton-Raphson returned a best-effort iterate that could be VERIFIED
client.verify_irr(cash_flows=[-100, 230, -132], expected=0.10)
# status: "VERIFIED"

# After — BLOCKED because two sign changes admit multiple real IRRs
client.verify_irr(cash_flows=[-100, 230, -132], expected=0.10)
# status: "BLOCKED"
# sign_changes: 2
```

### What this means for you

Existing code that already treated non-`VERIFIED` statuses as unverified continues to work. New policy code should read the structured diagnostic fields on `BLOCKED`/`CORRECTION_NEEDED` results — `ambiguous_modes`, `calculated_count`/`claimed_count`, and `converged`/`iterations_used` — to explain why a claim was rejected and to satisfy audit requirements. See [Math engine — Fail-closed semantics](https://docs.qwedai.tech/engines/math.md#fail-closed-semantics) for the full state tables and response schemas.

***

## QWED-Finance — v2.1.0 released

**Released: July 22, 2026** · [GitHub PR #40](https://github.com/QWED-AI/qwed-finance/pull/40)

> `qwed-finance` v2.1.0 is now the current release. This is a version-sync release: the Python package, npm wrapper, GitHub Action, and quickstart workflow reference are all realigned so `QWED Finance Guard` reports a single consistent version across PyPI, npm, and SARIF output in GitHub Advanced Security.

### What changed

* **`qwed-finance` Python package is now v2.1.0** on PyPI (`qwed_finance.__version__ == "2.1.0"`).
* **`@qwed-ai/finance` npm package** version bumped to 2.1.0 to match.
* **GitHub Action** auto-syncs its reported version from the installed package — the `QWED Finance Guard` name in workflow logs and the `version` field on SARIF uploads to the GitHub Security tab now both read `2.1.0` instead of a hardcoded `v2.0`.
* **Quickstart `qwed-verify.yml` workflow** is re-pinned from the stale v1.1.4 SHA to the v2.1.0 SHA (`QWED-AI/qwed-finance@19ce969f21d1fc2019da4d89fff23bc108e15a98 # v2.1.0`).

### What this means for you

Upgrade your dependency to pick up the current release:

```bash theme={null}
pip install --upgrade qwed-finance
```

If you run QWED Finance in CI, update the action reference so SARIF findings and workflow-run names line up with the current package:

```yaml theme={null}
- name: Verify banking calculations
  uses: QWED-AI/qwed-finance@v2.1.0
```

This is a version-sync patch for the existing v2.1.0 release (May 2026). It contains no new API or guard behavior changes beyond those already shipped in v2.1.0 (Decimal migration, fail-closed enforcement, rate parsing fix). For the original breaking changes, see the [v2.1.0 release notes](https://docs.qwedai.tech/changelog-archive#qwed-finance-v2-1-0-—-security-audit-hardening). See [GitHub Action (CI/CD)](https://finance.qwedai.tech/finance/action) for the updated workflow example.

***

## QWED-UCP — v0.3.0 released

**Released: July 20, 2026** · [GitHub PR #38](https://github.com/qwed-ai/qwed-ucp/pull/38)

> `qwed-ucp` v0.3.0 is now the current release. This version ships the typed `TrustStatus` enum on every verification result, alongside the fail-closed middleware and internal-error handling delivered over the v0.2.x series.

### What changed

* **`qwed-ucp` Python package is now v0.3.0** on PyPI.
* **Express middleware `qwed-ucp-middleware`** package version bumped to match.
* **`TrustStatus` enum** is confirmed as available from v0.3.0 onward — see [Trust status](https://docs.qwedai.tech/ucp/guards.md#trust-status) for the full state table and usage.
* **GitHub Action** should now be pinned to `QWED-AI/qwed-ucp@v0.3.0`.

### What this means for you

Upgrade your dependency to pick up the current release:

```bash theme={null}
pip install --upgrade qwed-ucp
```

If you audit checkouts in CI, update the action reference:

```yaml theme={null}
- name: Audit Commerce Transactions
  uses: QWED-AI/qwed-ucp@v0.3.0
```

Existing code that branches on `result.verified` continues to work unchanged. New code should branch on `result.status` (a `TrustStatus`) to distinguish `FAILED` from `ENGINE_ERROR`, `UNVERIFIABLE`, and other non-`VERIFIED` verdicts.

***

## QWED-UCP — Express middleware fails closed on internal verification errors

**Released: July 18, 2026** · [GitHub PR #36](https://github.com/qwed-ai/qwed-ucp/pull/36)

> When a guard raised an unexpected exception, the Express middleware previously logged the error and called `next()` — letting an unverified checkout through to the downstream handler. That defeated the trust boundary. The `catch` block now short-circuits with `HTTP 500`, `X-QWED-Verified: false`, and `code: "INTERNAL_VERIFICATION_ERROR"` so an internal crash can no longer be mistaken for a passing verification.

### What changed

* **Express middleware `catch` block is now fail-closed.** On any exception raised during `verifyCheckoutLocally()`, the middleware returns:
  ```json theme={null}
  {
    "error": "QWED-UCP Verification Failed",
    "message": "Internal verification error: verification could not be completed",
    "code": "INTERNAL_VERIFICATION_ERROR"
  }
  ```
  The underlying exception is logged server-side via `console.error` but is **not** included in the response body — raw stack traces, file paths, and internal messages stay out of client-visible output.
* **`X-QWED-Verified: false`** is set on the 500 response so upstream policy layers can treat it the same as a 422 failure.
* **npm package fix.** `qwed-ucp-middleware.js` is now included in the published `files` array. Previous versions installed via npm were missing the entrypoint that `index.js` required at runtime.

> [!WARNING]
>
> **Behavior change.** Any client code that treated an Express middleware error as a soft pass (e.g. retrying without checking the status, or relying on `next()` being called) will now see a `500`. Treat `500 INTERNAL_VERIFICATION_ERROR` the same as `422 VERIFICATION_FAILED` — the request has **not** been verified and must not be settled.

### What this means for you

Existing integrations that already branched on `X-QWED-Verified` or on the response status keep working — they just start seeing a new `500` path that was previously invisible. New integrations should treat both `4xx` and `5xx` responses from the middleware as an unverified request. See [Express.js middleware — Fail-closed on internal verification errors](https://docs.qwedai.tech/ucp/middleware-express.md#fail-closed-on-internal-verification-errors) for the full response contract.

***

## QWED-UCP — typed `TrustStatus` enum on every verification result

**Released: July 15, 2026** · [GitHub PR #33](https://github.com/qwed-ai/qwed-ucp/pull/33)

> Verification results previously exposed a single `verified: bool` that collapsed "proof disproved," "proof could not be established," "input outside supported semantics," and "verifier engine crashed" into the same `False` bucket. Every result dataclass now also carries a typed `status: TrustStatus` field so downstream policy code can make trust-aware decisions without parsing error strings.

### What changed

* **New `TrustStatus` enum** exported from `qwed_ucp` with seven states: `VERIFIED`, `FAILED`, `UNVERIFIABLE`, `UNSUPPORTED`, `PARTIAL`, `ENGINE_ERROR`, and `QUARANTINED` (reserved).
* **`status` field on every result** — `GuardResult`, `UCPVerificationResult`, and each per-guard result type (`MoneyGuardResult`, `StateGuardResult`, `SchemaGuardResult`, `LineItemsGuardResult`, `DiscountGuardResult`, `CurrencyGuardResult`, `RefundGuardResult`, `TipGuardResult`, `FeeGuardResult`, `AttestationResult`).
* **`UCPVerifier.verify_checkout()` now surfaces `ENGINE_ERROR`** when any guard raises an exception, instead of silently collapsing to `verified=False`.
* **`verified: bool` is preserved** as a backward-compatible derived field: `result.verified` is `True` only when `result.status == TrustStatus.VERIFIED`. Existing constructors that pass `verified=True`/`verified=False` continue to produce the corresponding `VERIFIED`/`FAILED` status.

### What this means for you

Existing code that reads `result.verified` or constructs results with `verified=...` keeps working unchanged. New policy code should branch on `result.status` to distinguish a disproved proof from a crashed verifier — see [Trust status](https://docs.qwedai.tech/ucp/guards.md#trust-status) for the full state table and an example.

***

## QWED-UCP — middleware fails closed on empty and non-JSON request bodies

**Released: July 14, 2026** · [GitHub PR #32](https://github.com/QWED-AI/qwed-ucp/pull/32)

> The FastAPI and Express middleware previously forwarded requests with an empty body, malformed JSON, or a non-object JSON payload to the downstream handler without running any guards. On a `/checkout-sessions` route that defeated the trust boundary — an attacker could send `{}`'s worth of nothing and skip verification. Both middlewares now reject those requests with `HTTP 422`, `X-QWED-Verified: false`, and `code: "UNPARSEABLE_REQUEST"` before the handler runs.

### What changed

* **FastAPI:** returns a distinct message per case:
  * Empty body → `"Empty request body: cannot verify empty payload"`
  * Malformed or non-UTF-8 body → `"Malformed request body: expected JSON"`
  * Top-level non-object JSON → `"Invalid request body: expected JSON object"`
* **Express:** all three cases return `"Empty or non-JSON request body: cannot verify unparseable payload"`.
* **Non-protected methods and paths** (for example, `GET /health` or a `POST` to a route outside `verify_paths`) still pass through untouched.

> [!WARNING]
>
> This is a fail-closed behavior change. Any client that was previously reaching a `/checkout-sessions`, `/checkout`, `/cart`, or `/payment` handler with a missing, malformed, or non-object JSON body will now receive `422 UNPARSEABLE_REQUEST` instead. Send checkout payloads as `application/json` with a JSON object at the top level, or narrow `verify_paths` if a route should not be treated as a checkout endpoint.

### What this means for you

If you deploy QWED-UCP as middleware in front of a UCP merchant server, unparseable requests can no longer bypass the guards. See [FastAPI middleware](https://docs.qwedai.tech/ucp/middleware-fastapi.md#fail-closed-on-unparseable-bodies), [Express.js middleware](https://docs.qwedai.tech/ucp/middleware-express.md#fail-closed-on-unparseable-bodies), and the [troubleshooting entry](https://docs.qwedai.tech/ucp/troubleshooting.md#unparseable_request-422-from-middleware) for the exact response shape and how to configure protected paths.

***

## QWED-A2A — persistent signing key and JWKS discovery endpoint

**Released: July 10, 2026** · [GitHub PR #29](https://github.com/QWED-AI/qwed-a2a/pull/29)

> `A2ACryptoService` no longer generates an ephemeral ECDSA P-256 key per process. The signing key is now loaded from the `QWED_A2A_SIGNING_KEY_PEM` environment variable (or the `pem_key` constructor argument) so attestation JWTs issued before a restart remain verifiable afterwards. A new `/.well-known/jwks.json` endpoint publishes the current public key for external consumers.

### What changed

* **Persistent signing key** — `A2ACryptoService` reads an unencrypted PKCS#8 P-256 PEM from `QWED_A2A_SIGNING_KEY_PEM` on first use. The derived `key_id` is a SHA-256 fingerprint of the public key, so it stays stable as long as the PEM does.
* **Fail-closed on missing key** — `sign_verdict`, `verify_attestation`, `get_public_key_jwk`, and `A2AVerificationInterceptor.intercept()` all raise `RuntimeError` when the PEM is missing, malformed, or uses a curve other than `SECP256R1`. The FastAPI gateway surfaces this as `HTTP 503 Signing key unavailable`.
* **JWKS endpoint** — A new `wellknown_router` exposes `GET /.well-known/jwks.json` for downstream services and auditors to fetch the current public key without an out-of-band exchange.
* **`get_public_key_jwk()`** — Returns the current public key as a JWK (`kty`, `crv`, `x`, `y`, `kid`, `use`, `alg`).

### Breaking changes

> [!WARNING]
>
> `A2ACryptoService()` and `A2AVerificationInterceptor()` no longer produce a working signer without configuration. Every deployment must now set `QWED_A2A_SIGNING_KEY_PEM` (and continue to set `QWED_A2A_DEPLOYMENT_ID`) before the service starts. Generate a key with:
>
> ```bash theme={null}
> openssl ecparam -name prime256v1 -genkey -noout \
>   | openssl pkcs8 -topk8 -nocrypt \
>   > qwed_a2a_signing_key.pem
> ```
>
> Load the PEM into `QWED_A2A_SIGNING_KEY_PEM` from your secrets manager, and make sure every replica in a logical deployment uses the same PEM so their `kid`s match and tokens cross-verify.

### What this means for you

If you deploy the QWED-A2A gateway, you now get audit continuity across restarts and rolling deployments — a JWT signed by an earlier process is still verifiable by the next one, provided the PEM is unchanged. External services can also verify attestations by fetching `/.well-known/jwks.json` directly. See [Crypto attestations](https://docs.qwedai.tech/a2a/crypto-attestations.md) and [Deployment](https://docs.qwedai.tech/a2a/deployment.md) for the full setup, JWKS shape, and key rotation guidance.

***

## QWED-Tax — structured 3-layer diagnostics for TDS, ITC, and GST-RCM

**Released: June 21, 2026** · [GitHub PR #45](https://github.com/QWED-AI/qwed-tax/pull/45)

> QWED-Tax adopts the same 3-layer `DiagnosticResult` contract introduced in [QWED-Verification v5.2.0](https://docs.qwedai.tech/changelog.md#v5-2-0-—-structured-verification-diagnostics) — as an independent model, no cross-package dependency. The first three guards (TDS, Input Tax Credit, GST reverse-charge) now expose a structured diagnostic in addition to their existing dict response.

### What's new

* **Tri-state status** — Every diagnostic resolves to `VERIFIED`, `UNVERIFIABLE`, or `BLOCKED`. No `HEURISTIC` or `AMBIGUOUS` middle ground.
* **Three disclosure layers** — A short agent-safe summary (no statute IDs or detection logic), a structured developer evidence block (constraint ID, statute, jurisdiction, audit trace, deduction, net payable), and an optional proof reference.
* **Proof reference is the authority bit** — A deterministic sha256 hash of the audit trace, present only when a result is `VERIFIED`. Absent on `UNVERIFIABLE` and `BLOCKED`.
* **First migrations** — `TDSGuard`, `InputCreditGuard`, and `GSTGuard` (reverse-charge) each expose the new diagnostic format alongside their existing return shape.

### Compatibility

**Additive release.** The legacy dict API on every guard is unchanged — the diagnostic format is opt-in. Existing integrations continue to work without modification. The remaining nine QWED-Tax guards (CapitalGains, Classification, Speculation, Setoff, Crypto, Valuation, Remittance, PoEM, Withholding) will migrate in follow-up releases.

### What this means for you

If you've already adopted the QWED-Verification diagnostic contract, the same response shape now applies to TDS, ITC, and GST-RCM checks — including the proof-reference authority bit you can gate execution on. See the [Verification Diagnostics guide](https://docs.qwedai.tech/advanced/diagnostics.md) for the response shape and the [tax guards reference](https://docs.qwedai.tech/tax/guards.md) for guard-by-guard coverage.

***

## QWED-Tax — exact paise comparison, edge-case input rejection, and strict payload schemas

**Released: June 21, 2026** · [GitHub PR #44](https://github.com/QWED-AI/qwed-tax/pull/44)

> Three input-strictness fixes ship together. `CryptoTaxGuard.verify_flat_tax_rate` now compares quantized paise values exactly instead of within a 0.1 tolerance, `ValuationGuard` and `RemittanceGuard` reject the edge-case numeric inputs that previously slipped through, and every QWED-Tax Pydantic input model now forbids unexpected fields.

### What changed

* **`CryptoTaxGuard.verify_flat_tax_rate`** — The `Decimal("0.1")` tolerance was removed. Both the computed `expected_tax` and the caller's `claimed_tax` are now quantized to two decimal places using `ROUND_HALF_UP` and compared with an exact `==`. A 1-paise (`0.01`) deviation correctly returns `verified=False`.
* **`ValuationGuard.verify_conversion`** — Added explicit range checks: `discount` must be in `[0, 1)`, and `cap`, `next_round_price`, and `investment` must all be strictly positive. `DivisionByZero` is now caught alongside `InvalidOperation` so a degenerate `cap = 0` or `discount = 1` no longer crashes — it fails closed with a structured `{"verified": False, "error": "..."}` response. The previous behavior allowed a `discount > 1` to produce a negative share count.
* **`RemittanceGuard.verify_lrs_limit`** — After numeric parsing, `amount_usd` and `financial_year_usage` are now checked for negativity. Negative inputs return `{"verified": False, "error": "BLOCKED: ..."}` instead of being summed into the limit check, where a negative usage could mask a transaction that exceeds the \$250,000 LRS cap.
* **Input models** — `Address`, `WorkArrangement`, `WorkerClassificationParams`, `ContractorPayment`, `TaxEntry`, `DeductionEntry`, `PayrollEntry`, and `VerificationResult` are now configured with `model_config = ConfigDict(extra="forbid")`. Any payload with an unexpected key raises a Pydantic `ValidationError` at the boundary. `QWEDTaxMiddleware` already surfaces this as `status: "BLOCKED"` with `risk: "INVALID_PAYLOAD"`.

### Breaking changes

> [!WARNING]
>
> Callers that previously relied on the 0.1-rupee tolerance in `CryptoTaxGuard.verify_flat_tax_rate` will now receive `verified=False` for claims that differ from `vda_income * 0.30` by 1 paise or more. Round your claimed tax to two decimal places with `ROUND_HALF_UP` before calling the guard.
>
> ```python theme={null}
> from decimal import Decimal, ROUND_HALF_UP
>
> claimed = (vda_income * Decimal("0.30")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
> ```

> [!WARNING]
>
> Payloads sent through `QWEDTaxMiddleware` (or constructed directly with the QWED-Tax input models) that include keys outside the declared schema now raise `ValidationError`. Strip unknown fields from AI-generated output — including typos and speculative overrides like `"override_verification": true` — before invoking the middleware.

> [!WARNING]
>
> `ValuationGuard.verify_conversion` no longer returns a result for `cap <= 0`, `next_round_price <= 0`, `investment <= 0`, `discount < 0`, or `discount >= 1`. These inputs now return `{"verified": False, "error": "..."}` instead of crashing or producing nonsensical share counts.

### What this means for you

If your agent forwards Indian VDA tax claims, startup conversion math, or LRS remittance requests through QWED-Tax, audit the calling code for: (1) tax claims that aren't pre-quantized to two decimal places, (2) discount/cap/investment inputs that can legitimately be zero or out of range, (3) negative remittance amounts being passed defensively, and (4) AI payloads that include fields not declared on the QWED-Tax input models. See the [CryptoTaxGuard](https://docs.qwedai.tech/tax/guards.md#cryptotaxguard-sec-115bbh), [ValuationGuard](https://docs.qwedai.tech/tax/guards.md#valuationguard-verify_conversion), [RemittanceGuard](https://docs.qwedai.tech/tax/guards.md#remittanceguard-fema%2Flrs), and [middleware integration guide](https://docs.qwedai.tech/tax/integration.md#qwedtaxmiddleware-gusto-interceptor) for the updated contracts.

### Audit reference

| Issue | Area | Fix |
| - | - | - |
| #20 | `CryptoTaxGuard` accepted claims within a 0.1-rupee tolerance | Quantize to paise with `ROUND_HALF_UP`, compare with exact `==` |
| #21 | `ValuationGuard` and `RemittanceGuard` accepted edge-case numeric inputs | Range checks on discount/cap/investment; negativity checks on LRS amount and usage; `DivisionByZero` caught |
| #22 | Input models silently accepted unexpected fields | `model_config = ConfigDict(extra="forbid")` on all eight input models |

***

## QWED-Tax — middleware never returns full "verified" and ReciprocityGuard no longer always-passes

**Released: June 21, 2026** · [GitHub PR #43](https://github.com/QWED-AI/qwed-tax/pull/43)

> Two fail-closed fixes ship together. The Gusto interceptor middleware no longer overstates a gross-to-net arithmetic pass as full tax verification, and `ReciprocityGuard` no longer returns `verified=True` for arrangements with no reciprocity agreement.

> [!NOTE]
>
> `ARITHMETIC_VERIFIED` is a **pre-conformance middleware-layer status**, not part of the `DiagnosticResult` tri-state vocabulary (`VERIFIED` / `UNVERIFIABLE` / `BLOCKED`) introduced in v5.2.0. The middleware will migrate to `DiagnosticResult` when engine-conformance work lands. Until then, treat `ARITHMETIC_VERIFIED` as a distinct middleware signal — it is not equivalent to `VERIFIED`.

### What changed

* **`QWEDTaxMiddleware.process_ai_payroll_request`** — The success response is now `status: "ARITHMETIC_VERIFIED"` with `execution_permitted: false`. The middleware only verifies gross-to-net math; classification, withholding legality, reciprocity, and filing checks are still required before execution. The response includes `checks_run` (what was verified) and `checks_not_run` (what is still required) so callers can decide what to run next.
* **`TaxPreFlight` report** — Every report now includes a `checks_not_run` list covering both unselected guards and **known gaps** (checks not yet implemented for the action). For example, `action="hire"` reports `payroll_arithmetic`, `withholding_legality`, `reciprocity`, and `filing_obligations` as known gaps.
* **`ReciprocityGuard`** — The Z3 solver was removed (the prior expression was tautologically satisfiable and ignored the `same_state` parameter). The guard is now a deterministic lookup against the reciprocity-pair table with explicit fail-closed paths: same state → verified, known pair → verified, no agreement → `verified=False`, unknown state → `verified=False`. A new `verify_reciprocity(residence_state, work_state, same_state=None)` method takes string inputs; `determine_withholding_state(arrangement)` is kept for backwards compatibility.

> [!NOTE]
>
> The reciprocity-pair table covers only the pairs modeled within the `State` enum's 8-state scope (NJ, PA, MD, VA). Pennsylvania has additional reciprocity agreements (with IN, MI, OH, VA, WV, WI) that are not modeled here because those states are not in the enum. VA-PA in particular is a known gap — callers will receive `verified=False` for that pair until the enum and table are extended.

### Breaking changes

> [!WARNING]
>
> The middleware no longer returns `status: "VERIFIED"` or `execution_permitted: true`. Any caller that gated execution on `decision["status"] == "VERIFIED"` or `decision["execution_permitted"]` being truthy will now always block. Update your integration to handle `ARITHMETIC_VERIFIED` and run the checks listed in `checks_not_run` (worker classification, withholding legality, reciprocity, filing) before forwarding to Gusto/Avalara.

> [!WARNING]
>
> `ReciprocityGuard` no longer returns `verified=True` for state pairs without a reciprocity agreement. Callers that previously relied on a Z3-backed "always sat" result for, e.g., NJ → NY will now correctly receive `verified=False`. Route these to your withholding logic for the work state or to human review.

### What this means for you

If your agent forwards payroll payloads to Gusto/Avalara based on a `VERIFIED` status from the middleware, those calls will start blocking until you run the remaining guards (`ClassificationGuard`, `WithholdingGuard`, `ReciprocityGuard.verify_reciprocity`, `Form1099Guard`) yourself. See the [tax integration guide](https://docs.qwedai.tech/tax/integration.md#qwedtaxmiddleware-gusto-interceptor) for the updated response shape and the [ReciprocityGuard reference](https://docs.qwedai.tech/tax/guards.md#reciprocityguard-state-tax) for the new lookup contract.

### Audit reference

| Issue | Area | Fix |
| - | - | - |
| #19 | Middleware overstated partial verification as full verification | Status narrowed to `ARITHMETIC_VERIFIED`, `execution_permitted` forced to `false`, `checks_run`/`checks_not_run` surfaced |
| #40 | `ReciprocityGuard` Z3 solver always returned sat | Z3 removed; deterministic lookup with explicit fail-closed branches |

***

## QWED-Tax — fail-closed on ambiguous classification and unverified claims

**Released: June 21, 2026** · [GitHub PR #42](https://github.com/QWED-AI/qwed-tax/pull/42)

> Six more QWED-Tax guards now refuse to sign off when they can't independently prove a result. Ambiguous worker classifications, unparseable trade dates, unknown set-off heads, and reverse-charge inputs the guard doesn't recognize all return `verified=False` with a structured error instead of a quiet pass.

### Bug fixes

* **CapitalGainsGuard** — Unparseable acquisition or disposal dates and unknown asset types now block instead of being coerced into a sentinel value that flowed through to `verified=True`. SLAB-rate verification can no longer succeed without an income bracket — slab rates can't be proven from the claim alone.
* **ClassificationGuard** — Mixed employee/contractor signals now return `verified=False` with an "ambiguous classification" error. The guard only returns `CONTRACTOR` when no employee indicators are present.
* **SpeculationGuard** — Set-off verification now requires a known income source (`intraday`, `f&o`, `futures`, `options`, `delivery`, `business`, `capital_gains`). Unrecognized sources are blocked instead of silently treated as non-speculative.
* **InterHeadAdjustmentGuard** — Set-off eligibility now runs against an explicit allowlist of heads. Salary loss set-off is added to the prohibition matrix, and unknown heads are blocked rather than defaulting to allow.
* **GSTGuard (RCM)** — Unrecognized service or entity types in reverse-charge checks now surface as a `verified=False` error naming the unknown value, instead of being coerced to `OTHER` or `INDIVIDUAL` and potentially suppressing a statutory RCM obligation. The verifier also gained an optional claim parameter — when you pass a `claimed_is_rcm` value, the guard compares it to the computed result and only returns `verified=True` on an exact match. Calls without the claim get a `computed_only=True` flag so calculation and verification are no longer conflated.
* **CryptoTaxGuard** — Zero VDA income now verifies a claimed tax of zero, instead of returning `verified=True` regardless of claim. Negative VDA income (a loss) returns `verified=False` with a message directing the caller to use `verify_set_off` for loss treatment — the method does not internally invoke `verify_set_off`, so callers must handle the `verified=False` branch explicitly.

### What this means for you

If your agent relied on any of these guards returning `verified=True` for inputs they don't model — ambiguous worker status, unknown set-off heads, unrecognized RCM service types, or capital-gains transactions with malformed dates — those calls now block. Surface the error to a human reviewer or extend the guard's configured rules before re-running.

See the [QWED-Tax guards reference](https://docs.qwedai.tech/tax/guards.md) for the updated contracts on each guard.

***

## QWED-Tax — fail-closed on unknown tax rules

**Released: June 19, 2026**

> Tax guards no longer silently pass when they encounter a service, asset, jurisdiction, or payment type they don't model. Six guards now return `verified=False` with a structured error instead of an unsafe default.

### What changed

* **TDSGuard** — unrecognized payment categories no longer return "verified, zero deduction." This closes a path where an agent could classify a payment into an unknown bucket and have it execute with no withholding. `TaxPreFlight` now blocks these payments.
* **CapitalGainsGuard** — unknown asset class or holding term fails closed instead of returning "no hard constraint."
* **NexusGuard** — states not in the configured risk list now require manual review instead of being treated as low-risk.
* **AddressGuard** — unknown state codes fail closed with "manual review required" instead of "assumed valid."
* **Form1099Guard (US)** — unmodeled payment types return `filing_required=None` with a manual-determination flag, instead of defaulting to "no filing required."
* **InputCreditGuard (GST)** — `verified=True` remains the legal default (ITC is allowed unless specifically blocked), but unknown categories now carry an explicit `unverified_category=True` flag in the result and an `audit_trace` entry of `category_match: "default_allow"` so downstream consumers can distinguish known-eligible from default-allowed.

### What this means for you

If your agent currently relies on a `verified=True` response for inputs the guards don't model, those calls will start blocking. Add explicit rules for the categories you care about, or route unverified results to human review.

See the [tax guards reference](https://docs.qwedai.tech/tax/guards.md) and [tax integration guide](https://docs.qwedai.tech/tax/integration.md) for the updated contracts.

***

## QWED-Infra — ecosystem policy framework adopted

**Released: June 17, 2026**

> `qwed-infra` now ships with the shared QWED governance baseline: `QWED_RULES.md`, contributor guidance, PR template, CodeRabbit config, and a boundary-check workflow that is consistent with the other QWED repositories.

No runtime behavior changes. Affects contributors and anyone consuming the repository's CI.

***

## QWED-MCP — boundary-check parity with QWED-Infra

**Released: June 18, 2026**

> The `qwed-mcp` boundary-check tool now catches the same import-alias, module-alias, wildcard-import, `eval`/`exec` alias, and `shell=True` patterns that `qwed-infra` does, and fails closed when the scan root is missing.

### What this means for you

If you run `qwed-mcp` boundary checks in CI, expect to catch additional bypass patterns that previously slipped through. Existing passing scans should continue to pass; previously hidden findings may now surface as failures.

See the [MCP tools reference](https://docs.qwedai.tech/mcp/tools.md) for the current rule set.

***

## QWED Open Responses v0.3.0 — version sync and dependency fix

**Released: June 12, 2026**

> Aligns `qwed-open-responses` with the rest of the QWED package versions and patches a transitive `qs` CVE flagged by Dependabot.

No API changes. Upgrade to pick up the dependency fix.

***

## v5.2.0 — Structured Verification Diagnostics

**Released: June 19, 2026** · [GitHub Release](https://github.com/QWED-AI/qwed-verification/releases/tag/v5.2.0) · Minor

> Introduces the unified 3-layer `DiagnosticResult` model — the diagnostic contract that all QWED verification engines will conform to. This is an **additive** release: no existing engine return types are changed. Engine conformance is tracked in blocked issues (#129, #130, #131, #133, #134, #162, #163, #164, #190, #205).

### New: `DiagnosticResult` model

Three disclosure layers:

* **Layer 1 — Agent-Safe**: `agent_message: str` — agent/model-facing summary, no internals leaked
* **Layer 2 — Developer**: `developer_fields: dict` — structured evidence (`constraint_id`, `advisory_checks`, `methods_used`, evidence)
* **Layer 3 — Proof**: `proof_ref: Optional[str]` — sha256 hash of retained proof artifact; the authority bit

### Key design

* **Tri-state status** — `VERIFIED` / `UNVERIFIABLE` / `BLOCKED` only. No `HEURISTIC` or `AMBIGUOUS` proliferation; richer distinctions live in `developer_fields.constraint_id`
* **`proof_ref` is the authority bit** — present = admissible for control flow, None = reject. No separate `authoritative` boolean needed (resolves #190 design debate)
* **VERIFIED requires proof** — structurally enforced in `__post_init__`; "VERIFIED without proof" is impossible to construct
* **Frozen dataclasses** — `DiagnosticResult` and `AdvisoryCheck` are `frozen=True`; post-construction mutation blocked
* **Advisory checks never influence verdicts** — `AdvisoryCheck.advisory_only=True` enforced via `__post_init__`
* **`compute_proof_ref()`** — deterministic sha256 hashing of JSON-serialized evidence
* **`from_legacy_dict()`** — migration helper for ad-hoc engine dicts (fail-closed states only; raises for legacy VERIFIED)

### Version propagation

| Artifact | Previous | This release |
| - | - | - |
| `qwed` (PyPI) | 5.1.2 | 5.2.0 |
| `qwed_sdk` (Python) | 5.1.1 | 5.2.0 |
| `@qwed-ai/sdk` (npm) | 5.1.2 | 5.2.0 |
| `qwed` (Rust crate) | 5.1.2 | 5.2.0 |
| API version marker | 5.1.2 | 5.2.0 |
| K8s deployment image | 5.1.2 | 5.2.0 |

### Tests

83 new tests covering: status taxonomy, all 3 layers, authority contract, fail-closed enforcement, advisory checks, proof hashing determinism, serialization round-trip, legacy migration, frozen dataclass immutability, and realistic scenarios drawn from the 10 blocked issues.

### Compatibility

**Additive release.** No breaking changes. Existing `VerificationResult` dataclasses and ad-hoc engine dicts continue to work. `DiagnosticResult` is opt-in — engines migrate incrementally.

### Included PRs

* [#206](https://github.com/QWED-AI/qwed-verification/pull/206) — feat(diagnostics): unified 3-layer DiagnosticResult model (#204)
* [#207](https://github.com/QWED-AI/qwed-verification/pull/207) — release: v5.2.0 version propagation

See the [Verification Diagnostics guide](https://docs.qwedai.tech/advanced/diagnostics.md) for full API documentation.

***

Older entries: [Changelog Archive](https://docs.qwedai.tech/changelog-archive)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.