Skip to content

chore(release): 0.17.0 — chain setter Token discipline + gate-cache staleness closure + lazy-export repair - #101

Merged
maltsev-dev merged 7 commits into
masterfrom
release/0.17.0
Sep 12, 2026
Merged

maltsev-dev merged 7 commits into
masterfrom
release/0.17.0

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

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-TOKENnullrun.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, +18/-4). 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, +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 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, +13/-1). 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-PARAMSnullrun.money_outflow and nullrun.tool_params are now reachable as top-level attributes on nullrun (src/nullrun/__init__.py, b977537, +12/-0). 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. Found via TC-12 strict verification 2026-09-12 against prod.

  • DEF-LAZYEXPORT-BUSINESS-IMPACTnullrun.business_impact is now reachable as a submodule attribute on nullrun (src/nullrun/__init__.py, 6601208, +22/-0). 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).

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 104.09s (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

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

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.66667% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/__init__.py 66.66% 1 Missing and 1 partial ⚠️
src/nullrun/context.py 75.00% 1 Missing ⚠️
src/nullrun/runtime.py 94.73% 0 Missing and 1 partial ⚠️

📢 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).
@maltsev-dev
maltsev-dev merged commit 2756862 into master Sep 12, 2026
5 checks passed
@maltsev-dev
maltsev-dev deleted the release/0.17.0 branch September 12, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant