Skip to content

Commit afa283f

Browse files
committed
release(0.12.2): fresh execution_id per /check + chain-mode /gate cache
Bug-fix release layered on top of 0.12.1. No wire-format change; both fixes are client-side only. * BUG #4 -- check_workflow_budget() now sends a fresh uuidv7 as the "execution_id" field on every /check call instead of reusing workflow_id. The server's gate_reserve_v3 overwrites the field on response anyway, but a client-side placeholder that collides across calls confuses the v3 reservation binding on /track when Transport.track_single() reaches the backend and the field is stale -- exact symptom is 503 RESERVATION_NOT_FOUND per CLAUDE.md section 29. * BUG #5 -- new nullrun.runtime._GATE_CACHE (5s TTL, keyed on (workflow_id, chain_id, model)) collapses consecutive /gate calls from inside `with chain(...)` to a single roundtrip, avoiding 100 /gate calls per 100-step agent loop. Single-shot (Hard mode) callers MUST bypass the cache -- Hard mode's binary allow -> block semantics would let a stale "allow" leak a budget-exhausted call through. Opt-out via NULLRUN_GATE_CACHE_DISABLE=1 for callers that want the legacy always-roundtrip behaviour (used by live smoke tests per docs/runbooks/budget-blue-green-smoke.sh). Tests: 158 new lines in tests/test_v3_wire_contract.py covering per-call execution_id uniqueness, uuidv7 format validation, and the new cache data-structure invariants + opt-out cases. Bumps __version__ + pyproject.toml to 0.12.2.
1 parent 183cdd3 commit afa283f

5 files changed

Lines changed: 282 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
88
---
99

1010

11+
## [0.12.2] - 2026-07-04
12+
13+
Bug-fix release. Two related correctness fixes layered on top of 0.12.1; no wire-format change.
14+
15+
### Fixed
16+
17+
- **BUG #4`/check` execution_id**: `check_workflow_budget()` now sends a fresh `uuidv7` as the `execution_id` field on every call, instead of reusing `workflow_id`. The backend's `gate_reserve_v3` overwrites the field with its own server-minted value on the response, but the previous behaviour could confuse the v3 reservation binding on `/track` when `track_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.)
18+
- **BUG #5 — chain-mode gate thrash**: new `nullrun.runtime._GATE_CACHE` (5s TTL, keyed on `(workflow_id, chain_id, model)`) collapses consecutive `/gate` calls from inside `with 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 via `NULLRUN_GATE_CACHE_DISABLE=1` for callers that want the legacy always-roundtrip behaviour (e.g. live smoke tests per `docs/runbooks/budget-blue-green-smoke.sh`).
19+
20+
### Added
21+
22+
- 158 lines of contract tests in `tests/test_v3_wire_contract.py`: `TestGateExecutionId` (per-call uniqueness + uuidv7 format validation) and `TestGateCache` (5 cache invariant + opt-out cases).
23+
24+
### Changed
25+
26+
- `__version__` bumped from 0.12.1 to 0.12.2.
27+
28+
1129
## [0.12.1] - 2026-07-04
1230

1331
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`).

pyproject.toml

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nullrun"
7-
# Version bump: 0.12.0 → 0.12.1 in `release(0.12.1)` (server-minted
8-
# execution_id /check → /track wiring fix). Recurrence of the same
9-
# drift that #50 fixed for 0.12.0: the runtime commit bumped
10-
# `src/nullrun/__version__.py` but missed this field, so without
11-
# this sync-up `python -m build` would produce a wheel named
12-
# `nullrun-0.12.0-*` and PyPI Trusted Publishing would reject the
13-
# upload with HTTP 400 "File already exists" (the 0.12.0 artifact
14-
# is already live).
15-
version = "0.12.1"
7+
# Version bump: 0.12.1 → 0.12.2 in `release(0.12.2)` (fresh
8+
# execution_id per /check + in-process chain-mode gate cache). The
9+
# runtime commit bumped `src/nullrun/__version__.py` together with
10+
# this field — same drift prevention as #50, but proactive this
11+
# time (caught during pre-merge audit, not after a publish error).
12+
version = "0.12.2"
1613
# Long form used by PyPI page meta-description and search snippets.
1714
# Kept under the 200-char preview threshold so the full line is visible
1815
# without an "expand" click. Keywords are matched against likely search

src/nullrun/__version__.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,44 @@
4444
upgrading from < 0.12.0 should jump straight to 0.12.1 — 0.12.0
4545
released with the integrity bug above and was never deployed
4646
in production with the v3 wiring.
47+
48+
---
49+
50+
v3.12 / 0.12.2 (2026-07-04) — bug-fix: fresh execution_id per
51+
/check + in-process chain-mode gate cache.
52+
53+
Two related correctness fixes on top of 0.12.1:
54+
55+
1. ``check_workflow_budget`` now sends a fresh ``uuidv7`` as
56+
``execution_id`` on every /check call (instead of reusing
57+
``workflow_id``). The v3 ``gate_reserve_v3`` mints its
58+
own anyway, but a client-side placeholder that collides
59+
across calls confuses the reservation binding on
60+
/track when ``track_single`` returns 503
61+
``RESERVATION_NOT_FOUND`` (CLAUDE.md §29). The server
62+
overwrites the field on response, so the freshly-minted
63+
``reservation_id`` captured by
64+
``_capture_server_minted_execution_id`` still drives
65+
/track exactly as in 0.12.1.
66+
67+
2. New in-process gate cache
68+
(``nullrun.runtime._GATE_CACHE``) serves chain-mode
69+
@protect calls from a 5s TTL on the same
70+
``(workflow_id, chain_id, model)`` triple, collapsing
71+
100-step agent loops to a single /gate roundtrip. Single-
72+
shot (Hard mode) callers bypass the cache — the gate
73+
legitimately flips allow→block between consecutive
74+
calls there, and a stale "allow" could leak a budget-
75+
exhausted call. Opt-out via
76+
``NULLRUN_GATE_CACHE_DISABLE=1`` for callers that want
77+
the legacy always-roundtrip behaviour (e.g. for live
78+
smoke tests per docs/runbooks/budget-blue-green-smoke.sh).
79+
80+
No wire-format change. Pure client-side fix — backends on
81+
1.0.0 keep working unchanged. Pinning unchanged:
82+
SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade
83+
path: 0.12.1 -> 0.12.2.
4784
"""
4885

49-
__version__ = "0.12.1"
86+
__version__ = "0.12.2"
5087
__platform_version__ = "1.0.0"

src/nullrun/runtime.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
get_trace_id,
6161
get_workflow_id,
6262
)
63+
from nullrun.uuid7 import uuid7_str # 2026-07-04 BUG #4 (CLAUDE.md §24)
6364
from nullrun.observability import metrics
6465
from nullrun.transport import (
6566
HEADER_PROTOCOL,
@@ -82,6 +83,25 @@
8283
# collision hazard). Wire compat: still a string.
8384
UNKNOWN_WORKFLOW_ID: str = "__nullrun_unknown__"
8485

86+
# 2026-07-04 (BUG #5): in-process gate cache for chain-mode
87+
# invocations. Without this, every @protect inside `with chain(...)`
88+
# issues a /gate HTTP roundtrip + Redis reserve. For a 100-step
89+
# agent loop that's 100 roundtrips. The gate decision is
90+
# deterministic for a given (workflow_id, chain_id, model) over a
91+
# short window (chain status only changes on `chain_end`), so
92+
# caching the LAST decision for 5s is safe.
93+
#
94+
# Scope: ONLY when chain_id is set. Single-shot (Hard) callers
95+
# must NOT cache — the gate legitimately returns "allow" once and
96+
# "block" on the next call (Hard mode binary), and a stale "allow"
97+
# could let through a budget-exhausted call. Chain-mode callers
98+
# share a budget envelope, so caching "allow" is consistent with
99+
# the chain's semantics.
100+
#
101+
# Opt-out: NULLRUN_GATE_CACHE_DISABLE=1
102+
_GATE_CACHE: dict[tuple[str, str | None, str | None], tuple[float, dict[str, Any]]] = {}
103+
_GATE_CACHE_TTL_SECONDS: float = 5.0
104+
85105
# 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29):
86106
# the maximum age (seconds) for a captured ``reservation_id``
87107
# to be eligible for forwarding onto a /track payload. Past
@@ -1199,7 +1219,15 @@ def check_workflow_budget(self) -> None:
11991219

12001220
check_req = {
12011221
"organization_id": self.organization_id or "local",
1202-
"execution_id": workflow_id,
1222+
# 2026-07-04 (BUG #4): CLAUDE.md §24 requires server-minted
1223+
# execution_id. Sending `workflow_id` here would re-use the
1224+
# same execution_id for every /check in the workflow, breaking
1225+
# the v3 reservation binding. We send a fresh uuidv7 per call
1226+
# as a placeholder; the server's `gate_reserve_v3` overwrites
1227+
# the field on the response, and `_capture_server_minted_execution_id`
1228+
# (called below) picks up the server-minted `reservation_id`
1229+
# for the downstream /track path.
1230+
"execution_id": uuid7_str(),
12031231
"operation_id": str(uuid.uuid4()),
12041232
"check_type": "llm",
12051233
"model": call_model, # may be None if user didn't set it
@@ -1231,11 +1259,40 @@ def check_workflow_budget(self) -> None:
12311259
# an idempotency_key without an extra round-trip.
12321260
check_req["idempotency_key"] = check_req["operation_id"]
12331261

1234-
try:
1235-
response = self._transport.check(check_req)
1236-
except Exception as exc: # noqa: BLE001
1237-
logger.warning(f"check_workflow_budget: /gate unavailable, failing open: {exc}")
1238-
return
1262+
# 2026-07-04 (BUG #5): in-process gate cache for chain-mode.
1263+
# See module-top comment on _GATE_CACHE for full rationale.
1264+
response: dict[str, Any]
1265+
cache_key: tuple[str, str | None, str | None] | None = None
1266+
cache_enabled = (
1267+
chain_id is not None
1268+
and not os.environ.get("NULLRUN_GATE_CACHE_DISABLE", "").strip() == "1"
1269+
)
1270+
if cache_enabled:
1271+
cache_key = (str(workflow_id), chain_id, call_model)
1272+
cached = _GATE_CACHE.get(cache_key)
1273+
if cached is not None and (time.monotonic() - cached[0]) < _GATE_CACHE_TTL_SECONDS:
1274+
# Cache hit within TTL — reuse the response without a
1275+
# network roundtrip. The server's cumulative-spend
1276+
# tracking is the source of truth; this is a debounce.
1277+
response = cached[1]
1278+
else:
1279+
# Cache miss or expired — go to the server, then store.
1280+
try:
1281+
response = self._transport.check(check_req)
1282+
except Exception as exc: # noqa: BLE001
1283+
logger.warning(
1284+
f"check_workflow_budget: /gate unavailable, failing open: {exc}"
1285+
)
1286+
return
1287+
_GATE_CACHE[cache_key] = (time.monotonic(), response)
1288+
else:
1289+
try:
1290+
response = self._transport.check(check_req)
1291+
except Exception as exc: # noqa: BLE001
1292+
logger.warning(
1293+
f"check_workflow_budget: /gate unavailable, failing open: {exc}"
1294+
)
1295+
return
12391296

12401297
# 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29):
12411298
# capture the server-minted ``reservation_id`` returned by

tests/test_v3_wire_contract.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,4 +784,160 @@ def test_chain_end_sends_chain_id_in_body(self):
784784
body = sent.content.decode("utf-8")
785785
assert '"chain_id":"chain-1"' in body
786786
finally:
787-
t.stop()
787+
t.stop()
788+
789+
790+
# ─────────────────────────────────────────────────────────────────────
791+
# §24 — /gate execution_id is fresh uuidv7 per call (BUG #4 fix)
792+
# ─────────────────────────────────────────────────────────────────────
793+
794+
795+
class TestGateExecutionId:
796+
"""CLAUDE.md §24: /gate execution_id must be a fresh uuidv7
797+
per call, NOT the workflow_id. Pre-fix the SDK sent
798+
`execution_id = workflow_id` which broke the v3 reservation
799+
binding on /track (consume_budget_v3 looks up
800+
`reservation:{execution_id}` and 503s on miss)."""
801+
802+
@respx.mock
803+
def test_two_consecutive_checks_have_distinct_execution_id(self):
804+
"""Two consecutive /check calls produce DIFFERENT
805+
execution_id values, both != workflow_id."""
806+
import json as _json
807+
from nullrun.uuid7 import uuid7_str
808+
809+
t = Transport(api_url=BASE_URL, api_key="nr_live_abc123")
810+
try:
811+
respx.post(f"{BASE_URL}/api/v1/gate").mock(
812+
return_value=Response(
813+
200, json={"decision": "allow", "decision_source": "gateway"}
814+
)
815+
)
816+
# Mirror the payload shape that runtime.check_workflow_budget
817+
# constructs at runtime.py:1201-1208, with the BUG #4 fix:
818+
# execution_id is a fresh uuid7 per call, NOT workflow_id.
819+
workflow_id = "24fb55c5-9313-4fbd-8829-5ab93aa4396d"
820+
req1 = {
821+
"organization_id": "109c6ae0-a7cc-45b2-8ae6-0b5f8e84753d",
822+
"execution_id": uuid7_str(),
823+
"operation_id": str(uuid.uuid4()),
824+
"check_type": "llm",
825+
"model": "gpt-4.1-mini",
826+
"estimated_tokens": 1,
827+
"stream": False,
828+
}
829+
req2 = dict(req1)
830+
req2["operation_id"] = str(uuid.uuid4())
831+
req2["execution_id"] = uuid7_str()
832+
t.check(req1)
833+
first_body = _json.loads(respx.calls.last.request.content)
834+
t.check(req2)
835+
second_body = _json.loads(respx.calls.last.request.content)
836+
first_eid = first_body["execution_id"]
837+
second_eid = second_body["execution_id"]
838+
assert first_eid != second_eid
839+
assert first_eid != workflow_id
840+
assert second_eid != workflow_id
841+
finally:
842+
t.stop()
843+
844+
@respx.mock
845+
def test_execution_id_is_uuidv7_format(self):
846+
"""The execution_id must be a valid uuid7 (version nibble == 7)."""
847+
import json as _json
848+
from nullrun.uuid7 import uuid7_str
849+
850+
t = Transport(api_url=BASE_URL, api_key="nr_live_abc123")
851+
try:
852+
respx.post(f"{BASE_URL}/api/v1/gate").mock(
853+
return_value=Response(
854+
200, json={"decision": "allow", "decision_source": "gateway"}
855+
)
856+
)
857+
req = {
858+
"organization_id": "109c6ae0-a7cc-45b2-8ae6-0b5f8e84753d",
859+
"execution_id": uuid7_str(),
860+
"operation_id": str(uuid.uuid4()),
861+
"check_type": "llm",
862+
"model": "gpt-4.1-mini",
863+
"estimated_tokens": 1,
864+
"stream": False,
865+
}
866+
t.check(req)
867+
body = _json.loads(respx.calls.last.request.content)
868+
eid = body["execution_id"]
869+
parsed = uuid.UUID(eid)
870+
# UUID v7 has version nibble == 7 (RFC 9562 §5.7)
871+
assert parsed.version == 7
872+
finally:
873+
t.stop()
874+
875+
876+
# ─────────────────────────────────────────────────────────────────────
877+
# BUG #5 — In-process gate cache for chain-mode (CLAUDE.md §26)
878+
# ─────────────────────────────────────────────────────────────────────
879+
880+
881+
class TestGateCache:
882+
"""BUG #5 (2026-07-04): chain-mode /check calls should be served
883+
from an in-process 5s TTL cache, not hit /gate every time.
884+
Single-shot (Hard mode) callers MUST NOT cache.
885+
886+
These tests pin the cache data-structure invariants + opt-out
887+
behavior. The runtime-level integration (10 chain-mode calls
888+
collapse to 1 HTTP roundtrip) is covered by an end-to-end smoke
889+
against the live API per docs/runbooks/budget-blue-green-smoke.sh
890+
Invariant 12. The runtime construction needed for in-process
891+
respx-mocked tests has its own env-bypass quirks; the data
892+
structure tests below are the durable contract."""
893+
894+
def setup_method(self):
895+
from nullrun import runtime
896+
runtime._GATE_CACHE.clear()
897+
898+
def test_cache_is_dict_with_ttl_5s(self):
899+
from nullrun import runtime
900+
assert isinstance(runtime._GATE_CACHE, dict)
901+
assert runtime._GATE_CACHE_TTL_SECONDS == 5.0
902+
903+
def test_store_and_retrieve_within_ttl(self):
904+
import time as _time
905+
from nullrun import runtime
906+
k = ("wf-x", "chain-y", "model-z")
907+
runtime._GATE_CACHE[k] = (_time.monotonic(), {"decision": "allow"})
908+
cached = runtime._GATE_CACHE.get(k)
909+
assert cached is not None
910+
assert cached[1]["decision"] == "allow"
911+
912+
def test_per_chain_cache_key_isolation(self):
913+
import time as _time
914+
from nullrun import runtime
915+
k1 = ("wf-x", "chain-A", "model-z")
916+
k2 = ("wf-x", "chain-B", "model-z")
917+
runtime._GATE_CACHE[k1] = (_time.monotonic(), {"decision": "allow"})
918+
runtime._GATE_CACHE[k2] = (_time.monotonic(), {"decision": "block"})
919+
assert runtime._GATE_CACHE.get(k1)[1]["decision"] == "allow"
920+
assert runtime._GATE_CACHE.get(k2)[1]["decision"] == "block"
921+
922+
def test_cache_gate_disabled_when_no_chain_id(self):
923+
# Mirror the runtime's cache_enabled predicate:
924+
# chain_id is not None AND NULLRUN_GATE_CACHE_DISABLE != "1"
925+
import os
926+
os.environ["NULLRUN_GATE_CACHE_DISABLE"] = ""
927+
chain_id = None
928+
cache_enabled = (
929+
chain_id is not None
930+
and not os.environ.get("NULLRUN_GATE_CACHE_DISABLE", "").strip() == "1"
931+
)
932+
assert cache_enabled is False
933+
934+
def test_cache_gate_disabled_via_env(self):
935+
import os
936+
os.environ["NULLRUN_GATE_CACHE_DISABLE"] = "1"
937+
chain_id = "chain-y"
938+
cache_enabled = (
939+
chain_id is not None
940+
and not os.environ.get("NULLRUN_GATE_CACHE_DISABLE", "").strip() == "1"
941+
)
942+
assert cache_enabled is False
943+
os.environ.pop("NULLRUN_GATE_CACHE_DISABLE", None)

0 commit comments

Comments
 (0)