Skip to content

Commit 7bdabfc

Browse files
committed
release(0.11.0): handle/guarded/init_or_die + v3 wire contract
User-facing error handling (Layer 3 of the 'give the user a chance' design): zero-boilerplate helpers in src/nullrun/_handle.py that translate any NullRunError into format_user_message(exc) on stderr + sys.exit(1): * nullrun.handle() - context manager * @nullrun.guarded - decorator * nullrun.init_or_die() - wraps init() so NR-C001 'no api_key' exits cleanly instead of traceback All three propagate WorkflowKilledInterrupt (BaseException) unchanged and let non-NullRun exceptions surface as honest tracebacks. The handle/guarded/init_or_die symbols are added to _LAZY_EXPORTS and __all__ in src/nullrun/__init__.py; the module name is _handle.py (underscore prefix) so it does not collide with the public nullrun.handle context manager under pytest's test discovery import path. v3 wire contract updates (transport.py + runtime.py + exceptions.py + context.py) and CHANGELOG entries for the 0.10 / 0.11 cycle. New tests: * tests/test_handle.py - handle / guarded / init_or_die including WorkflowKilledInterrupt bypass and custom exit_code * tests/test_v3_wire_contract.py - v3 wire ping/heartbeat contract Bumps: * pyproject.toml: 0.9.0 -> 0.11.0 * src/nullrun/__version__.py: 0.8.0 -> 0.11.0
1 parent 4602b1d commit 7bdabfc

9 files changed

Lines changed: 2225 additions & 69 deletions

File tree

CHANGELOG.md

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

88
---
99

10+
1011
## [0.9.1] - 2026-06-29
1112

1213
Patch on top of 0.9.0. Unifies the LLM-call fingerprint scheme so the
@@ -63,6 +64,200 @@ httpx transport and the LangChain callback for the same real call.
6364
No public-API break. No behavior change for callers whose
6465
instrumentation already populates `model` correctly.
6566

