Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<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).

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

## [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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.17.0"
__version__ = "0.17.1"
__platform_version__ = "1.0.0"
45 changes: 38 additions & 7 deletions src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/nullrun/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonRejection> 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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading