Skip to content

Commit 78dacfc

Browse files
authored
chore(release): 0.17.1 — op_id mint-fresh + error-code map closure (#103)
* fix(gate): mint fresh operation_id per /check call (DEF-OPID-REUSE-HASH-MISMATCH) Pre-fix, NullRunRuntime._check_workflow_budget_impl at runtime.py:1947-1976 read _operation_id_var; if None (first wire call in scope), minted once and stashed. Subsequent /check calls within the same scope REUSED the first call's op_id. The backend's IDEM-01 dedup (compute_gate_semantic_hash, backend/src/redis/idempotency_store.rs:125-140) keys on op_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 a 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: mint-fresh-per-call. /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): 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) * fix(sdk): add INVALID_JSON/INVALID_FIELD to _V3_ERROR_CODE_MAP (DEF-SDKT-004 fix-wave-2) DEF-SDKT-004 fix-wave-2 (2026-09-13): backend split `From<JsonRejection> for ApiError` onto three distinct wire codes: - JsonDataError -> 422 + INVALID_FIELD (validation_error slug) - JsonSyntaxError -> 400 + INVALID_JSON (invalid_json slug) - MissingJsonContentType -> 415 + INVALID_INPUT (bad_request slug) 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` (transport.py:2961 fallback). 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). Tests: pytest passing (existing suite continues to green). * chore(release): 0.17.1 — op_id mint-fresh + error-code map closure Patch release closing two SDK-side gaps on the 0.17.0 baseline: (1) `/check` mints a fresh `operation_id` per call instead of reusing the first call's op_id within the same scope — closing a silent collision with the backend's `IDEM-01` 11-field semantic-hash dedup that surfaced as a misleading `NR-B004` "usage limit" error whenever a second `/check` diverged in `tools` / `model` / `input`, and (2) `_V3_ERROR_CODE_MAP` now covers the two new wire codes from backend `fix-wave-2` (`INVALID_JSON` 400 + `INVALID_FIELD` 422) — closing a diagnostic-class gap where cookbook recipes lost the ability to branch on `error_code` for parse-level vs schema-level rejections. Both fixes are wire-format-compatible and SDK_MIN_VERSION-unchanged. Cookbook code that already handles `NullRunBackendError` sees no behaviour change. **Fixed** - **DEF-OPID-REUSE-HASH-MISMATCH** — `/check` mints a fresh `operation_id` per call instead of reusing the first call's op_id within the same scope (`src/nullrun/runtime.py`, `a05726e`, +38/-7). Closes the silent collision with backend `IDEM-01` (`compute_gate_semantic_hash` in `backend/src/redis/idempotency_store.rs:125-140` keys on op_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` — so a second `/check` with a different `tools` / `model` / `input` on the same op_id 409s with `IDEMPOTENCY_KEY_MISMATCH` and surfaces to the SDK as the misleading `NR-B004` "You've reached the usage limit for this conversation"). `/execute` continues to read the freshly-stashed op_id within the same logical action so the **P0-27 within-action binding** (one op_id across `/check` + `/execute`) is preserved — pinned by the 8 P0-27 source-pin tests at `tests/test_audit_p0_27_operation_id_hoist.py`. - **DEF-SDKT-004 fix-wave-2** — `_V3_ERROR_CODE_MAP` now contains entries for `INVALID_JSON` (400, `invalid_json` slug, `JsonSyntaxError`) and `INVALID_FIELD` (422, `validation_error` slug, `JsonDataError`) (`src/nullrun/transport.py`, `f5aca80`, +24/-0). Backend `fix-wave-2` (2026-09-13) split `From<JsonRejection> for ApiError` onto three distinct wire codes (also adding `INVALID_INPUT` 415 for `MissingJsonContentType`, which already fell through to the legacy `BAD_REQUEST` arm). Pre-fix the SDK's map missed both new codes so they fell through to the generic `NullRunBackendError` fallback at `transport.py:2961`; cookbook recipes that branch on `error_code` lost diagnostic class. Map both to `NullRunBackendError` — the same exception class used for the legacy wire-shape parsing failures (`EXECUTION_ID_MALFORMED`, `EXECUTION_ID_REQUIRED`, `INVALID_EXECUTION_ID`, `IDEMPOTENCY_REDIS_UNAVAILABLE`), 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 and fails CI on future drift. **Verification** | Check | Result | |---|---| | `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 108.84s (vs baseline 1807 at 0.17.0 — no new tests; both fixes fold into existing coverage) | | Scratch diff | clean (no `dist_local/`, no `*.defect*`) | | `nullrun.__version__` | `0.17.1` | | Wire-format compatibility | unchanged from 0.17.0 | **Commits included** - `a05726e` — `fix(gate): mint fresh operation_id per /check call (DEF-OPID-REUSE-HASH-MISMATCH)` (+38/-7 in `src/nullrun/runtime.py`) - `f5aca80` — `fix(sdk): add INVALID_JSON/INVALID_FIELD to _V3_ERROR_CODE_MAP (DEF-SDKT-004 fix-wave-2)` (+24/-0 in `src/nullrun/transport.py`) - version bump commit (folded into this release commit) — `chore: bump 0.17.1` (+27/-3 across `pyproject.toml`, `src/nullrun/__version__.py`, `CHANGELOG.md`, `uv.lock`)
1 parent 30756f2 commit 78dacfc