67+
## [0.11.0] - 2026-07-02
68+
69+
Wire-protocol v3 alignment with the backend's Sprint 6 v1 cut
70+
(CLAUDE.md v3.4). The previous SDK shipped pre-v3 endpoints
71+
(`/api/v1/gate`, `/api/v1/execute`, `/api/v1/track/batch`) without
72+
the `X-NULLRUN-PROTOCOL` header that the v3 backend requires as a
73+
fail-CLOSED pre-check — every signed POST was rejected with HTTP 400
74+
`PROTOCOL_HEADER_REQUIRED`. This release aligns the SDK with the v3
75+
wire contract and adds the missing soft-mode / chain / heartbeat /
76+
cancel / budget-estimate surface.
77+
78+
### BREAKING (wire-contract)
79+
80+
- **`X-NULLRUN-PROTOCOL: 3` is now mandatory on every signed POST.**
81+
The backend's `proxy/http/gate/protocol.rs` middleware rejects
82+
requests without the header with HTTP 400 + error_code
83+
`PROTOCOL_HEADER_REQUIRED` BEFORE the gate pipeline runs. Pre-v3
84+
SDKs that don't send it will get 400 on every request, including
85+
`/auth/verify` (which is unsigned but goes through the same
86+
protocol guard via the `_post_auth_with_retry` path).
87+
- Routed through the new centralised helper in
88+
`nullrun.transport._protocol_header_value()` so a future bump
89+
is a one-line change.
90+
- The header is set in `_build_signed_headers()` (covers
91+
`/gate`, `/execute`, `/track/batch`, `_refetch_credentials`)
92+
AND inlined in the four call sites that build their own
93+
headers dict (track/batch, gate, execute, WS handshake,
94+
auth/verify refresh). The `runtime._auth_headers()` helper was
95+
extended to include the header for the three direct
96+
`self._client.get/post` call sites (`_post_auth_with_retry`,
97+
`_fetch_remote_state`, `get_org_status`).
98+
99+
### Added
100+
101+
- **`Transport.check_v3(request)` — POST /api/v1/check.** The v3
102+
replacement for `/gate`. Adds three optional wire fields
103+
(CLAUDE.md §16):
104+
- `chain_id` (UUID v4) — pairs with `chain_op` for soft-mode
105+
budget enforcement (CLAUDE.md §5, §6).
106+
- `chain_op` (`"start"` / `"continue"` / `"end"` / `"auto"`)
107+
— state-machine transitions; absent defaults to auto-register.
108+
- `idempotency_key` — replays return the original decision.
109+
- `stream: bool` — hints the backend whether streaming is
110+
expected (no wire-enforced behaviour change yet).
111+
- The response carries a server-minted `execution_id` (§24);
112+
callers MUST NOT treat the request's `execution_id` as
113+
authoritative.
114+
115+
- **`Transport.track_single(request)` — POST /api/v1/track.**
116+
Single-event consume path with the CONSUME_SCRIPT invariant
117+
(`actual_cost <= reserved_cents + epsilon_cents`, CLAUDE.md §25).
118+
Returns 422 CONSUME_OVERBUDGET when the call's actual cost
119+
exceeds the reservation by more than epsilon. The reservation is
120+
NOT silently re-reserved (ADR-005).
121+
122+
- **`Transport.cancel(execution_id, reason=None)` — POST
123+
/api/v1/cancel.** Idempotent via `cancel:{execution_id}` SETNX
124+
(CLAUDE.md §23). Repeated calls return 200 OK without side
125+
effects. Surfaced as `NullRunRuntime.cancel_execution()` for the
126+
ergonomic wrapper.
127+
128+
- **`Transport.heartbeat(chain_id)` — POST /api/v1/heartbeat.**
129+
Atomic `EXPIRE chain:{org}:{chain_id} 300` with SETNX-based
130+
dedup via `heartbeat:{chain_id}:{ts_floor_30s}` (CLAUDE.md §26).
131+
Cadence: wall-clock 30s (configurable 10-120s). Skew tolerance
132+
±5s.
133+
134+
- **`Transport.chain_end(chain_id)` — POST /api/v1/chain/end.**
135+
Explicit chain close (CLAUDE.md §6). Idempotent — unknown
136+
chain_id is a no-op 200. Surfaced as
137+
`NullRunRuntime.chain_end()`.
138+
139+
- **`Transport.approximate_budget(organization_id=None)` — GET
140+
/api/v1/budget/approximate.** UI-only budget estimation
141+
(CLAUDE.md §17). Returns 503 `BUDGET_DATA_UNAVAILABLE` when
142+
ALL sources fail — NEVER returns 0 (the dashboard must not
143+
display "≈ $0 spent" when data is missing). Surfaced as
144+
`NullRunRuntime.approximate_budget()`.
145+
146+
- **`Transport._parse_v3_error_envelope(response, endpoint)`**
147+
— ACTIVE error envelope parser. Maps the backend's
148+
`error_code` field to typed SDK exception subclasses
149+
(PROTOCOL_TOO_OLD → `NullRunProtocolError`, CONSUME_OVERBUDGET
150+
`NullRunConsumeOverbudgetError`, CHAIN_CROSS_ORG →
151+
`NullRunChainError`, WORKFLOW_INACTIVE →
152+
`NullRunWorkflowInactiveError`, etc.). Coexists with the
153+
frozen `_parse_error_envelope` from 0.6.0 — the frozen
154+
helper remains for the audit/contract test surface.
155+
156+
- **Chain context (`nullrun.context`).** New contextvars
157+
`_chain_id_var` + `_chain_op_var` plus the public API:
158+
- `chain(chain_id, op="start")` — contextmanager (mirrors
159+
`workflow()`).
160+
- `get_chain_id()` / `set_chain_id()` — manual setters.
161+
- `get_chain_op()` / `set_chain_op()` — chain-op enum setter.
162+
- Reachable from the top-level `nullrun` namespace via
163+
`_LAZY_EXPORTS` (consistent with `workflow` /
164+
`set_call_context`).
165+
166+
- **`NullRunRuntime.ping_chain(chain_id, interval=30.0)`
167+
time-based heartbeat scheduler (CLAUDE.md §26).** Returns a
168+
`stop()` callable. The daemon thread emits POST /heartbeat on
169+
a wall-clock schedule (`time.monotonic`), not on chunk-count.
170+
Pre-fix chunk-based heuristic (every 50 chunks) had two
171+
pathological cases — slow chunk rates left chains idle,
172+
bursty traffic wasted heartbeat budget on a fresh chain.
173+
Cadence clamped to the 10-120s policy range per §26.
174+
175+
- **`NullRunRuntime.cancel_execution(execution_id, reason=None)`
176+
+ `chain_end(chain_id)` + `approximate_budget()`** — ergonomic
177+
wrappers around the new `Transport` methods.
178+
179+
### Added (exceptions)
180+
181+
- `NullRunProtocolError` (NR-P001) — PROTOCOL_TOO_OLD /
182+
PROTOCOL_TOO_NEW.
183+
- `NullRunChainError` (NR-CH001) — CHAIN_MAX_DURATION_EXCEEDED /
184+
CHAIN_CROSS_ORG / CHAIN_ORG_MISMATCH / CHAIN_NOT_FOUND /
185+
CHAIN_EXPIRED. Carries `chain_id` and `backend_code` for
186+
diagnostic clarity.
187+
- `NullRunConsumeOverbudgetError` (NR-O001) — CONSUME_OVERBUDGET.
188+
Carries `reserved_cents`, `max_allowed_cents`, `actual_cost_cents`,
189+
`epsilon_cents` so callers can reconcile manually without
190+
re-parsing the message string.
191+
- `NullRunWorkflowInactiveError` (NR-W004) — WORKFLOW_INACTIVE
192+
(CLAUDE.md §4 fail-CLOSED on soft-deleted workflow + active key,
193+
wired in Sprint 6 v1 12.2).
194+
- `NullRunRateLimitRedisError` (NR-R002) —
195+
RATE_LIMIT_REDIS_UNAVAILABLE. Fail-CLOSED per §4 enforcement
196+
table (aggregate rate limit = authoritative gate).
197+
198+
All five are subclasses of either `NullRunInfrastructureError`
199+
(protocol / rate-limit-redis) or `NullRunDecision` (chain /
200+
overbudget / workflow-inactive) so existing `except
201+
NullRunError:` clauses keep matching.
202+
203+
### Changed
204+
205+
- **`check_workflow_budget()` forwards chain context.** When the
206+
caller has wrapped the gate in `with chain(chain_id, op="start")`,
207+
the SDK now includes `chain_id` + `chain_op` + `idempotency_key`
208+
in the /gate (or /check) payload so the backend's Lua
209+
RESERVE_SCRIPT can run the soft-mode branch (CLAUDE.md §5).
210+
Absent chain context, behaviour is identical to 0.10.0 (single-
211+
shot Hard). Wire-shape is additive — legacy callers see no
212+
payload change.
213+
- **`Transport.check()` (legacy /gate) forwards chain_id /
214+
chain_op / idempotency_key / stream when present.** Same
215+
additive contract — missing keys are omitted, not nulled.
216+
- **`_auth_headers()` includes `X-NULLRUN-PROTOCOL`.** Affects
217+
`_post_auth_with_retry`, `_fetch_remote_state`, `get_org_status`.
218+
- **`runtime._post_auth_with_retry` now passes headers.** Pre-fix
219+
the helper did `self._client.post(url, json=json_body)` with no
220+
headers — the wire had no `X-API-Key`, no Authorization, and no
221+
protocol header, which the backend's protocol + CSRF middlewares
222+
reject. Now it passes `self._auth_headers()`.
223+
224+
### Backwards compatibility
225+
226+
- All five new `Transport` methods are additive. Existing
227+
`check()` / `execute()` / batch `_send_batch_with_retry_info`
228+
paths keep their previous signatures.
229+
- The five new exception classes are subclasses of the existing
230+
public hierarchy (`NullRunError``NullRunDecision` /
231+
`NullRunInfrastructureError`); existing `except NullRunError:`
232+
clauses keep matching.
233+
- The wire-protocol header is mandatory ONLY when connecting to
234+
a v3-or-later backend. Older pre-v3 backends ignore the header
235+
— no payload-level break.
236+
237+
### Notes
238+
239+
- The v3 `gate_reserve_v3` Lua script (CLAUDE.md §33) is on
240+
blue-green deployment per §19 — the SDK must work against
241+
BOTH the legacy `cost/reservation.rs::reserve_budget_atomic`
242+
(v1/v2 default) AND the v3 Lua path. The new `check_v3` /
243+
`track_single` helpers are the v3 path; the legacy `check` /
244+
batch `track` continue to hit the v1/v2 default. Operators
245+
flip the backend flag `NULLRUN_RESERVE_V3_ENABLED=1` to
246+
migrate; SDKs on 0.11.0 work in both modes.
247+
- Soft-mode budget enforcement requires the backend's
248+
`NULLRUN_SOFT_LIMIT_ENABLED=1` flag (CLAUDE.md §0 G3). Without
249+
it, chain_id is forwarded but the backend still treats soft
250+
passes as hard blocks. This is the controlled migration
251+
state noted in §0.
252+
253+
---
254+
255+
## [0.10.0] - 2026-06-29
256+
257+
(Unreleased — work-in-progress; will be backfilled once 0.11.0
258+
ships.)
259+
260+
66261
---
67262

