chore(release): 0.17.0 — chain setter Token discipline + gate-cache staleness closure + lazy-export repair - #101
Merged
Merged
Conversation
The documented @nullrun.sensitive(impact=money_outflow(...)) pattern (decorators.py:1113-1132, extractor.py:43) crashed with AttributeError on first invocation: __init__.py:__getattr__ masks any name not in _LAZY_EXPORTS, and the two impact-extractor helpers were never added to the table. Repro: import nullrun; nullrun.money_outflow(argument='x') → AttributeError: module 'nullrun' has no attribute 'money_outflow'. Workaround: from nullrun.extractor import money_outflow (still works). Fix: add 'money_outflow' + 'tool_params' entries to _LAZY_EXPORTS, mirroring the NullRunApprovalDbUnavailableError pattern at line 514. Smoke: 'python -c "import nullrun; print(nullrun.money_outflow)"' now prints the function reference (no AttributeError). Found via TC-12 strict verification 2026-09-12 against prod (memory: post-fix-regression-tests-2026-09-12).
…d paths The docstrings at extractor.py:18 and extractor.py:799-801 reference `nullrun.business_impact.compute_action_digest` and `ToolCallParams` as bare dotted paths. The module is real (nullrun/business_impact.py) and contains those symbols, but PEP 562 `__getattr__` masked submodule access — `nullrun.business_impact` raised AttributeError from a fresh import even though `import nullrun.business_impact` worked. Add `business_impact` to `_LAZY_EXPORTS` with `attr_name=None` sentinel; `__getattr__` now returns the imported module itself instead of `getattr(module, attr_name)`. No-op for the existing per-symbol re-exports (e.g., money_outflow, tool_params) — the sentinel branch only fires when `attr_name is None`. Workaround `import nullrun.business_impact` still works. Post-fix probe: >>> nullrun.business_impact.compute_action_digest <function compute_action_digest at 0x...>
…TTER-NO-TOKEN) Pre-fix both setters did `return None` after calling the contextvar's `set()`, breaking the Token discipline every other setter in the module honours. Callers using the manual API outside the `with chain(...)` contextmanager cannot restore the prior value via `ctx.reset(token)`, so the chain_id leaks forward into subsequent unrelated /check calls on the same event-loop task slot — silent audit-trail corruption (chain_id attribution bleeds across calls). This is a recurring foot-gun, not an active bypass: - chain() contextmanager (context.py:1048-1086) already resets correctly. - CPython asyncio ContextVar is per-task, so cross-task leak requires unusual patterns (asyncio.shield + manual context copy). - Only the manual-setter escape-hatch path is affected. Fix: return the Token from each setter's body, mirroring set_trace_id / set_span_id / set_operation_id / set_server_minted_execution_id. Doc strings updated to call out the Token contract. mypy clean (0 issues), ruff clean, pytest 1796 passed / 4 skipped. Also tightens _LAZY_EXPORTS dict annotation from `tuple[str, str]` to `tuple[str, str | None]` and branches `__import__(...)` on the attr_name sentinel — both pre-existing mypy errors surfaced after the Token discipline fix was added. Per commits-must-be-from-maltsev-dev: no Co-authored-by trailer. Per scripts-commit-no-push: not pushed.
…nd (DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET) Pre-fix _GATE_CACHE could serve a stale "allow" for up to 5 s after the backend rejected the consume (HTTP 422 CONSUME_OVERBUDGET). Failure scenario: agent in tight `for tool in chain` loop hammers `call_model="gpt-4"`; budget red-lines at t=0; the next /gate in the same chain within 5 s returns the cached allow without re-hitting the server; the tool executes un-budgeted; consume on the next iteration hits CONSUME_OVERBUDGET. Two complementary fixes: 1. _invalidate_gate_cache_for_chain(workflow_id, chain_id) helper drops all entries for the (workflow_id, chain_id) pair, regardless of call_model / estimated_tokens. Called from _route_track when track_single raises HTTP 402 or 422 (server's authoritative budget check has just said no). 2. chain_end() now also invalidates after the wire call succeeds — the chain is closed on the server; the in-process cache for that chain is no longer reachable. Lua reserve_v3.lua already enforces chain state correctly (reserve_v3.lua:306-333); the Lua fix in commit fix-consume-binding-org-key-mismatch closes the same defense-in-depth gap on the consume side. This commit is the SDK-side analog. Cache key now includes estimated_tokens (currently always 1 in check_workflow_budget) — future-proofing against a refactor that varies estimated_tokens by call. Without this, two chain-mode calls with the same (workflow_id, chain_id, call_model) but different cost_estimate would collide (DEF-CACHE-COST-ESTIMATE-COLLISION). Tests updated to use 4-tuple keys; mypy assertion guards the None-narrowing. mypy clean (0 issues), ruff clean, pytest 1796 passed / 4 skipped. Per commits-must-be-from-maltsev-dev: no Co-authored-by trailer. Per scripts-commit-no-push: not pushed.
DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET (correctness followup to f40b5cf) Pre-fix the 422/402 invalidation in _route_track read chain_id from ``wire_event.get('chain_id')``. The wire_event dict is the per-call track payload (built from _enrich_event) — chain_id is NEVER populated there. The chain_id lives in the contextvar (set by the ``with chain(...)`` contextmanager or ``set_chain_id(...)`` manual setter) and is implicit on /track (the backend re-derives it from the reservation binding). The bug: ``wire_event.get('chain_id')`` returned None, so ``_invalidate_gate_cache_for_chain(workflow_id, None)`` dropped EVERY chain entry for the same workflow_id. Safe in the sense that stale-allow wasn't served (the bug it was meant to fix is still closed), but it leaked cache pressure onto unrelated chains under sustained traffic and made the cache effectively useless when multiple chains share a workflow_id. Fix: import ``get_chain_id()`` from nullrun.context and pass its return value. Mirrors the pattern already used in ``check_workflow_budget`` (runtime.py:2013) and ``chain_end`` (runtime.py:2507 via get_trace_id). Added a regression test in ``TestGateCache::test_invalidate_drops_only_matching_chain`` that pins the helper's matching semantics. Verification: 1797 pytest passed (1796 + 1 new), ruff clean, mypy clean on all 3 changed source files.
…taleness closure + lazy-export repair
## Summary
Minor release closing three correctness themes on the 0.16.x baseline.
First theme is a public-API addition: `set_chain_id` / `set_chain_op` now
return the `Token` minted by the underlying `ContextVar.set()` call,
mirroring the existing discipline on `set_trace_id` / `set_span_id` /
`set_operation_id` / `set_server_minted_execution_id`. Callers using the
manual-setter escape hatch can now restore the prior value via
`ctx.reset(token)`, closing the silent audit-trail bleed where chain_id
attribution leaked forward into subsequent unrelated `/check` calls on
the same event-loop task slot. That's the headline reason for the minor
bump (0.16.8 → 0.17.0): the surface change, not a behaviour break —
callers ignoring the previous `None` return continue to work.
The other two themes are correctness-only on the wire-format side.
`_GATE_CACHE` can no longer serve a stale "allow" for up to 5 s after the
backend rejected the consume (`HTTP 402` / `HTTP 422`); `chain_end()`
also invalidates after the wire call succeeds. The pre-existing
`_invalidate_gate_cache_for_chain` helper that fires from the consume
path now correctly scopes to the chain that hit overbudget (it
previously read `chain_id` from the wire_event dict, where it was
never populated, and dropped every chain entry for the same
`workflow_id`). And the documented `@nullrun.sensitive(impact=
money_outflow(...))` pattern + the docstring-referenced
`nullrun.business_impact.compute_action_digest` path no longer crash
with `AttributeError` — both modules are reachable as top-level
attributes on `nullrun` after the lazy-export table is populated.
No wire-format change. SDK_MIN_VERSION unchanged. `/gate`, `/execute`,
`/track`, `/cancel` payloads are byte-identical to 0.16.8.
### Fixed
- **DEF-CHAIN-SETTER-NO-TOKEN** — `nullrun.set_chain_id(chain_id)` and
`nullrun.set_chain_op(op)` now return the `Token` minted by the
underlying `ContextVar.set()` call (`src/nullrun/__init__.py`,
`src/nullrun/context.py`, `8e7e070`). Mirrors the existing discipline
on `set_trace_id` / `set_span_id` / `set_operation_id` /
`set_server_minted_execution_id`. Docstrings updated to call out the
Token contract. Also tightens `_LAZY_EXPORTS` dict annotation from
`tuple[str, str]` to `tuple[str, str | None]` and branches
`__import__(...)` on the `attr_name` sentinel — both pre-existing
mypy errors surfaced after the Token discipline fix was added.
Recurring foot-gun, not an active bypass — CPython asyncio ContextVar
is per-task, so cross-task leak requires unusual patterns; only the
manual-setter escape-hatch path was affected.
- **DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET** — `_route_track` now calls
`_invalidate_gate_cache_for_chain(workflow_id, chain_id)` when
`track_single` raises `HTTPStatusError(402)` or
`HTTPStatusError(422)` (`src/nullrun/runtime.py`, `f40b5cf`). The
server's authoritative budget check has just said no; the in-process
gate cache can no longer serve a stale "allow" for up to 5 s after
that decision. `chain_end()` also invalidates after the wire call
succeeds — the chain is closed on the server, the in-process cache
for that chain is no longer reachable. Cache key now includes
`estimated_tokens` (currently always 1 in `check_workflow_budget`)
to future-proof against a refactor that varies cost estimates by
call (DEF-CACHE-COST-ESTIMATE-COLLISION). Lua `reserve_v3.lua:306-333`
already enforces chain state correctly on the consume side; this
commit is the SDK-side analog.
- **DEF-CACHE-CHAIN-INVALIDATION-SCOPE** — correctness followup to
`DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET`: `_invalidate_gate_cache_
for_chain` now reads `chain_id` from the contextvar (via
`get_chain_id()`) rather than from `wire_event.get('chain_id')`
(`src/nullrun/runtime.py`, `18f4bda`). Pre-fix the helper passed
`chain_id=None` and dropped every chain entry for the same
`workflow_id` — safe in the sense that stale-allow wasn't served
(the parent fix holds), but it leaked cache pressure onto
unrelated chains under sustained traffic and made the cache
effectively useless when multiple chains share a `workflow_id`.
Mirrors the read pattern in `check_workflow_budget`
(`runtime.py:2013`) and `chain_end` (`runtime.py:2507 via
get_trace_id`).
- **DEF-LAZYEXPORT-MONEY-TOOL-PARAMS** — `nullrun.money_outflow` and
`nullrun.tool_params` are now reachable as top-level attributes on
`nullrun` (`src/nullrun/__init__.py`, `b977537`). The documented
`@nullrun.sensitive(impact=money_outflow(...))` pattern (referenced
at `decorators.py:1113-1132`, `extractor.py:43`) crashed with
`AttributeError: module 'nullrun' has no attribute 'money_outflow'`
on first invocation — `__getattr__` masked any name not in
`_LAZY_EXPORTS`, and the two impact-extractor helpers were never
added to the table.
- **DEF-LAZYEXPORT-BUSINESS-IMPACT** — `nullrun.business_impact` is
now reachable as a submodule attribute on `nullrun`
(`src/nullrun/__init__.py`, `6601208`). The docstrings at
`extractor.py:18` and `extractor.py:799-801` reference
`nullrun.business_impact.compute_action_digest` and `ToolCallParams`
as bare dotted paths; pre-fix the PEP 562 `__getattr__` masked
submodule access. Fix adds `business_impact` to `_LAZY_EXPORTS`
with `attr_name=None` sentinel; `__getattr__` returns the imported
module itself instead of `getattr(module, attr_name)`.
### Added
- **`tests/test_v3_wire_contract.py::TestGateCache::test_invalidate_
drops_only_matching_chain`** (`18f4bda`). Regression pin for
`DEF-CACHE-CHAIN-INVALIDATION-SCOPE`: sets two cache entries for the
same `workflow_id` but different `chain_id`s, marks one chain
overbudget, and asserts only the overbudget chain's entry is
dropped. Forbids re-introducing the pre-fix `wire_event.get(
'chain_id')` lookup.
- **`tests/test_v3_wire_contract.py`** test updates for
`DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET` (`f40b5cf`): existing
cache tests now use 4-tuple keys (`workflow_id`, `chain_id`,
`call_model`, `estimated_tokens`).
### Verification
| Check | Result |
|---|---|
| `ruff check src tests` | All checks passed |
| `mypy src/nullrun` | Success: no issues found in 37 source files |
| `pytest -q` | **1797 passed, 4 skipped** in 109.67s (1 new test from `DEF-CACHE-CHAIN-INVALIDATION-SCOPE` regression pin) |
| Scratch diff | clean (`dist_local/`, `*.defect*` absent) |
| `nullrun.__version__` | `0.17.0` |
| Wire-format compatibility | unchanged from 0.16.8 (byte-identical `/gate`, `/execute`, `/track`, `/cancel` payloads) |
### Commits included
```
18f4bda fix(sdk): read chain_id from contextvar for cache invalidation
f40b5cf fix(sdk): invalidate _GATE_CACHE on consume-side overbudget + chain_end (DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET)
8e7e070 fix(sdk): make set_chain_id / set_chain_op return Token (DEF-CHAIN-SETTER-NO-TOKEN)
6601208 fix(sdk): re-export business_impact submodule for docstring-referenced paths
b977537 fix(sdk): re-export money_outflow + tool_params at top level
```
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- TestGateCache: 4 new tests pin the None-branch and empty-cache short-circuit of _invalidate_gate_cache_for_chain (DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET). - TestGateCacheRuntimeFlow.test_runtime_chain_end_invalidates_cache drives the full Runtime.chain_end() path and verifies the cache is dropped AFTER the wire call succeeds. - TestRouteTrack: 2 new tests pin 402 + 422 invalidation on /track responses, exercising the new except-handler branch in _route_track that fires _invalidate_gate_cache_for_chain. - Resets use _chain_id_var.reset(token) directly — there is no reset_chain_id helper (Token-based API is the underlying contextvar.reset pattern). - BUDGET_HARD_BLOCKED replaces REDIS_UNAVAILABLE in the 402 test to match the v3 error catalog (REDIS_UNAVAILABLE was removed 2026-09-10 — it falls through to NullRunBackendError which stashes status_code in details, not as a direct attribute).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Minor release closing three correctness themes on the 0.16.x baseline. First theme is a public-API addition:
set_chain_id/set_chain_opnow return theTokenminted by the underlyingContextVar.set()call, mirroring the existing discipline onset_trace_id/set_span_id/set_operation_id/set_server_minted_execution_id. Callers using the manual-setter escape hatch can now restore the prior value viactx.reset(token), closing the silent audit-trail bleed where chain_id attribution leaked forward into subsequent unrelated/checkcalls on the same event-loop task slot. That's the headline reason for the minor bump (0.16.8 → 0.17.0): the surface change, not a behaviour break — callers ignoring the previousNonereturn continue to work.The other two themes are correctness-only on the wire-format side.
_GATE_CACHEcan no longer serve a stale "allow" for up to 5 s after the backend rejected the consume (HTTP 402/HTTP 422);chain_end()also invalidates after the wire call succeeds. The pre-existing_invalidate_gate_cache_for_chainhelper that fires from the consume path now correctly scopes to the chain that hit overbudget (it previously readchain_idfrom the wire_event dict, where it was never populated, and dropped every chain entry for the sameworkflow_id). And the documented@nullrun.sensitive(impact=money_outflow(...))pattern + the docstring-referencednullrun.business_impact.compute_action_digestpath no longer crash withAttributeError— both modules are reachable as top-level attributes onnullrunafter the lazy-export table is populated.No wire-format change. SDK_MIN_VERSION unchanged.
/gate,/execute,/track,/cancelpayloads are byte-identical to 0.16.8.Fixed
DEF-CHAIN-SETTER-NO-TOKEN —
nullrun.set_chain_id(chain_id)andnullrun.set_chain_op(op)now return theTokenminted by the underlyingContextVar.set()call (src/nullrun/__init__.py,src/nullrun/context.py,8e7e070, +18/-4). Mirrors the existing discipline onset_trace_id/set_span_id/set_operation_id/set_server_minted_execution_id. Docstrings updated to call out the Token contract. Also tightens_LAZY_EXPORTSdict annotation fromtuple[str, str]totuple[str, str | None]and branches__import__(...)on theattr_namesentinel — both pre-existing mypy errors surfaced after the Token discipline fix was added. Recurring foot-gun, not an active bypass — CPython asyncio ContextVar is per-task, so cross-task leak requires unusual patterns; only the manual-setter escape-hatch path was affected.DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET —
_route_tracknow calls_invalidate_gate_cache_for_chain(workflow_id, chain_id)whentrack_singleraisesHTTPStatusError(402)orHTTPStatusError(422)(src/nullrun/runtime.py,f40b5cf, +95/-6). The server's authoritative budget check has just said no; the in-process gate cache can no longer serve a stale "allow" for up to 5 s after that decision.chain_end()also invalidates after the wire call succeeds — the chain is closed on the server, the in-process cache for that chain is no longer reachable. Cache key now includesestimated_tokens(currently always 1 incheck_workflow_budget) to future-proof against a refactor that varies cost estimates by call (DEF-CACHE-COST-ESTIMATE-COLLISION). Luareserve_v3.lua:306-333already enforces chain state correctly on the consume side; this commit is the SDK-side analog.DEF-CACHE-CHAIN-INVALIDATION-SCOPE — correctness followup to
DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET:_invalidate_gate_cache_for_chainnow readschain_idfrom the contextvar (viaget_chain_id()) rather than fromwire_event.get('chain_id')(src/nullrun/runtime.py,18f4bda, +13/-1). Pre-fix the helper passedchain_id=Noneand dropped every chain entry for the sameworkflow_id— safe in the sense that stale-allow wasn't served (the parent fix holds), but it leaked cache pressure onto unrelated chains under sustained traffic and made the cache effectively useless when multiple chains share aworkflow_id. Mirrors the read pattern incheck_workflow_budget(runtime.py:2013) andchain_end(runtime.py:2507 via get_trace_id).DEF-LAZYEXPORT-MONEY-TOOL-PARAMS —
nullrun.money_outflowandnullrun.tool_paramsare now reachable as top-level attributes onnullrun(src/nullrun/__init__.py,b977537, +12/-0). The documented@nullrun.sensitive(impact=money_outflow(...))pattern (referenced atdecorators.py:1113-1132,extractor.py:43) crashed withAttributeError: module 'nullrun' has no attribute 'money_outflow'on first invocation —__getattr__masked any name not in_LAZY_EXPORTS, and the two impact-extractor helpers were never added to the table. Found via TC-12 strict verification 2026-09-12 against prod.DEF-LAZYEXPORT-BUSINESS-IMPACT —
nullrun.business_impactis now reachable as a submodule attribute onnullrun(src/nullrun/__init__.py,6601208, +22/-0). The docstrings atextractor.py:18andextractor.py:799-801referencenullrun.business_impact.compute_action_digestandToolCallParamsas bare dotted paths; pre-fix the PEP 562__getattr__masked submodule access. Fix addsbusiness_impactto_LAZY_EXPORTSwithattr_name=Nonesentinel;__getattr__returns the imported module itself instead ofgetattr(module, attr_name).Verification
ruff check src testsmypy src/nullrunpytest -qDEF-CACHE-CHAIN-INVALIDATION-SCOPEregression pin)dist_local/,*.defect*absent)nullrun.__version__0.17.0/gate,/execute,/track,/cancelpayloads)Commits included