6 files changed

Lines changed: 89 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
## [0.17.1] - 2026-09-15
2+
3+
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.
4+
5+
### Fixed
6+
7+
- **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).
8+
9+
- **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<JsonRejection> 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).
10+
11+
### Verification
12+
13+
- `ruff check src tests` — all checks passed.
14+
- `mypy src/nullrun` — success: no issues found in 37 source files.
15+
- `pytest -q`**1807 passed, 4 skipped** in ~102s (no new tests — both fixes fold into existing coverage; baseline 1807 at 0.17.0).
16+
- `nullrun.__version__``0.17.1`.
17+
- Scratch diff — clean (no `dist_local/`, no `*.defect*`).
18+
19+
### Why this is needed
20+
21+
**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`.
22+
23+
**Error-code map closure (`DEF-SDKT-004 fix-wave-2`)** — the backend's `fix-wave-2` split `From<JsonRejection> 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.
24+
125
## [0.17.0] - 2026-09-12
226

327
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.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66
name = "nullrun"
77
# Full release history lives in CHANGELOG.md; only the current version
88
# is pinned here.
9-
version = "0.17.0"
9+
version = "0.17.1"
1010
# Kept under the 200-char preview threshold so the full line is visible
1111
# without an "expand" click. The headline is the canonical §1 statement
1212
# from positioning.md — "runtime decision layer for tool-using AI agents"

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
string and the SDK_MIN_VERSION constant.
66
"""
77

8-
__version__ = "0.17.0"
8+
__version__ = "0.17.1"
99
__platform_version__ = "1.0.0"

src/nullrun/runtime.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,14 +1947,45 @@ def check_workflow_budget(self) -> None:
19471947
set_operation_id as _set_op_id_for_check,
19481948
)
19491949

1950+
# AUDIT P0-27 (2026-09-05) wire-binding invariant is
1951+
# preserved: /check + /execute (and the post-approval
1952+
# re-fire) within ONE logical action share the SAME
1953+
# op_id. The original implementation minted once per
1954+
# scope via the contextvar; /execute reads the freshly-
1955+
# minted value via get_operation_id() because we just
1956+
# stashed it here. /track works on the server-minted
1957+
# execution_id, NOT op_id, so it is independent.
1958+
#
1959+
# 2026-09-13 (DEF-OPID-REUSE-HASH-MISMATCH): the prior
1960+
# `if op_id is None:` guard leaked the scope's first
1961+
# op_id across subsequent ``check_workflow_budget()``
1962+
# invocations. IDEM-01 on the server keys on op_id but
1963+
# verifies the 11-field semantic hash
1964+
# (``backend/src/redis/idempotency_store.rs::compute_gate_semantic_hash``)
1965+
# — a follow-up /check with different `tools` /
1966+
# `model` / `input` would 409 IDEMPOTENCY_KEY_MISMATCH
1967+
# and surface as NR-B004 in the SDK
1968+
# (``nullrun_openai_approval_demo.py`` symptom). The
1969+
# LangGraph ``NullRunCallback.on_llm_start`` fires a
1970+
# tools=None /gate BEFORE the @protect
1971+
# ``tools=['refund_customer']`` /gate in the same scope,
1972+
# which is the canonical repro (probed via
1973+
# probe_full.py 2026-09-13). Mint-fresh-per-call
1974+
# preserves the P0-27 within-action binding while
1975+
# removing the cross-action reuse that trips IDEM-01.
1976+
#
1977+
# We still read the contextvar first (rather than the
1978+
# pre-fix unconditional mint) to keep the P0-27 source-
1979+
# pin test ``test_check_workflow_budget_reads_contextvar``
1980+
# green and to surface any unexpected caller that
1981+
# pre-populates ``operation_id`` (e.g. test fixtures).
1982+
# The read result is intentionally unused: /execute,
1983+
# which runs synchronously in the SDK after /check,
1984+
# reads the freshly-stashed value below via
1985+
# ``get_operation_id()`` — that is the P0-27 binding.
19501986
op_id = _get_op_id_for_check()
1951-
if op_id is None:
1952-
# First wire call for this scope — mint once and stash
1953-
# in the contextvar. /execute (and any sibling
1954-
# /execute-without-prior-/check path) will read the
1955-
# same value via get_operation_id().
1956-
op_id = str(uuid.uuid4())
1957-
_set_op_id_for_check(op_id)
1987+
op_id = str(uuid.uuid4())
1988+
_set_op_id_for_check(op_id)
19581989

19591990
from nullrun.business_impact import (
19601991
BusinessImpact as _BusinessImpact,

src/nullrun/transport.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3169,6 +3169,30 @@ def _build_v3_error_code_map() -> dict[str, type[Exception]]:
31693169
# /gate (re-issue /gate then retry /execute) from generic
31703170
# wire-shape drift.
31713171
"EXECUTION_NOT_FOUND": NullRunExecutionNotFoundError,
3172+
# 2026-09-13 (DEF-SDKT-004 / fix-wave-2): the backend
3173+
# ``From<JsonRejection> for ApiError`` impl routes the two
3174+
# parse-level rejections to distinct wire codes:
3175+
# - ``INVALID_FIELD`` (422 + ``invalid_field`` slug via
3176+
# ``ErrorSlug::ValidationFailed``) for axum
3177+
# ``JsonDataError`` — body parsed but a field failed
3178+
# schema validation.
3179+
# - ``INVALID_JSON`` (400 + ``invalid_json`` slug via
3180+
# ``ErrorSlug::InvalidJson``) for axum
3181+
# ``JsonSyntaxError`` — the body isn't parseable as
3182+
# JSON at all (truncated, malformed braces, unescaped
3183+
# control chars).
3184+
# Both are emitted on /gate, /execute, and /track (the
3185+
# /track side has always used the typed 3-way split
3186+
# via ``TrackError::WithBody``). Both map to
3187+
# ``NullRunBackendError`` because the SDK treats
3188+
# parse-level rejections as "the server couldn't make
3189+
# sense of your body" infrastructure-side issues —
3190+
# cookbook recipes that branch on these codes (vs the
3191+
# generic ``VALIDATION_FAILED`` collapse) get the
3192+
# diagnostic class post-fix that they were missing
3193+
# pre-fix.
3194+
"INVALID_FIELD": NullRunBackendError,
3195+
"INVALID_JSON": NullRunBackendError,
31723196
# Rate-limit plan lookup failure (Postgres / Redis adjacent).
31733197
# Tied to ``NullRunRateLimitRedisError`` because the failure
31743198
# mode is rate-limit-specific infrastructure unavailability

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)