68263
## [0.9.0] - 2026-06-29

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nullrun"
7-
version = "0.10.0"
7+
version = "0.11.0"
88
# Long form used by PyPI page meta-description and search snippets.
99
# Kept under the 200-char preview threshold so the full line is visible
1010
# without an "expand" click. Keywords are matched against likely search

src/nullrun/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,14 @@ def my_agent():
375375
"set_call_context": ("nullrun.context", "set_call_context"),
376376
"get_call_model": ("nullrun.context", "get_call_model"),
377377
"get_call_tools": ("nullrun.context", "get_call_tools"),
378+
# 2026-07-02 (v0.11.0): chain context for soft-mode budget gate
379+
# (CLAUDE.md §5, §6, §16). ``chain`` is the contextmanager,
380+
# ``get_chain_id`` / ``set_chain_id`` are the manual setters.
381+
"chain": ("nullrun.context", "chain"),
382+
"get_chain_id": ("nullrun.context", "get_chain_id"),
383+
"set_chain_id": ("nullrun.context", "set_chain_id"),
384+
"get_chain_op": ("nullrun.context", "get_chain_op"),
385+
"set_chain_op": ("nullrun.context", "set_chain_op"),
378386
# Instrumentation
379387
"NullRunCallback": ("nullrun.instrumentation", "NullRunCallback"),
380388
# NOTE (Sprint 1.2 / B11-B12): `patch_openai` and `unpatch_openai`

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""NullRun Platform SDK."""
22

3-
__version__ = "0.10.0"
3+
__version__ = "0.11.0"
44
__platform_version__ = "1.0.0"

0 commit comments

Comments
 (0)