All notable changes to nullrun-sdk will be documented here.
Format: Keep a Changelog Versioning: Semantic Versioning
ToolParameters Approval Rules wire contract (Tier 2 / Разрыв 2 follow-up). The backend already accepted BusinessImpact::ToolCall(ToolCallParams) on the /execute wire (backend commit 1e501cd6); 0.14.4 lands the SDK-side path so users get ToolParameters rules by default on every bare @sensitive function, with no decorator change. Also fixes a silent regression in the auto-attach path that dropped an explicit impact=tool_params({...}) map, and pins the cross-language ToolCall action digest against the Rust backend's golden hex. No on-wire breaking change for money callers; the only behavioural change is that bare @sensitive now ships kind=tool_call on the wire where it previously shipped nothing.
BusinessImpact.tool_call(tool_name, params)factory —business_impact.py:323new factory builds aBusinessImpact(kind='tool_call', tool_name=..., params=...)envelope by analogy with the legacyBusinessImpactmoney constructor. Mirrors the backendBusinessImpact::ToolCall(ToolCallParams)variant (backend/src/proxy/gate/business_impact.rs:62-307). Used internally byToolParamsExtractor; exposed publicly so users can hand-build impacts without importing the dataclass.ToolCallParamsdataclass —business_impact.py:143mirrors the backend struct (tool_name≤ 128 bytes,param_name≤ 64, JSON-roundtrippable values only).BusinessImpact.kindnow discriminatesMoney|ToolCall; existing money callers continue to discriminate on the same field via theextractor_*metadata.ToolParamsExtractor+tool_params(...)factory —extractor.py:815(class) and the matching factory. Three modes: explicit{rule_param: arg_name}map,include_all=True(default — every kwarg captured), orinclude_all=Falsewith no map (empty). PII-masked sentinels ("***") and JSON-unsafe values (float, custom objects) are filtered before the wire. The factory is the analogue ofMoneyImpactExtractor + money_outflow(...).- Bare
@sensitivenow ships ToolParameters on the wire —decorators.py:1096(_do_sensitive_register) auto-attaches a defaultToolParamsExtractor(include_all=True)on a bare@sensitivedecorator. The stamp goes through_stamp_extractor_on_innermostso the bare function (the one@protectcaptures asfn) carries the attribute, not just the@protectwrapper. An explicit@sensitive(impact=money_outflow(...))or@sensitive(impact=tool_params({...}))wins — the auto-attach only fires when no extractor is present. @sensitive(impact=tool_params({...}))decorator form —decorators.py:1065new docstring +decorators.py:711dispatch branch. Operators writing ToolParameters Approval Rules on the backend can now declare the per-rule param map directly at the decorator site instead of relying on the auto-attach default.
- Auto-attach chain walk preserves an explicit
impact=tool_params({...})map —decorators.py:43new helper_find_extractor_in_chainwalks__wrapped__(bounded at 32 hops) so the auto-attach check sees the explicit extractor stamped on the bare function instead of falling through to the default. Before this fix,@sensitive(impact=tool_params({"delete_force": "force"})) @protect def delete_user(force, user_id): ...silently shipped{force: <bool>, user_id: <int>}(the auto-attach default) instead of the explicit{delete_force: <bool>}map. After this fix, the renamed key reaches the wire. Regression tests inTestAutoAttachChainWalk(4 cases): bare auto-attach, explicit tool_params map preserved, explicit money_outflow preserved, circular-__wrapped__defensive bounded walk. _enforce_sensitive_tooldispatch handles both extractor types —decorators.py:677(success path) anddecorators.py:711(error path) now branch by extractor type. NR-B003 error hint text branches too — operators writing ToolParameters rules see "did you meanimpact=tool_params(...)?" while money operators see the money remediation advice.- Bare
@sensitiveregression in the existingtests/test_sensitive_extractor.py— the 5 existing tests still pass because they register the tool manually viart.add_sensitive_tool(name), which bypasses the decorator auto-attach path. Documented as a deliberate carve-out: only@sensitive(the decorator form) auto-attaches.
tests/test_tool_params_extractor.py— 23 new tests across 5 classes (TestToolParamsFactory,TestToolParamsExtraction,TestAutoAttachOnBareSensitive,TestToolCallParamsShape,TestAutoAttachChainWalk). Covers factory shape (3), three extraction modes (4), PII sentinel + float filtering (3), action digest byte-identity with the backend's canonical JSON (1), the auto-attach wiring (2), dataclass validator (7), kind dispatch (1), and the chain-walk regression (4). Verified: 23/23 pass.tests/test_business_impact.py::TestToolCallActionDigestPins— 5 new tests cross-language parity for theToolCallimpact, pinned to the same hex literal the Rust backend pins inbackend/src/proxy/gate/business_impact.rs::tests::tool_call_digest_golden_value_stripe_charge_500. A drift on either side trips the test on the other side next time the suite runs. Fixture payload:tool_call("stripe.charge", {"region": "EU", "amount": 500})→9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6.tests/test_sensitive_extractor.py— 5/5 pass (regression check, the auto-attach wiring is additive on top of 0.14.1).tests/test_business_impact.py— full class passes (28/28 including the 5 new parity pins).tests/test_extractors.py— 35/35 pass.tests/test_protect.py + test_protect_branches.py + test_execute_approval_flow.py + test_approval_money_flow.py + test_gate_real_path.py + test_handle.py— 99/99 pass.tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py— 70/70 pass (1 skipped, pre-existing).
- Default SDK behaviour for bare
@sensitiveCHANGED — wasno business_impact on wire, nowkind=tool_call on wire. Operators who relied on the Phase 0 path (approval_id-only grant consume) must either pass@sensitive(impact=tool_params(include_all=False))explicitly, or accept the new ToolParameters wire shape. The change is additive on the SDK side; legacy backends ignorekind=tool_calland fall through to a no-op. - Existing
@sensitive(impact=money_outflow(...))callers are unaffected — the explicit extractor wins over the auto-attach (verified bytest_explicit_money_outflow_chain_walk_preserved). - Legacy "no impact extractor" call sites (registered via
rt.add_sensitive_tool(name)directly) are unaffected — the auto-attach is only wired through_do_sensitive_register, which only the@sensitivedecorator calls. - No SDK_MIN_VERSION bump. ToolParameters is an opt-in backend feature; SDK 0.14.4 talking to a backend that has the
BusinessImpact::ToolCallvariant (commit1e501cd6and later) is the supported path. SDK 0.14.4 talking to an older backend works but thekind=tool_callenvelope is ignored — same effective behaviour as 0.14.3 minus the wire bytes.
Three hotfixes that fell out of the 0.14.1 demo run. Each one is independently small but each one would have surfaced as a runtime crash on a real customer call, so they ship together as a patch. No on-wire breaking change. No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged.
@protectdecorator now emits atools/track_toolevent —decorators.py:470anddecorators.py:521(sync + async wrappers) now callruntime.track_tool(fn.__name__, metadata={"arguments": _safe_kwargs(kwargs)})after the wrapped body returns. Pre-0.14.2 theprotecteddecorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw aprotectexecution even though the body ran. The new emit goes through the same sink asllm_callevents, so it picks up the dedup LRU atruntime.track()for free.track_toolevent carriestokens: 0and a freshuuidv7execution_id—runtime.py:3077now stamps both fields onto everytool_callevent. The backend'sSdkTrackRequestrequirestokens: u64(non-Optional) and a threadableexecution_id; pre-0.14.2 the event dict only carriedtype/tool_name/is_retryand the deserializer rejected it. Span lifecycle events (span_start/span_end) get the sametokens: 0default viaruntime.py:2161.- Approval-resolved WS callback is now a plain sync function —
transport.py:1757wrapped_approval_resolvedwas previously declaredasync defto be awaitable, but the WebSocket dispatch path invokes it as a plain function (the dispatch signature isdict[str, Any] -> None, not awaitable). The async-decorated coroutine was silently dropped, so the syncthreading.Eventinsideruntime._wait_for_approval_resolutionnever got set on the first approval round-trip — the demo's first approval hung forever. Caught 2026-07-24 with the demo's first approval resolution. - WebSocket cancellation is treated as a clean shutdown —
runtime.py:1160now catchesasyncio.CancelledErrorbefore the genericexcept Exceptionblock.WebSocketConnection.close()cancels the receive task to unblock this waiter during normal shutdown; on Python 3.11+CancelledErrorderives fromBaseException(notException), so the old code re-raised it and produced a noisyWS receive loop ended: <never logged>debug line on every clean shutdown. The new branch is silent and the path stays contained.
tests/test_approval_ws_sync_callback.py— 103 lines of new coverage for the WS approval-resolved dispatch path: the callback is invoked as a sync function, thethreading.Eventis set, the wait returns within the timeout, and the previous async-decorated shape is asserted-not-present.tests/test_runtime_branches.py— 36 lines of new coverage for theawait conn._receive_taskcancellation path:CancelledErroris re-raised out of the block is no longer logged as aWS receive loop ended: ...debug line, and thefinallycleanup still runs.- The existing
tests/test_sensitive_extractor.py(5/5) andtests/test_approval_money_flow.py(18/18) pass unchanged — the new fields are additive on top of the 0.14.1 wire shape.
- Backward-compatible bug fix. No SDK_MIN_VERSION bump. No public API change.
- The new
tokens: 0/execution_idfields ontrack_toolevents are forwarded exactly as minted; the backend'sSdkTrackRequestalready accepts them (the 0.14.0 envelope contract). - The approval-resolved callback is the same public contract (
def on_approval_resolved(payload: dict) -> None); only the in-transport wrapper changed fromasync deftodef. - The WS cancellation handler is silent in the same way the previous
except Exceptionwas silent; the only user-visible delta is a removed debug log line on clean shutdown.
Decimal JSON serialization patch. track_tool event payloads that contain a Decimal value (e.g. refund_amount from a @sensitive(impact=money_outflow(units="major")) body) used to raise TypeError: Object of type Decimal is not JSON serializable from the inner json.dumps call. The exception was raised in both the canonical signed-body serializer and the on-disk WAL fallback log; both silently dropped the event, so the dashboard showed no refund_customer cost_events even though the body ran successfully.
_signed_request_bodyDecimal serialization —transport.py:251now passesdefault=strtojson.dumps(payload, separators=(",", ":"), default=str). Decimal serialises as its lossless string representation ("50.99"on the wire), and the backend's pricing math runs on the same string. Pre-fix events that serialised cleanly still serialise to the same bytes becausedefault=is only consulted when the default encoder fails. Other non-JSON-native types (bytes,datetime,UUID) get the samestr()fallback so a single encoder pass handles them all.- WAL fallback
default=str—transport.py:711_signed_request_bodyWAL fallback (f.write(json.dumps(event) + "\n")) also getsdefault=strfor consistency. The on-disk fallback log is read by ops only when the backend is unreachable, so the wire-format guarantee does not apply here.
tests/test_sensitive_extractor.py— 5/5 pass (the wire-format bytes match for any payload withoutDecimal).tests/test_approval_money_flow.py— 18/18 pass.- Full suite —
pytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml --cov-fail-under=0→ 1367 passed, 7 skipped, 29 warnings in 33.24s, coverage 81.49%.
- Backward-compatible bug fix. No SDK_MIN_VERSION bump. No public API change.
- The wire shape is preserved for every pre-fix event (a non-Decimal payload serialises to the same bytes); the Decimal serialisation is a strict superset.
InvalidMoneyPrecisionErrorandInvalidMoneyAmountError— dedicatedValueErrorsubclasses with structured fields. The amount variant carries areasondiscriminator ("negative"/"overflow"/"non_finite"); the precision variant carriescurrency/allowed/received/received_digits. Legacyexcept ValueError:blocks still catch them.BusinessImpactmodel (dataclass(frozen=True)) with explicitcurrency/units/amount_minorfields.detailsdict is still accepted on the legacy path.@sensitive(impact=BusinessImpact(...))— new decorator kwarg that emits a structuredbusiness_impactenvelope on the/trackevent. Existing@sensitive(details=...)/@sensitive(amount_minor=..., currency=...)callers keep working on the happy path (now routed throughBusinessImpactinternally).MoneyImpactExtractor— new helper that normalisesDecimal/int/float/ str intoBusinessImpactminor-units, raisingInvalidMoneyAmountError/InvalidMoneyPrecisionErroron the audit gaps above.
- Negative
amount_minorrejected on both unit paths. A negative value would silently fall through everyop=gtpredicate (negative < positiveis always False) — pre-fix a $-50 refund could be wired through without the backend catching it.0is still accepted (legitimate $0.00 refund). - Sub-precision Decimal rejected —
Decimal("1.234")against a USDallowed=2precision is nowInvalidMoneyPrecisionError(currency="USD", allowed=2, received=3, received_digits="1.234")instead of a silent round to1.23that drops the high-order digit the user explicitly typed.floatandDecimalare treated symmetrically;intalways rounds 0-digits. /executehandlesrequire_approvalcorrectly — re-checks with theapproval_idreturned by the backend (was dropping the approval handshake on round-trips).- Server
approval_timeoutclamped to[1, 3600]son the SDK side as defence against a malformed / overshooting backend that returns0or2147483647in the Разрыв 1c field.
tests/test_money_hardening.py— 5 Definition-of-Done scenarios (negative amount, sub-precision Decimal, overflow, non-finite,0accepted).tests/test_business_impact.py—BusinessImpactmodel contract + integration with the wire envelope.tests/test_units_discriminator.py—USDvsUSDTcollision caught at theBusinessImpactboundary, not on the backend at/tracktime.tests/test_sensitive_extractor.py—@sensitive(impact=...)round-trip + legacydetails=backward-compat.tests/test_approval_money_flow.py— 5 contract tests covering theMoneyImpactExtractorpath end-to-end.tests/test_execute_approval_flow.py—/executeround-trip with stub backend exercising therequire_approval+approval_idre-check path.
- Backward compatible on the happy path. Every existing call site keeps working; the new errors are
ValueErrorsubclasses; the newBusinessImpactdecorator kwarg is optional. - No SDK_MIN_VERSION bump — legacy backends without the Разрыв 1c field fall through to the env default (see 0.13.13 release notes).
- No on-wire change — envelope shape preserved; new fields are additive on the SDK side and ignored by older backends.
Approval-wait SDK sync with backend commit 0ad03b9 ("\u0420\u0430\u0437\u0440\u044b\u0432 1c", gate hot-path trigger). The backend now sends approval_timeout_seconds: Option<i64> and approval_expires_at: Option<String> on every /gate response so a backend approval rule can set a non-default short timeout. Pre-fix, the SDK only consulted NULLRUN_APPROVAL_TIMEOUT_SECONDS (env default 300s), which silently desynced from a 20s backend expiry sweeper. No public API change. No SDK_MIN_VERSION bump. No on-wire change.
- Approval wait uses server-authoritative
approval_timeout_secondswhen present \u2014 new optional kwargtimeout_seconds: float | None = Noneon_wait_for_approval_resolution. When the gate response carries a positive integer, that value drives the parkedevent.wait; when the field is absent, non-positive, or non-numeric, the SDK falls back to the env default (pre-0.13.13 behaviour preserved). Explicit zero/negative values are rejected becauseevent.wait(timeout=0)deadlocks on the very first call. check_workflow_budgetreadsresponse["approval_timeout_seconds"]with type and sign validation. Malformed values fall through to the env default path.approval_expires_atis documented as informational (UI/logs) and intentionally not parsed by the SDK.- Diverging server vs env default emits a DEBUG log line ("approval {id}: using server timeout={X}s (env default would have been {Y}s)") so an operator inspecting logs can see which value actually drove the wait \u2014 useful for diagnosing "why did this approval time out earlier than I configured" tickets.
tests/test_approval_timeout_field.py\u2014 6 new tests: server timeout used when response has valid value, env fallback when response omits the field, env fallback when server value is zero/negative, env fallback when server value is non-numeric, timeout sentinel returned when no ws push, diverging server value logs at debug.
- The new
timeout_secondskwarg is optional with aNonedefault, so existing callers are unaffected. - Legacy backends without the \u0420\u0430\u0437\u0440\u0438\u0432 1c field fall through to the env default \u2014 exactly as before.
- The SDK is a passive consumer of the new optional fields; no wire-format change.
CI / coverage-testability release. No on-wire change, no SDK_MIN_VERSION bump, no public API change. Backends on 1.0.0 keep working unchanged.
pytestsuite is now CI-fast on Windows + xdist — a new_fast_sleepautouse fixture intests/conftest.pycaps test-codetime.sleepcalls at 1ms, with two opt-out paths (@pytest.mark.slow_sleepandNULLRUN_FAST_SLEEP=0env var). The fixture also patchesnullrun.transport.time.sleepandnullrun.breaker.circuit_breaker.time.sleepso thetime.sleep(...)calls captured in those modules at import time still hit the cap. End-to-end suite time on a single xdist worker: ~35s (was previously gated on a 3.3s per-test wall-clock tax in theTestCircuitBreakerhalf-open tests).TestCircuitBreakerhalf-open tests no longer sleep the wall clock —test_open_transitions_to_half_open_after_timeout,test_half_open_success_closes, andtest_half_open_failure_reopensnow use a new_advance_clock(monkeypatch, seconds=...)helper that patchesnullrun.breaker.circuit_breaker.time.monotonicto the wall clock+N. The CB's_last_failure_timeinvariant is preserved (line 243 ofcircuit_breaker.py) without a real wait.TestPingChainScheduleropts out of the cap via marker — the new@pytest.mark.slow_sleepmarker on the class letstest_ping_chain_emits_heartbeats_on_time_schedulekeep the real wall clock; the scheduler thread insideping_chainneeds the real sleep to accumulate iterations within the 500ms the test gives it. The marker is registered inpyproject.tomlunder[tool.pytest.ini_options].markers.
- The
_advance_clockhelper lives intests/test_transport.pyand is module-private to the CB tests for now. If a future test needs the same wall-clock advancement (e.g. a new CB recovery test), move it totests/conftest.py— that promotion is out of scope for this release. tests/test_v3_wire_contract.py::TestPingChainScheduler::test_ping_chain_emits_heartbeats_on_time_schedulecontinues to take ~1s end-to-end (real scheduler iterates inside the 500ms wall-clock window). The 0.13.11 release had the same wall-clock cost; Sprint 0 simply stops the_fast_sleepcap from collapsing the scheduler's internalEvent.waitto 1ms and starving the iteration loop.- Sprint 0 reproducibly runs
1237 passed, 7 skipped, 29 warningson the full suite underpytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml:coverage.xml --cov-fail-under=0. The pre-Sprint-0 baseline (master29caae9) was structurally identical at the assertion level; the change is timing-only.
pyproject.toml— newmarkers = ["slow_sleep: opt out of the conftest autouse time.sleep cap"]entry under[tool.pytest.ini_options]. Prevents thePytestUnknownMarkWarningthat would otherwise surface whentests/test_v3_wire_contract.pydecoratesTestPingChainSchedulerwith@pytest.mark.slow_sleep.- The Codecov badge in
README.mdwill now report the real combined coverage on master. Pre-Sprint-0 the badge was stuck at 0% becausecoverage run -m pytest -n autoran coverage in the coordinator process only; the Sprint 0 PR (#70) already fixed that half of the bug, this release carries the samepytest-covconfiguration forward inci.yml(--cov=src/nullrun --cov-branch --cov-report=xml:coverage.xml --cov-report=term). Codecov's per-commit 0.13.12 patch coverage should land above the.codecov.yml70% patch target.
- No SDK public API change. No wire-format change. No backend migration required. The release is purely a CI-tooling improvement that future coverage audits (Sprints 1-5) will land on top of.
- Pre-Sprint-0 instability under
pytest-cov + xdist:test_status.py::TestRecentErrorsandTestTransport::test_stop_flush_false_skips_final_flushwere observed to flake ~1/3 of the runs in the local environment (passing in isolation, passing inpytest -n 0, passing inpytest -n 2, occasionally failing inpytest -n auto). Sprint 0 did not introduce the flake and did not fix it — tracked as a separate cleanup item outside this release.
Drift-fixes release. Closes the SDK-side items on docs/drift.md (2026-07-04); no on-wire breaking change — backends on 1.0.0 keep working unchanged.
- Idempotency-key propagation to
/trackv3 single-event — newnullrun.context._server_minted_idempotency_key_var+get_/set_/reset_/clear_server_minted_idempotency_keyhelpers._capture_server_minted_execution_idnow also readsresponse["operation_id"](which equals the/checkidempotency_keyperruntime.py:1260);_enrich_eventstamps it ontowire_eventforllm_call;_build_v3_track_payloadpropagates it onto the v3/trackbody with a contextvar fallback for tests and direct callers. Without this, transport-level retry on the same event either 503'd withRESERVATION_NOT_FOUND(reservation key DEL'd after first consume per CLAUDE.md §25) or double-billed the underlying budget.
runtime.pymodule docstring now distinguishes SDK-side transport failure (network / 5xx / breaker open → fail-OPEN on the/checkpath) from wire 4xx/5xx that names an enforcement failure (BUDGET_REDIS_UNAVAILABLE→ 402 fail-CLOSED,RATE_LIMIT_REDIS_UNAVAILABLE→ 503 fail-CLOSED). The previous README claim "Fail-OPEN na infrastructure failures" was conflating the two — the SDK code is now correctly documented in the docstring; the README rewrite is tracked underdrift.mdP0-1 (deferred to a separate doc PR).
- Wire
status_codepreserved on every decision exception —NullRunBlockedException,NullRunBudgetError,NullRunChainError,NullRunWorkflowInactiveError,NullRunConsumeOverbudgetErrornow all acceptstatus_code: int | None = None._parse_v3_error_envelopepopulates it fromresponse.status_codefor every branch (402 budget, 403 workflow/chain cross-org, 422CONSUME_OVERBUDGET, 503RATE_LIMIT_REDIS_UNAVAILABLE, ...). FastAPI exception handlers readingexc.status_codepreviously gotNone/ 500 for budget blocks because the backend's 402 was lost in the constructor chain. - Patch-coverage gap from 0.12.2 closed —
tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow(3 tests) drivesNullRunRuntime.check_workflow_budgetinsidewith chain(...)and exercises thecache_enabled/ cache-hit / cache-miss / cache-bypass-via-env branches inruntime.py:1287-1310that were previously uncovered (was dragging codecov/patch below the 70% floor on PR #52).
tests/test_drift_fixes_2026_07_04.py— 15 new tests: 5 idempotency-key contextvar lifecycle + payload-shape, 8 status_code on every decision exception, 2 fail-CLOSED on wire 503RATE_LIMIT_REDIS_UNAVAILABLE. All pass on the 0.13.0 source.tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow— 3 runtime-level chain-mode cache tests as described above.
- New
docs/drift.mdrecords the six P0 + P1 items that turned up during pre-publish review of 0.12.2 (idempotency-key wiring, status_code on exceptions, fail-CLOSED honesty, plus four P0/P1 README issues that are deferred to a README rewrite PR and explicitly NOT in this release).
Bug-fix release. Two related correctness fixes layered on top of 0.12.1; no wire-format change.
- BUG #4 —
/checkexecution_id:check_workflow_budget()now sends a freshuuidv7as theexecution_idfield on every call, instead of reusingworkflow_id. The backend'sgate_reserve_v3overwrites the field with its own server-minted value on the response, but the previous behaviour could confuse the v3 reservation binding on/trackwhentrack_single()reached the backend — the same root cause as the four gaps 0.12.1 closed, from the client-side placeholder angle. (CLAUDE.md §29 §24 ownership.) - BUG #5 — chain-mode gate thrash: new
nullrun.runtime._GATE_CACHE(5s TTL, keyed on(workflow_id, chain_id, model)) collapses consecutive/gatecalls from insidewith chain(...)to a single roundtrip, avoiding 100 /gate calls per 100-step agent loop. Single-shot (Hard mode) callers bypass the cache — the gate legitimately flips allow→block between consecutive calls there, and a stale "allow" would leak a budget-exhausted call through. Opt-out viaNULLRUN_GATE_CACHE_DISABLE=1for callers that want the legacy always-roundtrip behaviour (e.g. live smoke tests perdocs/runbooks/budget-blue-green-smoke.sh).
- 158 lines of contract tests in
tests/test_v3_wire_contract.py:TestGateExecutionId(per-call uniqueness + uuidv7 format validation) andTestGateCache(5 cache invariant + opt-out cases).
__version__bumped from 0.12.1 to 0.12.2.
Bug-fix release. The v0.12.0 changelog claimed the SDK propagates the server-minted execution_id from /check to /track but the wiring was never shipped — the SDK still sent client-supplied ids on /track/batch and ignored reservation_id on /check responses (audit fix per memory sdk-v3-migration-gaps).
This release closes the four gaps documented in docs/sdk-v3-migration-gaps.md:
check_workflow_budget()now readsresponse["reservation_id"]and stores it on a contextvar (nullrun.context._server_minted_execution_id_var).- New helpers
set_server_minted_execution_id/get_server_minted_execution_id/reset_server_minted_execution_id+ a paired_server_minted_reservation_attimestamp for the 295s TTL guard. _enrich_eventstampsexecution_idonto the /track payload when the captured reservation is fresh, and drops it (clearing the capture) once past the safety window — prevents forwarding a doomed id that would 503 on /track per CLAUDE.md section 33._route_trackroutesllm_callevents to the v3/api/v1/tracksingle-event endpoint viaTransport.track_single()so backendgate_consume_v3validates the consume-vs-reserve + epsilon invariant (CLAUDE.md section 25). Span / tool events keep using the legacy/api/v1/track/batch.NULLRUN_V3_TRACK_DISABLE=1opt-out forces everything through the legacy batch path (backends still on v1/v2).
nullrun.context._server_minted_execution_id_var+nullrun.context._server_minted_reservation_at_var+ 6 helpers (get_/set_/reset_/clear_).nullrun.runtime._capture_server_minted_execution_id(response)— defensive UUID parse + warn-on-malformed.nullrun.runtime._route_track(wire_event)— dispatches to single-event /track or batch /track/batch.nullrun.runtime._build_v3_track_payload(event, reservation_id)— maps an enriched event onto the v3 /track wire schema.- 27 contract tests in
tests/test_v3_server_minted.pycovering contextvar hygiene, capture defence-in-depth, _enrich_event age threshold, _route_track dispatch, and end-to-end /gate -> /track round trip.
__version__bumped from 0.12.0 to 0.12.1 (post-release integrity fix — the v0.12.0 wiring never shipped before this).
- SDK no longer treats the /check
reservation_idfield as decorative. Each LLM-call track event now carries the server-minted uuidv7 the backend minted, so v3gate_consume_v3can find the matchingreservation:{execution_id}Redis key (300s TTL). - LLM-call events now POST to
/api/v1/track(v3 single-event) instead of/api/v1/track/batch. This exercises the consume-vs-reserve invariant that the batch path silently skipped (regression of the v1/v2monthly_costcounter — see CLAUDE.md section 0 G1).
Server-minted execution_id default ON. Per CLAUDE.md section 24, every /check now mints a server-side uuidv7 execution_id. The SDK no longer needs to generate its own; the response carries the server-minted id which propagates to /track. This is the SDK_MIN_VERSION for the v3 rollout - older SDKs still work for v1/v2 endpoints but should upgrade.
Integrity note (2026-07-04): the propagation claim in this entry was correct in intent but the actual wiring was not shipped in 0.12.0. See 0.12.1 above for the closing fix.
nullrun.uuid7module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.nullrun.capabilitiesmodule - probe_capabilities(), parse_capabilities(), validate_sdk_version(). Wired into nullrun.init().
- version bumped from 0.11.0 to 0.12.0.
nullrun.uuid7module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.nullrun.capabilitiesmodule - probe_capabilities(), parse_capabilities(), validate_sdk_version(). Wired into nullrun.init().
- version bumped from 0.11.0 to 0.12.0.
Patch on top of 0.9.0. Unifies the LLM-call fingerprint scheme so the
dedup LRU at runtime.track() can collapse sibling emissions from the
httpx transport and the LangChain callback for the same real call.
-
Double-emission of llm_call events. Pre-0.9.1 the httpx transport (
NullRunSyncTransport._emit) and the LangChain callback (NullRunCallback.on_llm_end) each computed their own_fingerprintfrom different inputs —sha256(host|status|body)vssha256(json({path:"langchain_callback", run_id, response_id, model, provider, invocation_params})). The two fingerprints never collided, so the dedup LRU atruntime.track()could not collapse the two emissions for the same call. On a typicalapp.invoke()with 6 LLM calls the backend saw ~12llm_callevents on the wire (2 per real call), doublingllm_call_countand skewingcost_eventsaggregates.Post-fix both observers call the same helper
_fingerprint_for_llm_call(model, provider, response_id)with the three signals reachable from every observation path:- httpx transport reads
modelandidstraight out of the OpenAI-style response body (payload["model"],payload["id"])._openai_extractornow also carries"id"on its return so the transport has it without re-parsing the body. - LangChain callback reads
modelfrominvocation_params/response.llm_output["model_name"]andidfromresponse.llm_output["id"]/response.id/ the generation's AIMessage.id/response.response_metadata["id"]— all four locations are populated by langchain-openai 1.x for OpenAI chat completions.
When any of the three signals is missing, the helper falls back to the empty string on that slot; the resulting fingerprint is still deterministic for the call, just less specific. A missing
id(custom chat-model wrappers that don't surface it) still collapses the two observers via the model+provider combination. - httpx transport reads
tests/test_unified_fingerprint.pypins the new contract: deterministic fingerprint for identical inputs, distinct fingerprints for distinct inputs, the httpx transport calls the helper with values extracted from the response body, the LangChain callback produces the SAME fingerprint for the same LLM call when reading the chat-completion id from any of the four known langchain locations.tests/test_llm_call_metadata_flags.pyupdated to match the new extractor shape (usage["id"]is now present alongsideusage["model"]).
No public-API break. No behavior change for callers whose
instrumentation already populates model correctly.
Wire-protocol v3 alignment with the backend's Sprint 6 v1 cut
(CLAUDE.md v3.4). The previous SDK shipped pre-v3 endpoints
(/api/v1/gate, /api/v1/execute, /api/v1/track/batch) without
the X-NULLRUN-PROTOCOL header that the v3 backend requires as a
fail-CLOSED pre-check — every signed POST was rejected with HTTP 400
PROTOCOL_HEADER_REQUIRED. This release aligns the SDK with the v3
wire contract and adds the missing soft-mode / chain / heartbeat /
cancel / budget-estimate surface.
X-NULLRUN-PROTOCOL: 3is now mandatory on every signed POST. The backend'sproxy/http/gate/protocol.rsmiddleware rejects requests without the header with HTTP 400 + error_codePROTOCOL_HEADER_REQUIREDBEFORE the gate pipeline runs. Pre-v3 SDKs that don't send it will get 400 on every request, including/auth/verify(which is unsigned but goes through the same protocol guard via the_post_auth_with_retrypath).- Routed through the new centralised helper in
nullrun.transport._protocol_header_value()so a future bump is a one-line change. - The header is set in
_build_signed_headers()(covers/gate,/execute,/track/batch,_refetch_credentials) AND inlined in the four call sites that build their own headers dict (track/batch, gate, execute, WS handshake, auth/verify refresh). Theruntime._auth_headers()helper was extended to include the header for the three directself._client.get/postcall sites (_post_auth_with_retry,_fetch_remote_state,get_org_status).
- Routed through the new centralised helper in
-
Transport.check_v3(request)— POST /api/v1/check. The v3 replacement for/gate. Adds three optional wire fields (CLAUDE.md §16):chain_id(UUID v4) — pairs withchain_opfor soft-mode budget enforcement (CLAUDE.md §5, §6).chain_op("start"/"continue"/"end"/"auto") — state-machine transitions; absent defaults to auto-register.idempotency_key— replays return the original decision.stream: bool— hints the backend whether streaming is expected (no wire-enforced behaviour change yet).- The response carries a server-minted
execution_id(§24); callers MUST NOT treat the request'sexecution_idas authoritative.
-
Transport.track_single(request)— POST /api/v1/track. Single-event consume path with the CONSUME_SCRIPT invariant (actual_cost <= reserved_cents + epsilon_cents, CLAUDE.md §25). Returns 422 CONSUME_OVERBUDGET when the call's actual cost exceeds the reservation by more than epsilon. The reservation is NOT silently re-reserved (ADR-005). -
Transport.cancel(execution_id, reason=None)— POST /api/v1/cancel. Idempotent viacancel:{execution_id}SETNX (CLAUDE.md §23). Repeated calls return 200 OK without side effects. Surfaced asNullRunRuntime.cancel_execution()for the ergonomic wrapper. -
Transport.heartbeat(chain_id)— POST /api/v1/heartbeat. AtomicEXPIRE chain:{org}:{chain_id} 300with SETNX-based dedup viaheartbeat:{chain_id}:{ts_floor_30s}(CLAUDE.md §26). Cadence: wall-clock 30s (configurable 10-120s). Skew tolerance ±5s. -
Transport.chain_end(chain_id)— POST /api/v1/chain/end. Explicit chain close (CLAUDE.md §6). Idempotent — unknown chain_id is a no-op 200. Surfaced asNullRunRuntime.chain_end(). -
Transport.approximate_budget(organization_id=None)— GET /api/v1/budget/approximate. UI-only budget estimation (CLAUDE.md §17). Returns 503BUDGET_DATA_UNAVAILABLEwhen ALL sources fail — NEVER returns 0 (the dashboard must not display "≈ $0 spent" when data is missing). Surfaced asNullRunRuntime.approximate_budget(). -
Transport._parse_v3_error_envelope(response, endpoint)— ACTIVE error envelope parser. Maps the backend'serror_codefield to typed SDK exception subclasses (PROTOCOL_TOO_OLD →NullRunProtocolError, CONSUME_OVERBUDGET →NullRunConsumeOverbudgetError, CHAIN_CROSS_ORG →NullRunChainError, WORKFLOW_INACTIVE →NullRunWorkflowInactiveError, etc.). Coexists with the frozen_parse_error_envelopefrom 0.6.0 — the frozen helper remains for the audit/contract test surface. -
Chain context (
nullrun.context). New contextvars_chain_id_var+_chain_op_varplus the public API:chain(chain_id, op="start")— contextmanager (mirrorsworkflow()).get_chain_id()/set_chain_id()— manual setters.get_chain_op()/set_chain_op()— chain-op enum setter.- Reachable from the top-level
nullrunnamespace via_LAZY_EXPORTS(consistent withworkflow/set_call_context).
-
NullRunRuntime.ping_chain(chain_id, interval=30.0)— time-based heartbeat scheduler (CLAUDE.md §26). Returns astop()callable. The daemon thread emits POST /heartbeat on a wall-clock schedule (time.monotonic), not on chunk-count. Pre-fix chunk-based heuristic (every 50 chunks) had two pathological cases — slow chunk rates left chains idle, bursty traffic wasted heartbeat budget on a fresh chain. Cadence clamped to the 10-120s policy range per §26. -
**
NullRunRuntime.cancel_execution(execution_id, reason=None)chain_end(chain_id)+approximate_budget()** — ergonomic wrappers around the newTransportmethods.
NullRunProtocolError(NR-P001) — PROTOCOL_TOO_OLD / PROTOCOL_TOO_NEW.NullRunChainError(NR-CH001) — CHAIN_MAX_DURATION_EXCEEDED / CHAIN_CROSS_ORG / CHAIN_ORG_MISMATCH / CHAIN_NOT_FOUND / CHAIN_EXPIRED. Carrieschain_idandbackend_codefor diagnostic clarity.NullRunConsumeOverbudgetError(NR-O001) — CONSUME_OVERBUDGET. Carriesreserved_cents,max_allowed_cents,actual_cost_cents,epsilon_centsso callers can reconcile manually without re-parsing the message string.NullRunWorkflowInactiveError(NR-W004) — WORKFLOW_INACTIVE (CLAUDE.md §4 fail-CLOSED on soft-deleted workflow + active key, wired in Sprint 6 v1 12.2).NullRunRateLimitRedisError(NR-R002) — RATE_LIMIT_REDIS_UNAVAILABLE. Fail-CLOSED per §4 enforcement table (aggregate rate limit = authoritative gate).
All five are subclasses of either NullRunInfrastructureError
(protocol / rate-limit-redis) or NullRunDecision (chain /
overbudget / workflow-inactive) so existing except NullRunError: clauses keep matching.
check_workflow_budget()forwards chain context. When the caller has wrapped the gate inwith chain(chain_id, op="start"), the SDK now includeschain_id+chain_op+idempotency_keyin the /gate (or /check) payload so the backend's Lua RESERVE_SCRIPT can run the soft-mode branch (CLAUDE.md §5). Absent chain context, behaviour is identical to 0.10.0 (single- shot Hard). Wire-shape is additive — legacy callers see no payload change.Transport.check()(legacy /gate) forwards chain_id / chain_op / idempotency_key / stream when present. Same additive contract — missing keys are omitted, not nulled._auth_headers()includesX-NULLRUN-PROTOCOL. Affects_post_auth_with_retry,_fetch_remote_state,get_org_status.runtime._post_auth_with_retrynow passes headers. Pre-fix the helper didself._client.post(url, json=json_body)with no headers — the wire had noX-API-Key, no Authorization, and no protocol header, which the backend's protocol + CSRF middlewares reject. Now it passesself._auth_headers().
- All five new
Transportmethods are additive. Existingcheck()/execute()/ batch_send_batch_with_retry_infopaths keep their previous signatures. - The five new exception classes are subclasses of the existing
public hierarchy (
NullRunError←NullRunDecision/NullRunInfrastructureError); existingexcept NullRunError:clauses keep matching. - The wire-protocol header is mandatory ONLY when connecting to a v3-or-later backend. Older pre-v3 backends ignore the header — no payload-level break.
- The v3
gate_reserve_v3Lua script (CLAUDE.md §33) is on blue-green deployment per §19 — the SDK must work against BOTH the legacycost/reservation.rs::reserve_budget_atomic(v1/v2 default) AND the v3 Lua path. The newcheck_v3/track_singlehelpers are the v3 path; the legacycheck/ batchtrackcontinue to hit the v1/v2 default. Operators flip the backend flagNULLRUN_RESERVE_V3_ENABLED=1to migrate; SDKs on 0.11.0 work in both modes. - Soft-mode budget enforcement requires the backend's
NULLRUN_SOFT_LIMIT_ENABLED=1flag (CLAUDE.md §0 G3). Without it, chain_id is forwarded but the backend still treats soft passes as hard blocks. This is the controlled migration state noted in §0.
(Unreleased — work-in-progress; will be backfilled once 0.11.0 ships.)
Server-derived coverage replaces the in-process counter dicts.
Counter-bump helpers are gone; every llm_call span now carries
metadata.tracked and metadata.streaming_skipped flags so the
backend's coverage_pct query can compute coverage from span
metadata alone. Adds nullrun.shutdown() for clean WS close on
script exit.
NullRunRuntime.coverage_report()removed.NullRunRuntime._coverage_seen/_coverage_tracked/_coverage_streaming_skippedinstance attributes removed.NullRunRuntime.start_coverage_reporter()daemon thread removed (no longer called frominit())._safe_bump_coverage/_bump_streaming_skippedhelpers removed fromnullrun.instrumentation.auto.llm_callwire shape:metadata.tracked: boolandmetadata.streaming_skipped: boolare now authoritative; the separatecoverage_reportevent is dropped.
nullrun.shutdown(timeout=2.0): sends a clean WebSocket close frame and drains in-flight events. Long-running scripts that exit viasys.exit()previously let the kernel RST the TCP socket, which the backend logged as WARN "Connection reset without closing handshake". Registeringnullrun.shutdownin anatexithandler eliminates the noisy log. No-op ifinit()was never called.
tests/test_llm_call_metadata_flags.pypins the new contract: everyllm_callspan carriesmetadata.trackedormetadata.streaming_skipped. Coverage is now an out-of-process concern.tests/test_coverage_report.pyandtests/test_coverage_seen_httpx.pyremoved — coverage is no longer an SDK-side concept.
Additive patch on top of 0.8.2. Closes the same silent zero-billing class of bug 0.8.2 closed on the httpx path — but on the langgraph callback path and the init-ordering hazard that 0.8.2 didn't reach. Promotes the missing-model wire failure from WARN to fail-LOUD.
- langgraph callback model extraction.
_extract_model_from_responsenow consultsresponse.llm_outputFIRST. langchain-openai 1.x puts the date-suffixed model id (e.g.gpt-4.1-mini-2025-04-14) onLLMResult.llm_output, while the AIMessage insidegenerations[0][0].messageleavesresponse_metadataempty. The previous chain led withresponse_metadata, so every OpenAI-via-LangChain 1.x call silently zero-billed. Also adds an "any key containing model" sweep insidellm_outputfor non-OpenAI wrappers (proxies, custom chat models). - Init-ordering hazard for
patch_httpx. The class-level__init__wrap only catches Clients created AFTER it is installed. Users that buildChatOpenAI(...)beforenullrun.init(api_key=...)end up with a pre-existinghttpx.Clientthat the patch never sees.patch_httpxnow sweepsgc.get_objects()once at install and wraps any pre-existingClient/AsyncClientwhose transport isn't already aNullRun*Transport. Idempotent via the existing class-level marker. - Fail-LOUD missing-model wire tag.
runtime.track()now escalates the missing-model warning fromlogger.warningtologger.error, bumps adropped_llm_call_no_modelruntime counter for dashboards, and tags the wire event with__missing_model: Trueso the backend'sinto_track_requestgate can reject with HTTP 422 instead of silently recording a zero-cost call. The event is still sent (not fail-CLOSED) so the backend can audit; the flag is wire-private and stripped before persisting. Activated only forllm_call; other event types are silent.
tests/contract/test_llm_call_model_wire.pypins all three invariants: 7 unit tests for_extract_model_from_response(every known langchain shape + non-OpenAI wrappers + empty-string fallthrough), 3 tests fortrack()'s missing-model wire tagging (ERROR + counter +__missing_modelflag + non-llm_call silence), and 2 tests for the eager-wrap sweep (pre-existing Client gets wrapped, idempotent on re-patch).
Additive patch on top of 0.8.0. No public-API break. Continues the 0.8.0 wire-format audit with two regressions that were caught on review and one contract test that pins the post-2026-06-27 backend schema so a future rename can't silently break the SDK.
track_coverage()emits counter dicts underevent.metadatainstead of the event top level. Pre-fix the per-hostseen/tracked/streaming_skippeddicts sat at the event root, where serde silently dropped them —SdkTrackRequestuses explicit fields with no#[serde(flatten)]catchall, so unknown keys are discarded. The dashboard'slast_coverage_pctwas permanentlynullbecause every coverage report landed with emptyseen/tracked/streaming_skippedJSONB columns. Pin:tests/test_coverage_report.py::test_track_coverage_emits_wire_shape_with_metadata_nesting.- Request-body model fallback in
NullRunSyncTransport._emit. When the response body extractor returnsNoneformodel(OpenAI Responses API, Anthropic streaming edge cases),_extract_model_from_request_bodyreads the model string the user embedded in the request body viaChatOpenAI(model="gpt-4.1-mini"). Without this every such call was zero-billed — backendunwrap_or("default")+DEFAULT_RATE≈ $0/call. Unit-tested intests/test_model_fallback.py.
tests/test_batch_response_parsing.pypins the post-2026-06-27BatchTrackResponseshape (actions: Vec<ActionTaken>,messages: Vec<String>) and documents that the legacyactions_taken: Vec<String>field is intentionally dropped in 0.8.0. Regression test so a future backend rename can't silently break the SDK.
SDK↔backend wire-format audit. Closes a class of silent-fail-OPEN
path that was sending model=None (or model="unknown") on
/track for many LLM-vendor paths — every such event cost the
backend a model_pricing lookup that returned no row, fell
through to DEFAULT_RATE (~$30/M), and emitted a fallback warning
the operator couldn't reproduce because the offending observation
was buried in another package's telemetry.
No public-API break. No behavior change for callers whose
instrumentation already populates model correctly. Pure wire-
payload hygiene.
-
NullRunRuntime.track()stripsNonevalues from the wire payload. Pre-0.8.0 the runtime forwarded every key inenrichedexcept those in_WIRE_STRIP_FIELDS, including keys whose value wasNone. Putting{"model": null}on the wire triggered backendunwrap_or("default")and a fallback warning. Backend handles a missing key as well asnull; droppingNonehere keeps the diagnostic signal loud (the newWARN track(): llm_call event missing 'model' fieldfires on missing-key, which is what we want operators to see) instead of silent (the JSON-null case). Activated only forllm_callsospan_start/span_end/tool_calltraffic doesn't pollute logs. -
All four instrumentation paths now extract
model/providerfrom the response object as a fallback, not just frominvocation_params/self.model. When langchain 1.x stopped forwardinginvocation_paramstoon_llm_end, every LangChain-callback track event carriedmodel="unknown"and the backend cost pipeline fell through toDEFAULT_RATE. The same shape applied to llama-index mock providers and autogen subclasses that don't expose a.modelattribute. New fallback chain (per path):NullRunCallback.on_llm_end(langgraph):invocation_params.model_name→response.response_metadata['model_name']→ AIMessageresponse_metadata→response.llm_output['model_name']→response.model_name/response.model→'unknown'(truly last resort, not the common case).extract_from_event(llama_index):event.response.model→event.response.raw.model→usage['model']. Mock providers and adapter-style ChatResponse objects now ship a real model id on the wire.on_messages(autogen):self.model→result.model. OpenAI's response carries the actual model id (may differ from request if the server resolved an alias) — this is the right value._emit_from_span(auto, openai-agents):span['model']→usage['model']→span['response_metadata']['model_name']. Some custom tracer configs leavespan['model']empty; the other two sources usually have it.
-
Two shared helpers added to
instrumentation/langgraph.py:_extract_model_from_responseand_extract_provider_from_response. These mirror the same best-effort pattern_get_finish_reasonalready uses, so we have a single "best-effort read from the response object" idiom across the module. The autogen / llama_index / agents paths duplicate the walk inline (the response shapes differ too much to share a single helper), but the ordering matches: official-attr → metadata → usage → wrapper-attr.
logger.warning("track(): llm_call event missing 'model' field — backend will fall back to DEFAULT_RATE. event=...") is now emitted from NullRunRuntime.track() whenever an llm_call event reaches the wire without a model field. This log is the single signal an operator needs to reproduce "which observation (httpx / langchain callback / manual track / agents tracer / requests) produced an llm_call without model set". Activated only for llm_call; other event types are silent. Log destination is whatever the host application configures for the nullrun.runtime logger.
- Tests covering the new helper chain will land in a follow-up
release once the wire-format audit findings are stable. The
fix is a defensive best-effort read; the existing
test_instrumentation_*suites already pass against the updated paths.
Additive patch on top of 0.7.7. Converts two silent fail-OPEN footguns
into explicit DeprecationWarning / RuntimeError. No behavior
change for callers who don't touch the deprecated surface.
NullRunRuntime.start_recording()andNullRunRuntime.stop_recording()now emitDeprecationWarning. They have been silent no-op stubs since Sprint 2.1 (0.4.0). Decision history is available via the backend dashboard at/control-center/decision-history. Both methods will be removed in 0.9.0.- Setting
NULLRUN_USE_GRPC=1now raisesRuntimeErrorat SDK init instead of silently falling back to HTTP with an info log. gRPC transport remains on the roadmap but is not yet implemented. Unset the env var to use HTTP. See https://docs.nullrun.io/reference/sdk-api#transport
- Replace
runtime.start_recording(workflow_id, metadata=...)with a dashboard navigation ornullrun.status()introspection. - Remove any
NULLRUN_USE_GRPCenv var from deployment configs (Docker compose, k8s manifests, systemd units). - Catch
RuntimeErrorat SDK init if you want to keep the env var as a feature flag — but the recommended path is to unset it.
Additive patch on top of 0.7.7. Converts two silent fail-OPEN footguns
into explicit DeprecationWarning / RuntimeError. No behavior
change for callers who don't touch the deprecated surface.
NullRunRuntime.start_recording()andNullRunRuntime.stop_recording()now emitDeprecationWarning. They have been silent no-op stubs since Sprint 2.1 (0.4.0). Decision history is available via the backend dashboard at/control-center/decision-history. Both methods will be removed in 0.9.0.- Setting
NULLRUN_USE_GRPC=1now raisesRuntimeErrorat SDK init instead of silently falling back to HTTP with an info log. gRPC transport remains on the roadmap but is not yet implemented. Unset the env var to use HTTP. See https://docs.nullrun.io/reference/sdk-api#transport
- Replace
runtime.start_recording(workflow_id, metadata=...)with a dashboard navigation ornullrun.status()introspection. - Remove any
NULLRUN_USE_GRPCenv var from deployment configs (Docker compose, k8s manifests, systemd units). - Catch
RuntimeErrorat SDK init if you want to keep the env var as a feature flag — but the recommended path is to unset it.
Additive patch on top of 0.7.6. Fixes the /gate pre-flight so the
backend can compute projected_cost and tool_block decisions from
real per-call data instead of the previous fake "budget-precheck"
sentinel and empty tool list. No breaking changes — new helpers
default to None / empty so existing call sites keep working.
nullrun.set_call_context(model=..., tools=[...])— per-call context the SDK forwards to/gateso the backend can enforce budget tiers and tool-block on real values.import nullrun with nullrun.workflow(name="support-bot"): nullrun.set_call_context( model="claude-sonnet-4-6", tools=["shell.run", "code.eval"], ) @nullrun.protect def chat(message: str) -> str: return agent.run(message)
model(optional) — LLM model name. Backend uses it to look up the per-model rate fromtool_pricing(Postgres) soprojected_costmatches what/trackwill compute from real token counts. Defaults toNone(backend falls back toclaude-sonnet-4default rate).tools(optional) — list of tool names the call intends to use. Backend matches each against the workflow's effectiveblocked_toolsaggregate and returnsblockon any match.Noneleaves whatever was previously set;[]clears.nullrun.get_call_model()andnullrun.get_call_tools()are the read-side helpers (also reachable vianullrun.context.get_call_model/get_call_tools).
-
/gatepre-flight no longer sendsmodel="budget-precheck". Pre-0.7.7 every SDK/gatecall for any workflow with a budget was hard-blocked because the runtime hard-coded the literal string"budget-precheck"as the model. The backend'sPolicyEvaluationGraph.evaluate()stub treated any syntheticcost_limitrule with score > 0.8 asBlock(seebackend/src/policy/graph.rs:448-462,backend/src/proxy/http/gate/internal.rs:619-628), so the pricing lookup never landed on a real model and the rule fired with the wrong score. Now the runtime forwards the model fromset_call_context(model=...)(orNonewhen unset), and the backend'scalculate_projected_costfalls through to the default rate cleanly. -
/gatepre-flight now forwards the per-calltoolslist.Transport.checkpreviously dropped thetoolskey from the wire payload, so even when the user calledset_call_context(tools=[...])the backend'sgate/internal.rs::check_tool_blockhad nothing to match against. The transport now propagatestoolswhen the runtime sets it;[]vs missing-Noneare distinguished on the wire (pergate/internal.rs::check_tool_blockdoc-comment — "no tools will be called" is different from "I did not tell you what tools").
-
tests/test_gate_real_path.py(new, 226 lines) — regression test pinning the fix. Three classes:TestGateRealPathRegression— default request now returnsallow(not the old blanket block on the syntheticcost_limitrule), wire payload contains nopolicy-Nresidue from the old graph plumbing, and a realdecision="block"still raisesWorkflowKilledInterrupt(so the fix didn't accidentally remove the real-block path).TestSetCallContext—set_call_context(model=...)flows into the wire body,set_call_context(tools=[...])flows into the wire body, no-context means notoolskey at all (not[]), andset_call_context(tools=[])clears a previously-set tool list.TestPackageExports— the new helpers are reachable fromnullrun.*.
-
tests/conftest.py—reset_runtimefixture now also clears_call_model_varand_call_tools_varso a test'sset_call_context(...)doesn't leak into the next test's wire payload.
Additive patch on top of the 0.7.0 thin-client refactor. Brings a FastAPI integration, a default user-facing message catalog, and small transport consistency fixes. No breaking changes.
-
nullrun.integrations.fastapi— one-line FastAPI integration that turns everyNullRunDecision/NullRunInfrastructureErrorthrown by@nullrun.protectendpoints into a clean JSON response with the right HTTP status code. No per-endpointexceptblocks required.from fastapi import FastAPI import nullrun from nullrun.integrations.fastapi import install nullrun.init(api_key="nr_live_...") app = FastAPI() install(app) @app.post("/chat") @nullrun.protect def chat(message: str) -> str: return agent.run(message)
Response shape:
{ "error_code": "NR-B004", "user_message": "You've reached the usage limit...", "category": "decision" }HTTP status mapping:
NR-B004(budget),NR-L001(loop),NR-R001(rate) → 429 with optionalRetry-After.NR-T001(tool blocked),NR-X001(generic block) → 403.NR-W003(paused) → 503 withRetry-After.NR-W002(killed) → 503.WorkflowKilledInterruptis aBaseExceptionsubclass so Starlette'sadd_exception_handlerrefuses it; the integration uses an ASGI middleware instead (hybrid pattern documented in the module docstring).- All
NullRunInfrastructureErrorsubclasses → 503 (failure is on our side, not the user's).
-
nullrun.messages— default user-facing message catalog. EveryNR-*error code has an English default message owned by NULLRUN, not by customer code, so a Customer Support Bot hitting a budget cap shows the same wording across every NullRun-backed application.format_user_message(exc)— render an exception as a user-facing string.set_user_message(code, text)— per-process override for branded variants in a single deployment.get_user_message(code)— raw lookup.reset_overrides()— clear all overrides (for tests).
-
Transport._send_batchcanonical JSON serialization — route the/track/batchbody through_signed_request_bodyfor consistent compact-separator serialisation (,/:). HMAC itself is unaffected (it hashes the bytes either way), but consistent serialisation removes a special-case from the wire-format contract tests. Docstring invariant: "All three signed POST call sites MUST serialise via this helper." -
Transport._send_batchactions response handling — backend renamedBatchTrackResponse.actions_taken(debug names) →BatchTrackResponse.actions(ActionTakenstructs with human-readable strings moved tomessages). Single/trackstill usesTrackResponse.actions_taken. We read both for forward-compat; per-elementtry/exceptso one malformed entry doesn't abort the whole loop. -
pyproject.tomlmetadata — long-form description with keyword coverage for search,Maintainer:populated viamaintainers = [...], expanded classifiers (OS Independent/ Linux / Windows / macOS, Python 3.13,CPython,Security,AI,WWW/HTTPtopics), project URL expander (Discussions / Releases / Source / Security Policy).
tests/test_messages.py(new, 282 lines) — catalog completeness (every NR-* code inexceptions.pyhas a default message), override / reset behavior, render path.tests/test_integrations_fastapi.py(new, 289 lines) — HTTP status mapping per error code, response shape, ASGI middleware path forWorkflowKilledInterrupt, hybrid (exception handlers + middleware) composition.tests/test_decision_split.py(new, 199 lines) — pins the decision / infrastructure error split.- Updates to
tests/test_runtime.py,tests/test_extractors.pyreflecting transport canonical-JSON + actions-renamed changes.
SDK is now a thin client. All enforcement decisions arrive from the
backend via /api/v1/gate and /api/v1/execute. Local policy
enforcement, its dataclass, and its hardcoded thresholds are removed.
Removed:
class Policy,Policy.default_local(),Policy.strict_local(),Policy.from_dict()(was atnullrun.runtime.Policy)NullRunRuntime.policypropertyNullRunRuntime(policy=...)constructor kwargNullRunStatus.active_policy,.fallback_policy,.fallback_reason,.last_policy_fetch,.last_policy_fetch_age_secondsfieldsTransport.fetch_policy()methodTransport.clear_policy_cache()methodFallbackMode.CACHEDenum value (gate-decision fallback)- Local loop/rate detectors:
LoopTracker,RateTracker,LocalDecisionclasses NullRunRuntime._local_check(),_loop_tracker,_rate_trackerinstance attrs_local_loop_threshold,_local_rate_limitinstance attrs (hardcoded 6/1000)CachedDecision,PolicyCachetransport classes (tied to the removed CACHED fallback mode)NULLRUN_FALLBACK_MODEenv varNULLRUN_POLICY_FAIL_OPENenv var (no longer needed — backend is authoritative)NullRunRuntime._fetch_policy()method (no local policy fetch on init)- WS
on_policy_invalidatedcallback (no local policy to invalidate)
Migration:
If you need to display policy values in a UI, fetch them directly
via GET /api/v1/orgs/{org_id}/policies. The SDK no longer mirrors
them.
Audit: Drift D-01 from 2026-06-26 SDK↔backend audit
(PolicyResponse lacked fields SDK expected; local defaults silently
widened limits).
Transport._atexit_flush_safe is now a no-op that emits a single
DEBUG log line. It does NOT persist buffered events to the WAL
anymore — by the time weakref.finalize fires, self._buffer /
self._lock / self._client are already gone, so any attempt to
write them would either no-op or crash. Crash-safety now lives
exclusively in stop() and the context-manager pattern. Callers
who relied on the implicit on-exit WAL flush must switch to:
with nullrun.Transport(api_url=..., api_key=...) as t:
# use t; __exit__ calls stop() which calls _persist_to_wal
...or call t.stop() explicitly before process exit. A DEBUG log
line "Transport finalizer fired without explicit stop(); remaining
events may be lost" is the user-visible signal that events were
dropped.
Additive release — Layers 1, 2, and 3 of the "give the user a chance" design land together. Structured exceptions, a global error hook, and a synchronous runtime snapshot. No breaking changes.
Every public SDK exception now carries a stable, grep-able
error_code (e.g. NR-A001, NR-B002, NR-R001) plus a short
imperative user_action and a retryable flag, so cookbook
examples and Sentry integrations can branch on the code instead
of parsing the message string.
-
NullRunError— structured base for every user-facing SDK exception. Carries four actionable fields:error_code— stableNR-LETTERNNNidentifier (documented per-code indocs/errors/<code>.md).user_action— short imperative next-step hint ("Set NULLRUN_API_KEY", "Verify API key at …", "Retry in 30s — backend is down", …). Empty when there is no actionable step.retryable—Trueonly for transient failures (5xx, network blip, transient auth);Falsefor config, permission, and budget-exhausted (retrying without changing something will just hit the same wall).docs_url— per-code docs page (falls back to thehttps://docs.nullrun.io/errorsindex when the per-code page does not exist yet).cause— optional chainedBaseException.
-
New specialized exception classes (each is a subclass of the existing user-facing class, so existing
exceptclauses keep matching):Class Subclass of error_coderetryableNullRunConfigErrorNullRunErrorNR-C001False NullRunAuthErrorNullRunAuthenticationErrorNR-A001False NullRunBackendErrorNullRunTransportErrorNR-B002True NullRunBudgetErrorNullRunBlockedExceptionNR-X001False NullRunToolBlockedErrorNullRunBlockedExceptionNR-T001False -
Public re-exports —
nullrun.NullRunError,nullrun.NullRunAuthError,nullrun.NullRunConfigError,nullrun.NullRunBackendError,nullrun.NullRunBudgetError,nullrun.NullRunToolBlockedError,nullrun.WorkflowKilledInterruptare now innullrun.__all__and show up indir(nullrun)for discoverability. The legacy types (NullRunBlockedException,NullRunAuthenticationError,WorkflowKilledException,WorkflowPausedException) stay importable via the lazy-export table for back-compat — adding them here would changedir(nullrun)for existing users.
nullrun.on_error(hook)— global error hook. Fires for every structuredNullRunErrorbefore the exception propagates so the call stack is still live. Returns an idempotentunregistercallable.- Skipped for
WorkflowKilledInterrupt(BaseException subclass — kill is a signal, not an error) and for non-NullRunErrorexceptions. - Multiple hooks fire in registration order.
- Hook exceptions are caught and logged at DEBUG — a misbehaving hook cannot break the SDK.
- Zero-cost fast path when no hook is registered
(
has_hooks()short-circuit before any allocation).
- Skipped for
- Backed by
nullrun.observability.error_hooks—register_hook,unregister_hook,emit_error,clear_hooks,STAGES,ErrorContext.
nullrun.status()— synchronous runtime snapshot. Returns a frozenNullRunStatusdataclass (state, version, reason, auth state, policy state, connectivity, workflow state, bounded recent-errors ring buffer).- Four headline states derived automatically:
ok,degraded,offline,misconfigured. - Raises
NullRunConfigError(NR-C004) if no runtime has beeninit()'d — never lazily creates a runtime as a side effect. - Thread-safe — safe to call from the agent loop, the transport flush thread, or a debug console.
- Four headline states derived automatically:
- Backed by
nullrun.observability.status—NullRunStatus,RecentError,WorkflowState,_RecentErrorRing.
docs/errors/— 15 per-code pages (NR-A001..A003,NR-B001..B005,NR-C001/C003,NR-L001,NR-R001,NR-T001,NR-W002/W003) plus aREADME.mdindex. Each page documents the trigger conditions, theuser_action, theretryablehint, and a small reproducer / fix snippet.docs/integration-baseline-2026-06-19.md— pinned baseline for the next integration run.
tests/test_exception_hierarchy.py— pins the hierarchy shape (class roots), the structured fields on every public class, and the five back-compat invariants (exceptclauses keep matching across the new subclasses;WorkflowKilledInterruptis the only public class not catchable byexcept Exception).tests/test_error_hooks.py— registry basics,emit_errorsemantics (fires with both args, swallows hook exceptions, one-bad-hook-isolated, unregister-mid-dispatch is safe),ErrorContextvalidation, theWorkflowKilledInterruptandWorkflowKilledExceptionbypass rules, and that the globalnullrun.on_errorshim is wired through.tests/test_status.py— no-runtime raisesNR-C004, with-runtime snapshot is frozen / equality-stable, key prefix is truncated to 10 chars, state derivation (ok / degraded / misconfigured), recent-errors ring buffer (capacity 10, fed by_emit_sdk_error).tests/test_integration_contract.py—track_eventsetdefaultrace pinned against the locked helper.tests/test_dead_code_removed.py::test_dir_size_unchanged— rewritten to key offnullrun.__all__(source of truth for the curated surface) instead of a hardcoded symbol count, so the curated-surface contract is still pinned without blocking legitimate additions.
- The previous
0.6.0on TestPyPI is yanked (visible but not installable viapip install nullrun) — it predates the Layer-1 / Layer-2 / Layer-3 work merged in this release, so users who pinned0.6.0on TestPyPI should upgrade to0.6.1to pick up the new structured exceptions and observability APIs.
- Every existing
exceptclause keeps matching — the new exception classes are subclasses of the existing ones. from nullrun.breaker.exceptions import Xkeeps working unchanged.pip install nullrun==0.6.1is a drop-in replacement for0.6.0.
Hardening pass driven by the 2026-06-22 SDK↔backend integration audit. Closes three classes of silent fail-OPEN regressions that the previous release shipped: SDK POSTs being rejected by the backend's CSRF middleware, WS HMAC identity field drift, and policy-fetch silently falling through to a permissive default on any backend blip. Coverage jumped from ~76% to 84.59% (branch = true).
-
FIX-F3 — every signed POST now carries
Authorization: Bearer <api_key>. The backend's CSRF middleware (backend/src/auth/csrf.rs::has_bearer_auth) bypasses the cookie-double-submit check whenever any non-emptyAuthorizationheader is present. Pre-fix the SDK only sentX-API-Key, so every POST hit the "state-changing request without session cookie" branch and got 403 — which the SDK'stry/exceptaround/gate,/track,/check, and/executesilently swallowed. The net effect was that every SDK-side enforcement gate was effectively fail-OPEN on production traffic. The fix uses the user-facingapi_keyas the Bearer value so the bypass header is meaningful for debugging; the canonical auth path is stillX-API-Key(+ HMAC when configured). Safe percsrf.rs:80-95(browsers never auto-attachAuthorizationto cross-site requests, so this is not a CSRF regression). -
FIX-F4 — WebSocket HMAC identity field pinned to
api_key. AddedWS_HMAC_IDENTITY_FIELD = "api_key"constant intransport_websocket.pymatching the backend'sSignedWsMessagestruct (backend/src/proxy/http/ws_control.rs:43). The SDK now readsdata["api_key"](withdata["api_key_id"]as a backwards-compat fallback for pre-FIX-F4 servers) to verify the HMAC signature. Pre-fix a future server-side rename would silently break WS signature verification with no compile-time signal.
-
Policy fetch is now fail-CLOSED (F-R2-02). Pre-fix, any HTTP exception, non-200 status, or empty
{"data": []}response silently fell through toPolicy.default_local()— which hadbudget_cents=1000,rate_limit=100,loop_threshold=6, no tool block, i.e. effectively unenforced. A 503 from the backend would keep the customer's SDK running with zero enforcement for the rest of the session. Post-fix the SDK resolves the policy on this gate in priority order: (1) the last known-good cached policy (self._last_good_policy— written by every successful_fetch_policy), (2)Policy.strict_local()(zero budget cap forces the backend reservation service, which is itself fail-CLOSED), (3) opt-out viaNULLRUN_POLICY_FAIL_OPEN=1to restore the legacy permissive fallback for tests/staging. Mirrors the shape ofNULLRUN_SKIP_BUDGET_CHECK=1andNULLRUN_SENSITIVE_FAIL_OPEN=1. -
Policy.strict_local()new classmethod. Tight fail-CLOSED fallback:budget_cents=0,rate_limit=1,loop_threshold=1,retry_threshold=1. The zero budget cap forces every cost-bearing operation through the backend's reservation service. The 1-call rate limit caps sustained throughput. The threshold-of-1 loop and retry detectors fire on the first suspicious repetition.
-
Policy.from_dictnow readsrate_limit_per_minute(the backend field name fromPolicyResponseinbackend/src/proxy/http/policies.rs). Falls back to legacyrate_limitfor backwards compat. SDK keeps the local attribute namerate_limit(cents per minute) — only the wire-mapping changes. -
_is_acknowledged_statecase-insensitive fallback for WS. New helper onWebSocketConnectionchecks PascalCase first (the happy path perhandlers.rs:9258as_pascal_case()normaliser), then falls back to lowercase for defensive coverage against server regressions to"killed"/"paused". -
Backend policy fetch uses the correct route. Pre-fix the SDK POSTed to
/api/v1/policieswithorganization_idin the body — the backend route isGET /api/v1/orgs/{org_id}/policies, so the call 404'd and silently fell through toPolicy.default_local()(silent fail-OPEN on every policy fetch). -
README.mdPyPI badge switched fromdmtodt. The daily mirror (dm) was inflating the displayed download count from mirror syncs; the total (dt) shows the canonical PyPI total.
-
tests/test_integration_contract.py(new, 675 lines, 12 test classes). Pins the SDK↔backend wire-format contracts surfaced by the 2026-06-22 audit:Authorizationheader on every signed POST (FIX-F3),/api/v1/orgs/{org_id}/policiesand/api/v1/orgs/{org_id}/workflows/{wf}URL shapes, ACK unit discrimination, WS HMAC identity field (FIX-F4), backendPolicyResponse→ SDKPolicyfield mapping, canonical-bytes guard against silent re-serialisation drift, sensitive-tool routing through/execute, fail-CLOSED policy fetch under exceptions / 5xx / empty data, outgoing WS ACK is plain JSON (not signed — corrects the 0.5.2 overclaim), all five workflow states (running/paused/killed/completed/failed) accepted, atomic remote-state registration across concurrent reconnects. Each test is paired with a specific backend file — update both sides in lock-step, do not edit one side alone. -
tests/test_high_reliability_fixes.py— re-aligned with the fail-CLOSED contract after the master merge; pins the last-known-good policy cache priority. -
tests/test_hmac_byte_equality.py— pinned thecontent=vsjson=body-byte equality that the legacy batch path silently broke. -
tests/test_ws_signed_payload.py— expanded to cover theapi_key/api_key_iddual-field WS HMAC identity contract. -
tests/test_preflight_fail_policy.py— updated to coverNULLRUN_POLICY_FAIL_OPEN=1opt-out alongside the default fail-CLOSED path. -
Coverage: 84.59% (branch = true,
fail_under = 82). Per-file leaders:transport.py85.01%,transport_websocket.py65.64%,runtime.py83.71%,instrumentation/auto.py70.17% (LLM-vendor patches — most remain opt-in),instrumentation/langgraph.py93.69%,instrumentation/crewai.py90.82%,instrumentation/autogen.py93.41%.
Production-readiness hardening. No public-API changes; the curated 6-symbol
surface is unchanged. Aligns the SDK with the contracts in
NULLRUN/docs/adr/008-sdk-preflight-fail-policy.md and
NULLRUN/docs/kill-contract.md.
- gRPC transport code path removed.
create_grpc_transportwas referenced but never defined, so settingNULLRUN_USE_GRPC=1raisedNameErrorat init. The gRPC server at the platform is intentionally frozen until the activation checklist (TLS, auth, proto extensions, cost pipeline parity, tests) is complete. The SDK now logs an INFO line onNULLRUN_USE_GRPC=1and silently falls back to HTTP. Thegrpciohard dependency has been dropped frompyproject.toml. If/when gRPC is unblocked, the SDK will add it back as a separate optional extra. InsecureTransportErrorURL check hardened. Replaced thestartswith("http://127.0.0.1")chain with aurllib.parse.urlparseipaddress.ip_addresscheck. The previous check lethttp://127.0.0.1.attacker.comandhttp://localhost.evil.comthrough (homograph attacks) and rejectedhttp://[::1]:8080(IPv6 loopback). The new check allows the full127.0.0.0/8IPv4 loopback range,::1, andlocalhost(case-insensitive).
signal.signalglobal hijack removed.Transport.__init__no longer installs a process-wideSIGTERM/SIGINThandler that calledsys.exit(0)from inside the signal context. The fix contract was already pinned intests/test_signal_safety.pyand is now applied to the source.atexit.registerreplaced withweakref.finalize. The per-Transportatexitchain was growing without bound in long-running deployments; weakref finalizers only fire if the transport is still alive at process exit.Transportis now a context manager.with Transport(...) as t:starts the flush thread on enter and stops it on exit. Replaces the manualstart() / stop()pair that was easy to forget.- HMAC body byte-equality in the legacy batch path. The
pre-fix code signed
body = json.dumps({"events": batch})and then sent the same payload via httpx'sjson=...parameter, which re-serialises with compact separators. The signed bytes and the wire bytes were not identical. Now the path usescontent=bodyso the signed bytes are the wire bytes. - All 4 examples fixed.
basic.pywas callinginit()with no args (raises in 0.3.0).basic_observe.pywas passingorganization_id=(not in the signature) and callingnullrun.coverage_report()(did not exist).cost_dashboard.pywas usingAuthorization: Bearerand the non-existent/api/v1/orgs/{org_id}/usageendpoint. All four now use the current SDK surface and the canonical/api/v1/orgs/{org_id}/statusendpoint.
- AsyncTransport dead code deleted. 626 lines of unused async transport that had no call sites. Tests already removed.
- TrackResult dead class deleted.
track()returnsdict, notTrackResult. The class was unreferenced. - Singleton-state lock added.
init()now wraps the three singleton-slot writes (NullRunRuntime._instance,_rt_mod._runtime,_dec_mod._runtime) in a module-levelthreading.Lockso concurrentinit()calls cannot leave the slots pointing at two different runtimes. - Legacy API key warning. Pre-Phase-139 API keys (no
workflow_idfrom/auth/verify) now emit a one-time WARNING explaining that remote kill/pause will not be honoured. Without the warning, the dashboard KILL button silently no-ops for users on legacy keys. - Distributed circuit-breaker race fix. The pre-fix code
defined
_publish_half_open_statebut never called it. Thestateproperty now calls it on theOPEN → HALF_OPENtransition so other workers see the new state in Redis instead of falling back to PERMISSIVE.
AsyncTransport(626 lines)TrackResult(12 lines)BoundedDictcost / loop / retry counters_check_local_limits(the local budget check that readcost_centswhich the SDK never sets — was dead for the public API)StructuredLogger,get_logger,TenantFilter,configure_logging_with_tenant_context,timedfromobservability.py(zero call sites)tenant_context,set_tenant_context,get_org_idfromcontext.py(zero call sites;get_org_idwas already documented as gone in 0.3.0 CHANGELOG)instrumentation/openai.py(the v0.x patcher that no longer applied toopenai>=1.0)
NullRunRuntime.coverage_report()— public method that returns{"seen": ..., "tracked": ..., "streaming_skipped": ...}. The auto-instrumentation layer already populates the counters; this method just exposes them. Called byexamples/basic_observe.py.Transport.__enter__/__exit__(see above)tests/test_init_contract.py— pins the 0.3.0 init contract (api_key required, singleton state, no organization_id kwarg)tests/test_insecure_transport.py— homograph / IPv6 / case-insensitive coverage for the new URL checktests/test_grpc_removed.py— pins the post-deletion gRPC contracttests/test_legacy_key_warning.py— pins the legacy API key warningtests/test_cb_halfopen_publish.py— pins the HALF_OPEN Redis publishtests/test_kill_deprecation.py— pins theWorkflowKilledInterruptdeprecation-bypass contract
WorkflowKilledInterruptdocstring now includes a "Catching in production" section with the recommended Sentry / OpenTelemetry pattern (except BaseException, notexcept Exception).NULLRUN/docs/sdk/README.mdrewritten to match the actual 6-symbol SDK surface and currenttrack_*signatures. The previous 7-symbol reference was a description of an older design that did not match the shipped SDK.
0.5.2 — 2026-06-19
This release bundles the Sprint 2.5 production-readiness hardening
alongside the Phase 0 contract / lifecycle fixes. The two streams were
shipped as separate [Unreleased] sections during development; they
are merged here into a single canonical entry so release tooling that
scans for the [Unreleased] anchor picks up the complete change set
exactly once.
-
HMAC signing expanded (with documented exceptions, audit 2026-06-22 round 2 — F-R2-05 / F-R2-14). The SDK now signs every outgoing POST/GET that the backend's
HMAC_REQUIRED_PATHSallowlist requires:/track/batch,/gate,/check,/execute. The header set is built via_add_hmac_headers(Content-Type, X-Signature, X-Signature-Timestamp, X-API-Key, Authorization for CSRF bypass). Compliance with the canonicalHMAC-SHA256(secret_key, "<ts>:<api_key>:<sha256_hex(body)>")formula frombackend/src/auth/hmac.rs:6-9.Explicitly NOT signed (chicken-and-egg / backend allowlist):
runtime._authenticate→POST /api/v1/auth/verifyon initial bootstrap: nosecret_keyexists yet (it is what /auth/verify hands back). The key-rotation refetch (Transport._refetch_credentialsat transport.py:1588) IS signed becausesecret_keyis then populated.runtime._fetch_policy→GET /api/v1/orgs/{id}/policies. Not inHMAC_REQUIRED_PATHS(backend/src/proxy/middleware/ hmac_verify.rs:58). Backend allowlist is authoritative.runtime._fetch_remote_state→GET /api/v1/orgs/{id}/workflows/ {wf}. Not inHMAC_REQUIRED_PATHS.runtime.get_org_status→GET /api/v1/orgs/{id}/status. Not inHMAC_REQUIRED_PATHS.
Outgoing WebSocket ACK is plain JSON, not signed. Earlier documentation overstated this —
transport_websocket._send_acksends{"type": "ack", "message_id", "received_at"}as plain JSON without an HMAC signature. The backend does not currently verify ACK authenticity (ws_control.rs:842-848is a TODO). If that ever changes, the SDK will sign the ACK using the sameWS_HMAC_IDENTITY_FIELD+secret_keypath as incoming messages — until then, treat CHANGELOG claims of "signed ACKs" as inaccurate. -
WebSocket protocol compliance (Phase 2 of the plan). The SDK now honours
resync_required(closes the connection, clears local state, reconnects — no merge per ADR-007), enforces per-workflowversionmonotonic dedup (drops events withversion <= lastto survive at-least-once delivery), and signs outgoing ACKs. The URL usesX-API-Keyheader (never the query string — per SEC-7, the server rejects?api_key=…). -
track_eventfingerprint + coverage counters (Phase 3).track_eventnow emits a stable_fingerprintso the dedup LRU at thetrack()sink collapses repeat emissions of the same event (the user's manualtrack_eventplus the httpx transport hook firing on the same LLM call). The fingerprint is stripped before the wire send. The_coverage_seen/_coverage_tracked/_coverage_streaming_skippedcounters are now initialised in__init__so the_safe_bump_coveragehelper innullrun.instrumentation.autoactually increments the dashboard's coverage tab. -
SENSITIVE_ARG_KEYSexpanded from 7 to 29 tokens. Now maskspassword,passwd,pwd,token,secret,api_key,apikey,key,auth,authorization,bearer,session,session_id,cookie,access_token,refresh_token,id_token,private_key,secret_key,email,phone,ssn,credit_card,credit_card_number,cvv,cvc,pin,otp,mfa. Matching is case-insensitive. -
Recursive
_safe_error_str(Phase 3). The previous one-level regex was replaced with a balanced-brace walker that handles arbitrary nesting depth and dict values that contain{/}in string content. Baredetails=foo(no opening brace) is preserved so we don't lose free-form text. -
RateLimitErrorexception class (Phase 4). A newRateLimitError(NullRunTransportError)carries the parsedRetry-After(seconds) andupgrade_urlfrom the 429 envelope percontracts/errors.ts. The transport layer's_parse_error_envelopehelper maps 4xx / 5xx / 429 to typed exceptions (NullRunAuthenticationError/NullRunTransportError(GATEWAY_ERROR)/RateLimitError) so callers can branch on the type instead of string-matchingstr(exc). -
Transport.post_signed_with_401_retryhelper (Phase 4). The runtime can opt into transparent one-shot re-authentication on HTTP 401 by passing areauth_callback(typicallylambda: self._authenticate()). The first 401 re-callsauth/verifyto pick up the freshly-rotatedsecret_keyand retries the original request. A second 401 propagates asNullRunAuthenticationError. -
PolicyCache.clear()(Phase 2). New method on the transport's policy cache so thePolicyInvalidatedWebSocket callback can flush every cached decision atomically. TheTransport.clear_policy_cachepublic method now delegates to it instead of poking the internal_cachedict. -
_fingerprint_for_event_dicthelper (Phase 3). New innullrun.instrumentation.autofor the generic event-dict fingerprint used bytrack_event(the existing_fingerprint_foris for HTTP responses keyed on host+body+status). -
Async Policy Cache:
AsyncTransportnow usesPolicyCachefor CACHED fallback mode. Previously the async transport always fell back to PERMISSIVE when gateway was unreachable. Now it caches successful execute decisions and uses them when gateway is unavailable. -
Custom Sensitive Tools API: Added
add_sensitive_tool(),remove_sensitive_tool(),register_sensitive_tools(), andget_sensitive_tools()methods toNullRunRuntime. Users can now register custom tools as sensitive requiring strict mode enforcement. -
NullRunBlockedException.tool_nameattribute (FIX-5): Thetool_namekwarg is now a first-class attribute onNullRunBlockedException(and its subclassesLoopDetectedException, etc.) instead of being absorbed into**details. Cookbook examples that readexc.tool_nameno longer raiseAttributeError. Backwards-compatible:tool_namedefaults toNoneand does not appear inexc.detailswhen unset. The stringified exception now includestool={name}when set. -
check_control_planeis case-insensitive on the state value. SDK now normalises the state with.lower()before comparing to"paused"/"killed". Pre-fix a backend regression to UPPERCASE (e.g."KILLED"instate_change) would have silently failed the match and let a killed workflow keep running. Backend already emits PascalCase per theas_pascal_case()normaliser inhandlers.rs:9258; this is defensive peranalyze.md§11.6.
- Empty placeholder modules deleted.
src/nullrun/flow/,src/nullrun/gate/,src/nullrun/common/were placeholders for promised-but-unimplemented products. Removed. - Orphan
protos/directory deleted.grpc_transport.pywas removed in 0.4.0; the proto schema is no longer needed in the SDK. instrumentation/openai.py(v0.x patcher) deleted. It patchedopenai.ChatCompletion.createwhichopenai>=1.0does not expose. All OpenAI v1.0+ traffic is now tracked via the httpx transport hook innullrun.instrumentation.auto.DecisionHistoryRecorder.replay_locally/replay_event/replay_from_filedeleted. They calledruntime.track(which hits the backend) despite the docstring claiming "local-only". The honest-scope local recorder surface (start_recording,stop_recording,record_event,estimate_cost,RecordingSession.to_dict/from_dict) is preserved.observability.TenantFilterno longer writes the deprecatedorg_idfield — only the canonicalorganization_idandapi_key_idremain. The legacyget_org_id()helper is gone alongside the workspace_id → organization_id migration.
-
examples/cost_dashboard.pyswitched fromAuthorization: Bearer(which the SDK never uses on the user's behalf) toX-API-Key, and from the non-existent/usageendpoint to the canonical/quotapercontracts/openapi.yaml. -
P0-1 (PCI-DSS / GDPR): positional PII masking. Sensitive tools called positionally (e.g.
charge("4111-1111-1111-1111", 50)) now mask positional args the same way kwargs already do, by introspecting the function signature withinspect.signature(fn)and applyingSENSITIVE_ARG_KEYSto the matching parameter name. Pre-fix the PAN at position 0 was forwarded as-is into/executeand landed in the audit log. -
P0-3 (OOM): streaming response memory cap. Sync and async httpx transports now use bounded chunked reads capped at
MAX_RESPONSE_BYTES(16 MiB by default;NULLRUN_MAX_RESPONSE_BYTESenv var to override). When the cap is exceeded, tracking is skipped and_coverage_streaming_skippedis incremented so the dashboard sees which hosts are producing oversized responses. Pre-fixresponse.read()/await response.aread()buffered the entire response body in memory — a 16+ MB allocation per streaming LLM call under load. -
P0-4 (cost-audit): drop-newest on buffer overflow. The CB-OPEN re-queue path in
Transport._do_flush_lockednow drops the NEWEST non-critical events instead of the oldest. The oldest events (start-of-incident, start-of-billing-period) are exactly what a billing investigator needs to reconstruct — losing them silently broke monthly rollups. Control-plane events (state_change/kill_received/policy_invalidated/key_rotated) are preserved regardless of position so the dashboard's KILL switch continues to land even under sustained backend outage. -
P0-6 + P3-3 (security): redact-before-truncate.
_safe_reprnow runs_strip_details_balancedon the FULL repr before truncating tomax_len=50. Pre-fix the truncate ran first, and ifdetails={...}lived past position 50 in the original repr (common for httpx.HTTPError with a long URL), the redact pass saw nothing on the truncated slice and the raw payload leaked intospan_endaudit events. -
S-8 / P2-4:
agent_idis now a real UUID with dashes.agent()context manager emitsstr(uuid.uuid4())(e.g.95ca7c0b-8334-478a-af23-2788803ef3b8) for auto-generated ids. Pre-fix the format wasf"agent-{uuid.uuid4().hex}"— 32 hex chars with no dashes; backend UUID-typed columns silently dropped these to NULL on insert. User-supplied names are still preserved verbatim. -
S-9: LRU cap on
NullRunCallback._active_runs(4096 entries, FIFO eviction with WARN log). Pre-fix this dict grew unbounded whenon_chain_enddid not fire (errors in the chain body short-circuited the end hook for some LangChain versions), leaking memory in long-running services. -
S-10: WebSocket reconnect max-attempts cap (10 consecutive failures). Pre-fix the loop was unbounded (
while not self._closed:) and leaked the WS thread forever when the backend was permanently down. After the cap the SDK falls back to HTTP-poll for control-plane state delivery. -
P2-1:
_coverage_seennow bumps in the httpx path. Pre-fix the counter was only incremented in therequestspath (auto_requests.py:185), so the dashboard's coverage view was empty for the dominant httpx traffic (every OpenAI / Anthropic / Gemini / Mistral / Cohere call). Now both sync and async httpx_emitbump the counter. -
P3-2: webhook delivery uses exponential backoff (cap 30s). Pre-fix the schedule was linear (
0.5 * (attempt + 1)); under sustained outage this produced a tight retry storm on the dead endpoint — each KILL/PAUSE spawned its own delivery thread. Post-fix the schedule is0.5 * 2**attemptcapped at 30s: 0.5s, 1.0s, 2.0s, 4.0s, 8.0s, 16.0s, 30.0s.
Added regression tests for every item above (57 new tests across 9
new test files: test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py; existing
test_buffer_invariants.py extended with drop-newest + critical-event
preservation cases).
- SDK silent runtime fallback removed (FIX-4):
_get_or_create_runtimeinnullrun.decoratorsno longer wrapsNullRunRuntime.get_instance()in atry/except Exceptionthat rebuilds a no-argNullRunRuntime(). In 0.3.0 (T3-S2) the no-arg constructor requiresapi_keyand raisesNullRunAuthenticationError— so the fallback swallowed the auth error fromget_instance()only to crash with the same error from the fallback path itself. After this fix, the auth error propagates cleanly to the first@protectinvocation, mirroring the fail-loud contract ofnullrun.init(). Aligns with the T3-S2 invariant that the SDK has no local mode: a missing API key is a hard error, not a silent allow-all.
- Public surface unchanged.
init,protect,track_llm,track_tool,track_eventretain the same call signatures documented in the existing examples. The platform'sdocs/sdk/README.mddescribes an alternative 7-symbol surface (withwrapalias and a differentinit(organization_id, ...)signature) — that doc is out of sync with the SDK; an update to the platform docs is tracked separately. Per the production plan's user decisions, the SDK's surface is the source of truth.
Production-readiness release. Resolves all BLOCKER + HIGH + MEDIUM + LOW
audit findings from the 0.3.x audit. The curated 6-symbol public surface
(init, protect, track_llm, track_tool, track_event,
__version__) is unchanged. Full PR-by-PR description follows; this
entry is the summary. Phase-7 (framework patches) and Phase-8
(release-prep polish) ship as follow-up releases under the same 0.4.x
line.
BoundedDictclass (runtime.py) — dead since 0.3.1.wrap_tool,wrap,check_before_tool,enforce_check_before_llm,check_before_llm(and theCheckDecisiondataclass),evaluate(runtime.py) — zero in-tree callers;wraphad a latentNameErrorthat's gone with the deletion.clear_pause(actions.py) — zero callers.WorkflowContextclass (context.py) — duplicate of theworkflow()contextmanager.WebSocketManager(transport_websocket.py) — never instantiated; the runtime usesWebSocketConnectiondirectly.PoolConfig+AdaptivePool(transport.py) — never instantiated;httpx.Limitsis the real pool.Transport._atexit_flush(transport.py) — orphan method from the pre-weakref.finalize migration.EventRecorder(decision_history.py) — never used.
- First-
track()AttributeError(Phase 2).runtime.track()no longer readsself._workflow_costs(a BoundedDict removed in 0.3.1 whose two callers survived). Returnslocal_cost_cents = 0from the new_local_cost_cents_estimateattribute. auto_requestsmodule was unimportable. The missing_safe_bump_coveragehelper thatauto_requests.pyimports is now defined inauto.py. The whole module imports cleanly and the coverage dashboard counter is reachable.auto_instrument()now callspatch_requests. Therequestslibrary path is no longer dead; ~30-50% of real codebases that userequestsdirectly are now tracked.
_remote_statesnow protected bythreading.RLock. New helpers_remote_state_for/_set_remote_stateare the only public mutation path.test_remote_states_race.pyis now meaningful.PolicyCacheno longer writespolicy_versioninto thettl_secondsfield (silent cache-lifetime corruption). Added dedicatedpolicy_versionfield onCachedDecision.get_instance()re-auth path is now inside the singleton lock; no more TOCTOU window where a concurrent caller can observe a half-shutdown runtime._fetch_remote_stateusesself._transport._client(shared pool- circuit breaker) instead of a raw
httpx.get.
- circuit breaker) instead of a raw
workflow()emits a real UUID4 instead ofwf-{hex32}.@sensitivepropagatesNullRunAuthenticationErrorinstead of silently swallowing it.- Custom-host LLM endpoints now honour the dashboard KILL switch (the kill check is no longer gated on the extractor table).
Transport.executeaccepts anon_transport_errorcallback (per ADR-008) so sensitive-tool pre-checks can fail-CLOSED on classified transport errors.
NULLRUN_FALLBACK_MODEenv var (orfallback_modeconstructor arg) selects PERMISSIVE / STRICT / CACHED._rebuildstripsTransfer-EncodingalongsideContent-Encoding.shutdown()caps join waits at 0.5s (was 2.0s) — safe from signal handlers.- WS URL constructed via
urllib.parse(rejects unknown schemes). DEDUP_LRU_MAXraised 512 -> 4096.
nullrun.instrumentation.llama_index—patch_llama_indexsubscribes toLLMChatEndEventandFunctionCallEventon the llama-index core Dispatcher. Optional extrapip install nullrun[llama-index].nullrun.instrumentation.crewai—patch_crewaiwrapsCrew.kickoffandCrew.kickoff_asyncto installstep_callback/task_callback. Post-run readscrew.usage_metricsand emits onellm_callevent per model. Optional extrapip install nullrun[crewai].nullrun.instrumentation.autogen—patch_autogenwrapsBaseChatAgent.on_messagesfor span tracking andOpenAIChatCompletionClient.createfor streaming-safe usage capture. Optional extrapip install nullrun[autogen].
NullRunRuntime.get_org_status(org_id)— public helper for reading/api/v1/orgs/{org_id}/status. Routes through the shared transport client. Used byexamples/cost_dashboard.py.NULLRUN_BATCH_SIZEandNULLRUN_FLUSH_INTERVAL_MSenv vars overrideFlushConfigwithout subclassing.- README "mTLS / client certificate authentication" section
documenting
NULLRUN_TLS_CLIENT_CERT,NULLRUN_TLS_CLIENT_KEY,NULLRUN_TLS_CA_CERT. - Circuit-breaker
OPEN -> HALF_OPENjitter sleep capped at 5s (was 30s). RecordingSessionno longer persists the dedup_fingerprintfield — it leaks to disk viasave()otherwise.
- The platform's
docs/sdk/README.mddescribes a 7-symbol surface that does not match the shipped SDK. The SDK's curated surface is the source of truth; platform docs re-alignment is tracked separately.
- No-api-key init now raises (T3-S2):
nullrun.init()andNullRunRuntime(...)without anapi_key(and withNULLRUN_API_KEYunset) now raiseNullRunAuthenticationErrorinstead of falling back to aNullRunNoopstub. The previous silent fallback silently bypassed every backend gate (budget, policy, control plane) — a real safety hole in production. Action required: ensureapi_key="nr_live_..."is passed toinit()(orNULLRUN_API_KEYis set) in every entry point. The0.2.0deprecation warning has been removed; the new behavior is hard. local_modefield removed: The auto-derivedlocal_modeflag onNullRunRuntimeis gone. Theis_local_modeproperty and theNullRunNoop/NullRunNoopBreaker/_NullContextclasses are deleted (nullrun.noopmodule removed). All call sites that readruntime.local_modewill seeAttributeError— there is no migration path because the field no longer has meaning. Code paths that previously branched onlocal_modenow always go through the cloud runtime (auth + policy fetch + control plane).
- Legacy Breaker exports (T9): The 7 legacy re-exports
(
nullrun.BreakerError,nullrun.CostLimitExceeded,nullrun.ApprovalRequired,nullrun.BreakerTimeout,nullrun.Policy,nullrun.FallbackMode,nullrun.PoolConfig) are no longer reachable asfrom nullrun import X. The canonical exception names (NullRunBlockedException,WorkflowPausedException,WorkflowKilledException,NullRunAuthenticationError, …) and the canonical policy/transport modules (from nullrun.runtime import Policy,from nullrun.transport import FallbackMode, PoolConfig) remain available. Audited for 0 external callers.
- 0.2.x → 0.3.0:
nullrun.init()calls withoutapi_keywill raise. Passapi_key="nr_live_..."explicitly or setNULLRUN_API_KEY.NullRunRuntime(...)constructions withoutapi_keywill raise (same fix).- Tests using
NullRunNoop/local_mode=Truemocking must switch toNullRunRuntime(api_key="test-key", _test_mode=True)—_test_modeskips the network calls without silently bypassing policy. from nullrun import BreakerError(and the 6 other legacy names) must use the canonical paths above.
- Async Policy Cache:
AsyncTransportnow usesPolicyCachefor CACHED fallback mode. Previously the async transport always fell back to PERMISSIVE when gateway was unreachable. Now it caches successful execute decisions and uses them when gateway is unavailable. - Custom Sensitive Tools API: Added
add_sensitive_tool(),remove_sensitive_tool(),register_sensitive_tools(), andget_sensitive_tools()methods toNullRunRuntime. Users can now register custom tools as sensitive requiring strict mode enforcement.
- No-api-key init / local mode (T3-S1): Calling
nullrun.init()or constructingNullRunRuntime(...)without anapi_key(and withNULLRUN_API_KEYunset) now emits aDeprecationWarning. The runtime still falls back to local mode and silently bypasses every backend gate (budget, policy, control plane). The fallback will be removed in 0.3.0 — passingapi_key='nr_live_...'explicitly or settingNULLRUN_API_KEYis the only supported path going forward. Pin the warning to a hard error withpython -W error::DeprecationWarningto catch callers in CI.
0.1.1 — 2026-05-20
- CR-2: Fixed buffer overflow when circuit breaker is OPEN. Previously, re-queued events were prepended to buffer, causing newest events to be dropped first. Now appends to buffer end and checks max_buffer_size before re-queue.
- CR-5: Async circuit breaker now uses
asyncio.Lockinstead ofthreading.Lockfor proper async context handling. - CR-1+CR-4:
runtime.pynow creates Transport before_authenticate()and_fetch_policy(), reusing the HTTP client for connection pooling and consistent timeout/retry policies. - AsyncAwait: Fixed
_call_async()not awaiting_on_success_async()and_on_failure_async()coroutines, causing "coroutine was never awaited" warnings in async transport.
- Transport buffer now enforces max_buffer_size before re-queuing events on circuit breaker OPEN
0.1.0 — 2026-05-18
- Circuit breaker core (
src/nullrun/breaker/) with STRICT / PERMISSIVE / CACHED fallback modes - HTTP transport with batch event sending (
transport.py) - Async transport for asyncio applications
- Retry logic with jitter and policy-aware backoff
@protectdecorator for wrapping functions (decorators.py)- Workflow context support (
context.py) - Main runtime entrypoint (
runtime.py) X-API-Versionheader on all outgoing requests
- Requires Python ≥ 3.10
- Compatible with NullRun API version
2024-01-15
No breaking changes yet. Watch this file.