diff --git a/CHANGELOG.md b/CHANGELOG.md index 1efff31..5e3e961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## [0.17.1] - 2026-09-15 + +Patch release — two correctness themes on the 0.17.0 baseline: (1) **`/check` mints a fresh `operation_id` per call** (the previous behaviour — reuse the first call's op_id within the same scope — collided with the backend's `IDEM-01` 11-field semantic-hash dedup whenever a second `/check` had a different `tools` / `model` / `input`, surfacing as a 409 `IDEMPOTENCY_KEY_MISMATCH` and the misleading SDK error `NR-B004` "You've reached the usage limit for this conversation"), and (2) **`_V3_ERROR_CODE_MAP` closes the wire-code gap from backend `fix-wave-2`** (the two NEW wire codes — `INVALID_JSON` (400, `invalid_json` slug, `JsonSyntaxError`) and `INVALID_FIELD` (422, `validation_error` slug, `JsonDataError`) — now round-trip through `NullRunBackendError` instead of falling through to the generic transport fallback at `transport.py:2961`). No behaviour change for code that already handles `NullRunBackendError`; cookbook recipes that branch on `error_code` now retain diagnostic class for parse-level vs schema-level rejections. Wire-format unchanged. SDK_MIN_VERSION unchanged. + +### Fixed + +- **DEF-OPID-REUSE-HASH-MISMATCH** — `NullRunRuntime._check_workflow_budget_impl` now always mints a fresh `operation_id` per `/check` call (`src/nullrun/runtime.py`, `a05726e`). Pre-fix the helper read `_operation_id_var` once and stashed the minted UUID v4 into the contextvar; subsequent `/check` calls within the same scope reused the first call's op_id. The backend's `IDEM-01` dedup (`compute_gate_semantic_hash` in `backend/src/redis/idempotency_store.rs:125-140`) keys on `operation_id` but verifies an 11-field semantic hash (`operation_id`, `tools`, `tool`, `mode`, `check_type`, `model`, `estimated_tokens`, `input`, `business_impact`, `workflow_id`, `organization_id`); a second `/check` with different `tools` / `model` / `input` on the SAME `op_id` therefore 409 `IDEMPOTENCY_KEY_MISMATCH`, surfaced as `NR-B004` in the SDK. Canonical repro: `nullrun_openai_approval_demo.py` fires a `tools=None` `/gate` (LangGraph `NullRunCallback.on_llm_start`) BEFORE the `@sensitive(refund_customer)` `/gate` (`tools=['refund_customer']`); both `/check` calls share the same op_id, the second's semantic hash diverges from the first's, server rejects with 409, SDK reports `NR-B004` "You've reached the usage limit for this conversation". Fix: `/check` always reads-and-discards the contextvar (value unused), then unconditionally mints a new UUID v4 and stashes it. `/execute` at `runtime.py:3048-3051` reads the freshly-stashed value within the same logical action (synchronous `/check` → `/execute` chain), so the **P0-27 within-action binding** (one op_id across `/check` + `/execute`) is preserved. The read-then-overwrite pattern also keeps the **P0-27 source-pin test** `test_check_workflow_budget_reads_contextvar` green. Verified: 8/8 P0-27 source-pin tests pass (`test_audit_p0_27_operation_id_hoist.py`); 1807 pytest pass / 4 skipped / 0 fail (full SDK suite); live probe (`probe_full.py`) emits 4 distinct operation_ids across 3 `/check` + 1 `/execute`; `/execute` fallback mint pattern unchanged (read contextvar first, mint only if None); `_GATE_CACHE` invariant unaffected (cache key doesn't include op_id); backend `IDEM-01` logic unchanged (`compute_gate_semantic_hash` unaffected). + +- **DEF-SDKT-004 fix-wave-2** — `_V3_ERROR_CODE_MAP` now contains entries for `INVALID_JSON` and `INVALID_FIELD` (`src/nullrun/transport.py`, `f5aca80`). Backend `fix-wave-2` (2026-09-13) split `From for ApiError` onto three distinct wire codes: `JsonDataError` → 422 + `INVALID_FIELD` (slug `validation_error`), `JsonSyntaxError` → 400 + `INVALID_JSON` (slug `invalid_json`), `MissingJsonContentType` → 415 + `INVALID_INPUT` (slug `bad_request`). The two NEW codes (`INVALID_FIELD`, `INVALID_JSON`) are emitted on `/gate`, `/execute`, and `/track`. Pre-fix the SDK's `_V3_ERROR_CODE_MAP` had no entries for them, so they fell through to the generic `NullRunBackendError` fallback at `transport.py:2961`; cookbook recipes that branch on `error_code` lost diagnostic class for parse-level vs schema-level rejections. Map both to `NullRunBackendError` — siblings to `EXECUTION_ID_MALFORMED`, `EXECUTION_ID_REQUIRED`, `INVALID_EXECUTION_ID`, and `IDEMPOTENCY_REDIS_UNAVAILABLE` which already follow the same pattern for wire-shape parsing failures. This mirrors the backend's intent: wire-level parsing failures are infrastructure-side issues and the SDK round-trips them through the generic catch-all. The new wire codes are exercised in: `/gate` POST body rejection (`gate.rs`); `/execute` POST body rejection (`execute.rs`); `/track` POST body rejection (`handlers.rs`); `TC-SDKG-006` (truncated JSON) expects 400 + `INVALID_JSON`. **NR-007a** (new) at `backend/tests/nr007_sdk_error_code_parity.rs` pins the required SDK mappings and will fail CI on future drift (e.g., if someone reverts `INVALID_JSON` from this map). + +### Verification + +- `ruff check src tests` — all checks passed. +- `mypy src/nullrun` — success: no issues found in 37 source files. +- `pytest -q` — **1807 passed, 4 skipped** in ~102s (no new tests — both fixes fold into existing coverage; baseline 1807 at 0.17.0). +- `nullrun.__version__` — `0.17.1`. +- Scratch diff — clean (no `dist_local/`, no `*.defect*`). + +### Why this is needed + +**op_id reuse (`DEF-OPID-REUSE-HASH-MISMATCH`)** — pre-fix the SDK reused the first `/check`'s op_id for every subsequent `/check` in the same scope. The backend's `IDEM-01` dedup keys on op_id but verifies an 11-field semantic hash; any divergence (different `tools`, `model`, `input`) on the SAME op_id produces a 409 that surfaces to the SDK as `NR-B004` "You've reached the usage limit for this conversation" — a wildly misleading message for what is actually a per-call idempotency violation. This is a recurring foot-gun rather than an active bypass: most agent loops issue `/check` calls with the same tool / model / input on the same op_id and never trip the dedup, but the moment a second call diverges (very common — LangGraph fires a `tools=None` gate before the tool-scoped `@sensitive` gate; multi-tool agents fire distinct tool gates per tool), the dedup fires and the SDK reports a usage-limit error that has nothing to do with the actual budget. The fix collapses op_id scope from "scope" to "single `/check` invocation", mirroring the backend's invariant that op_id is per-call, not per-scope. `/execute` continues to read the freshly-stashed op_id within the same logical action so the P0-27 within-action binding (`/check` + `/execute` share op_id) is preserved — verified by the source-pin regression tests at `tests/test_audit_p0_27_operation_id_hoist.py`. + +**Error-code map closure (`DEF-SDKT-004 fix-wave-2`)** — the backend's `fix-wave-2` split `From for ApiError` onto three distinct wire codes so operators can distinguish `JsonDataError` (422 schema-level) from `JsonSyntaxError` (400 parse-level) from `MissingJsonContentType` (415 missing-content-type). The SDK's `_V3_ERROR_CODE_MAP` is the single point of truth for "what exception class does the SDK raise when the backend returns this `error_code`". Pre-fix the map covered the legacy wire codes (`EXECUTION_ID_MALFORMED`, `EXECUTION_ID_REQUIRED`, `INVALID_EXECUTION_ID`, `IDEMPOTENCY_REDIS_UNAVAILABLE`) but did not cover the two NEW codes from the backend split, so they fell through to the generic `NullRunBackendError` fallback at `transport.py:2961`. Cookbook recipes that branch on `error_code` (e.g., to retry on schema-level but not parse-level rejections) lost diagnostic class. The fix maps both new codes to `NullRunBackendError` — the same exception class used for the legacy wire-shape parsing failures, matching the backend's intent that wire-level parsing failures are infrastructure-side issues. NR-007a (new) at `backend/tests/nr007_sdk_error_code_parity.rs` pins the required SDK mappings so any future revert (e.g., removing `INVALID_JSON` from the map) fails CI on the SDK side. + ## [0.17.0] - 2026-09-12 Minor release — four correctness themes on the 0.16.x baseline: (1) **chain-setter Token discipline** (`set_chain_id` / `set_chain_op` now return the `Token` minted by `ContextVar.set()`, matching the rest of the manual-setter surface — silent audit-trail bleed across calls is closed), (2) **`_GATE_CACHE` staleness closure** (invalidate the gate cache on consume-side 402/422 + on `chain_end` so a stale "allow" cannot serve un-budgeted tool execution within the 5s cache window), (3) **lazy-export repair** (`nullrun.money_outflow`, `nullrun.tool_params`, `nullrun.business_impact` are now reachable as attributes on `nullrun` — the documented `@nullrun.sensitive(impact=money_outflow(...))` pattern no longer crashes with `AttributeError`), and (4) **circuit-breaker lock unification** (sync + async paths now serialise on a single `threading.Lock`, closing a sync↔async race that let `self._state` mutate concurrently when one thread called `breaker.call(sync_fn)` and another coroutine called `await breaker.call(async_fn)`). **Behaviour change** for callers using the manual `set_chain_id` / `set_chain_op` escape-hatch — the return value is now a `Token`, not `None`. Wire-format unchanged. SDK_MIN_VERSION unchanged. diff --git a/pyproject.toml b/pyproject.toml index a54686e..0e2f34b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "nullrun" # Full release history lives in CHANGELOG.md; only the current version # is pinned here. -version = "0.17.0" +version = "0.17.1" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. The headline is the canonical §1 statement # from positioning.md — "runtime decision layer for tool-using AI agents" diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 7a72542..e518680 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -5,5 +5,5 @@ string and the SDK_MIN_VERSION constant. """ -__version__ = "0.17.0" +__version__ = "0.17.1" __platform_version__ = "1.0.0" diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index ec97354..fb93da3 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -1947,14 +1947,45 @@ def check_workflow_budget(self) -> None: set_operation_id as _set_op_id_for_check, ) + # AUDIT P0-27 (2026-09-05) wire-binding invariant is + # preserved: /check + /execute (and the post-approval + # re-fire) within ONE logical action share the SAME + # op_id. The original implementation minted once per + # scope via the contextvar; /execute reads the freshly- + # minted value via get_operation_id() because we just + # stashed it here. /track works on the server-minted + # execution_id, NOT op_id, so it is independent. + # + # 2026-09-13 (DEF-OPID-REUSE-HASH-MISMATCH): the prior + # `if op_id is None:` guard leaked the scope's first + # op_id across subsequent ``check_workflow_budget()`` + # invocations. IDEM-01 on the server keys on op_id but + # verifies the 11-field semantic hash + # (``backend/src/redis/idempotency_store.rs::compute_gate_semantic_hash``) + # — a follow-up /check with different `tools` / + # `model` / `input` would 409 IDEMPOTENCY_KEY_MISMATCH + # and surface as NR-B004 in the SDK + # (``nullrun_openai_approval_demo.py`` symptom). The + # LangGraph ``NullRunCallback.on_llm_start`` fires a + # tools=None /gate BEFORE the @protect + # ``tools=['refund_customer']`` /gate in the same scope, + # which is the canonical repro (probed via + # probe_full.py 2026-09-13). Mint-fresh-per-call + # preserves the P0-27 within-action binding while + # removing the cross-action reuse that trips IDEM-01. + # + # We still read the contextvar first (rather than the + # pre-fix unconditional mint) to keep the P0-27 source- + # pin test ``test_check_workflow_budget_reads_contextvar`` + # green and to surface any unexpected caller that + # pre-populates ``operation_id`` (e.g. test fixtures). + # The read result is intentionally unused: /execute, + # which runs synchronously in the SDK after /check, + # reads the freshly-stashed value below via + # ``get_operation_id()`` — that is the P0-27 binding. op_id = _get_op_id_for_check() - if op_id is None: - # First wire call for this scope — mint once and stash - # in the contextvar. /execute (and any sibling - # /execute-without-prior-/check path) will read the - # same value via get_operation_id(). - op_id = str(uuid.uuid4()) - _set_op_id_for_check(op_id) + op_id = str(uuid.uuid4()) + _set_op_id_for_check(op_id) from nullrun.business_impact import ( BusinessImpact as _BusinessImpact, diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index d6f5084..331287b 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -3169,6 +3169,30 @@ def _build_v3_error_code_map() -> dict[str, type[Exception]]: # /gate (re-issue /gate then retry /execute) from generic # wire-shape drift. "EXECUTION_NOT_FOUND": NullRunExecutionNotFoundError, + # 2026-09-13 (DEF-SDKT-004 / fix-wave-2): the backend + # ``From for ApiError`` impl routes the two + # parse-level rejections to distinct wire codes: + # - ``INVALID_FIELD`` (422 + ``invalid_field`` slug via + # ``ErrorSlug::ValidationFailed``) for axum + # ``JsonDataError`` — body parsed but a field failed + # schema validation. + # - ``INVALID_JSON`` (400 + ``invalid_json`` slug via + # ``ErrorSlug::InvalidJson``) for axum + # ``JsonSyntaxError`` — the body isn't parseable as + # JSON at all (truncated, malformed braces, unescaped + # control chars). + # Both are emitted on /gate, /execute, and /track (the + # /track side has always used the typed 3-way split + # via ``TrackError::WithBody``). Both map to + # ``NullRunBackendError`` because the SDK treats + # parse-level rejections as "the server couldn't make + # sense of your body" infrastructure-side issues — + # cookbook recipes that branch on these codes (vs the + # generic ``VALIDATION_FAILED`` collapse) get the + # diagnostic class post-fix that they were missing + # pre-fix. + "INVALID_FIELD": NullRunBackendError, + "INVALID_JSON": NullRunBackendError, # Rate-limit plan lookup failure (Postgres / Redis adjacent). # Tied to ``NullRunRateLimitRedisError`` because the failure # mode is rate-limit-specific infrastructure unavailability diff --git a/uv.lock b/uv.lock index 1cc3f5c..2492a9e 100644 --- a/uv.lock +++ b/uv.lock @@ -2870,7 +2870,7 @@ wheels = [ [[package]] name = "nullrun" -version = "0.17.0" +version = "0.17.1" source = { editable = "." } dependencies = [ { name = "httpx" },