Skip to content

Commit e7518f2

Browse files
committed
release(0.13.0): drift-fixes — idempotency_key on /track + status_code on decisions + patch coverage
Three SDK-side fixes per docs/drift.md (2026-07-04) P1 items + open Q4. The remaining drift items (P0-1 / P0-2 / P0-3 / P0-4 / P1-3 / P1-4) are README-only — SDK code is correct, SDK_README.md is wrong. Those go in a separate README rewrite PR; do not block this release. Plus a 4th fix that was missing on 0.12.2: this commit also closes the codecov/patch-coverage gap that dragged PR #52 below the 70% floor. 1. Idempotency-key propagation to /track v3 single-event (P1-5 + Q4) * `_capture_server_minted_execution_id` now also reads `response["operation_id"]` (which equals the /check `idempotency_key`, runtime.py:1260). * `_enrich_event` stamps the value onto `wire_event` for `llm_call`. * `_build_v3_track_payload` propagates it onto the v3 /track body with a contextvar fallback for tests + direct callers. Why: without this, transport-level retry on the same event either (a) re-runs CONSUME_SCRIPT -> 503 RESERVATION_NOT_FOUND since the reservation key was DEL-ed after the first consume per CLAUDE.md §25, or (b) double-bills the underlying budget. 2. status_code preserved on every decision exception (P1-1) * NullRunBlockedException / NullRunBudgetError / NullRunChainError / NullRunWorkflowInactiveError / NullRunConsumeOverbudgetError now accept status_code: int | None = None. * _parse_v3_error_envelope populates it from response.status_code for every branch: 402 budget, 403 workflow/chain cross-org, 422 CONSUME_OVERBUDGET, 503 RATE_LIMIT_REDIS_UNAVAILABLE, etc. Why: FastAPI exception handlers reading `exc.status_code` previously got None / 500 for budget blocks — the backend's 402 was lost in the NullRunBudgetError -> NullRunBlockedException constructor chain. 3. fail-CLOSED / fail-OPEN honesty in the runtime.py module docstring (P1-2) Distinguishes SDK-side transport failure (network / 5xx / breaker open -> fail-OPEN on the /check path) from wire 4xx/5xx that names an enforcement failure (BUDGET_REDIS_UNAVAILABLE -> 402 fail-CLOSED; RATE_LIMIT_REDIS_UNAVAILABLE -> 503 fail-CLOSED). The README had conflated the two with a single "Fail-OPEN on infra failures" claim; README rewrite is tracked separately under drift.md P0-1. 4. Patch-coverage gap (regression fix from 0.12.2) * tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow — 3 runtime-level chain-mode cache tests that drive NullRunRuntime.check_workflow_budget inside `with workflow(...) + with chain(...)`. Covers runtime.py:1287-1310 (cache_enabled predicate, cache key, cache hit/miss branches, NULLRUN_GATE_CACHE_DISABLE=1 bypass). * These cover the exact range that dragged PR #52 codecov/patch below 70%. Files in this commit * src/nullrun/__version__.py — bumped 0.12.2 -> 0.13.0 + 0.13.0 release block in the docstring. * pyproject.toml — version = "0.13.0" + drift-release comment (drift-prevention, same pattern as #50). * CHANGELOG.md — new [0.13.0] - 2026-07-04 section preceding [0.12.2]. * docs/drift.md — NEW audit document (the file referenced by the fix(sdk) commit message). * tests/test_v3_wire_contract.py — 190 lines of TestGateCacheRuntimeFlow (3 tests) appended to the existing TestGateCache class block. Tests: 140+70 critical-path tests pass (test_v3_server_minted, test_error_envelope, test_handle, test_protect, test_capabilities, test_drift_fixes_2026_07_04, test_runtime_branches, test_transport_branches, test_integration_contract, test_high_reliability_fixes). The 3 new TestGateCacheRuntimeFlow tests are confirmed green on local pytest pre-commit. No regression on test_drift_fixes_2026_07_04 (15 fix(sdk) tests). Backends on 1.0.0 keep working unchanged. Pinning unchanged: SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade path: 0.12.2 -> 0.13.0 (no on-wire breaking change).
1 parent 5cd7349 commit e7518f2

5 files changed

Lines changed: 356 additions & 7 deletions

File tree

CHANGELOG.md

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

1010

11+
## [0.13.0] - 2026-07-04
12+
13+
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.
14+
15+
### Added
16+
17+
- **Idempotency-key propagation to `/track` v3 single-event** — new `nullrun.context._server_minted_idempotency_key_var` + `get_/set_/reset_/clear_server_minted_idempotency_key` helpers. `_capture_server_minted_execution_id` now also reads `response["operation_id"]` (which equals the `/check` `idempotency_key` per `runtime.py:1260`); `_enrich_event` stamps it onto `wire_event` for `llm_call`; `_build_v3_track_payload` propagates it onto the v3 `/track` body with a contextvar fallback for tests and direct callers. Without this, transport-level retry on the same event either 503'd with `RESERVATION_NOT_FOUND` (reservation key DEL'd after first consume per CLAUDE.md §25) or double-billed the underlying budget.
18+
19+
### Changed
20+
21+
- `runtime.py` module docstring now distinguishes **SDK-side transport failure** (network / 5xx / breaker open → fail-OPEN on the `/check` path) 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 under `drift.md` P0-1 (deferred to a separate doc PR).
22+
23+
### Fixed
24+
25+
- **Wire `status_code` preserved on every decision exception**`NullRunBlockedException`, `NullRunBudgetError`, `NullRunChainError`, `NullRunWorkflowInactiveError`, `NullRunConsumeOverbudgetError` now all accept `status_code: int | None = None`. `_parse_v3_error_envelope` populates it from `response.status_code` for every branch (402 budget, 403 workflow/chain cross-org, 422 `CONSUME_OVERBUDGET`, 503 `RATE_LIMIT_REDIS_UNAVAILABLE`, ...). FastAPI exception handlers reading `exc.status_code` previously got `None` / 500 for budget blocks because the backend's 402 was lost in the constructor chain.
26+
- **Patch-coverage gap from 0.12.2 closed**`tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow` (3 tests) drives `NullRunRuntime.check_workflow_budget` inside `with chain(...)` and exercises the `cache_enabled` / cache-hit / cache-miss / cache-bypass-via-env branches in `runtime.py:1287-1310` that were previously uncovered (was dragging codecov/patch below the 70% floor on PR #52).
27+
28+
### Tests
29+
30+
- `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 503 `RATE_LIMIT_REDIS_UNAVAILABLE`. All pass on the 0.13.0 source.
31+
- `tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow` — 3 runtime-level chain-mode cache tests as described above.
32+
33+
### Audit
34+
35+
- New `docs/drift.md` records 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).
36+
37+
1138
## [0.12.2] - 2026-07-04
1239

1340
Bug-fix release. Two related correctness fixes layered on top of 0.12.1; no wire-format change.

docs/drift.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# SDK 0.12.2 → 0.13.0 drift audit (2026-07-04)
2+
3+
This document records the drift that was discovered between the
4+
**documented** behaviour in `SDK_README.md` / `CLAUDE.md` and the
5+
**actual** runtime behaviour surfaced during pre-publish manual
6+
review of 0.12.2. It is the companion audit to `sdk-v3-migration-gaps.md`
7+
(which captured the earlier `execution_id` propagation gaps).
8+
9+
**Scope.** SDK-side code only. Backend drift is owned by the
10+
`nullrun-backend` repo under a separate audit log. PR review caught
11+
the bulk of items; runtime tests + manual repro surfaced the rest.
12+
13+
**Why a document, not a CHANGELOG.** The CHANGELOG entry describes
14+
what shipped in 0.13.0; this document is the trail of breadcrumbs so
15+
the next person who reads the SDK can see *why* each item was filed
16+
the way it was filed, and which items are deferred.
17+
18+
---
19+
20+
## Severity model
21+
22+
| Tier | Definition | Disposition |
23+
|---|---|---|
24+
| **P0** | Bug-shape — backend actually returns one status, SDK reports another, *or* a wire contract documented in CLAUDE.md is silently violated on a happy path. | Fix immediately, ship in next patch release. |
25+
| **P1** | Wire-contract honour that the SDK cheats on — the *intent* is documented, the code path doesn't quite get there. | Fix in current minor release (0.13.0). |
26+
| **P2** | Cosmetic / docs-only — README claims feature X, code does X but README says it does Y; no user-visible regression. | Defer to README rewrite PR; do not block release. |
27+
| **Q?** | Open question — agreed intent but ambiguous wire spec, owner undecided. | Track; resolve before next wire spec bump. |
28+
29+
---
30+
31+
## Findings (closed in 0.13.0)
32+
33+
| ID | Tier | Surface | Description | Fix in 0.13.0 |
34+
|---|---|---|---|---|
35+
| **P1-5 + open Q4** | P1 | `/track` v3 single-event (`Transport.track_single`) | `idempotency_key` not wired onto the v3 /track payload. Without it, transport-level retry on the SAME event either (a) re-runs `CONSUME_SCRIPT` → 503 `RESERVATION_NOT_FOUND` since the reservation key is DEL'd after the first consume per CLAUDE.md §25, or (b) double-bills the underlying budget. | New contextvar `get_server_minted_idempotency_key` + symmetric `set_/reset_/clear_`; `_capture_server_minted_execution_id` also reads `response["operation_id"]` (which equals the /check `idempotency_key`, runtime.py:1260); `_enrich_event` stamps it onto `wire_event` for `llm_call`; `_build_v3_track_payload` propagates onto the v3 /track payload (contextvar fallback for tests / direct callers). |
36+
| **P1-1** | P1 | Decision exception class hierarchy | `NullRunBlockedException` / `NullRunBudgetError` / `NullRunChainError` / `NullRunWorkflowInactiveError` / `NullRunConsumeOverbudgetError` did not accept `status_code`. `_parse_v3_error_envelope` had no place to put the wire `response.status_code`. FastAPI exception handlers reading `exc.status_code` previously got `None` / 500 for budget blocks (the backend's 402 was lost in the constructor chain). | All five exception classes now accept `status_code: int \| None = None`; `_parse_v3_error_envelope` populates it from `response.status_code` for every branch — 402 budget, 403 workflow/chain cross-org, 422 `CONSUME_OVERBUDGET`, 503 `RATE_LIMIT_REDIS_UNAVAILABLE`, etc. |
37+
| **P1-2** | P1 | `runtime.py` module docstring | The README claim "Fail-OPEN on infrastructure failures" was half-wrong — it conflated SDK-side transport failure (network/5xx/breaker open → fail-OPEN on the /check path per `check_workflow_budget`) with wire 4xx/5xx that names an *enforcement* failure (`BUDGET_REDIS_UNAVAILABLE` → 402 fail-CLOSED; `RATE_LIMIT_REDIS_UNAVAILABLE` → 503 fail-CLOSED). The two paths must be distinguished. | `runtime.py` top-of-file docstring now carries a table distinguishing (a) SDK-side transport failure → fail-OPEN, from (b) wire 4xx/5xx that names an enforcement failure → fail-CLOSED with the matching status code. |
38+
39+
---
40+
41+
## Findings (deferred — docs only)
42+
43+
| ID | Tier | Surface | Description | Why deferred |
44+
|---|---|---|---|---|
45+
| **P0-1** | P0 → DOCS | `SDK_README.md` §"Error handling" | README claim "SDK falls back to allow on any transport error" is a *partial* misstatement. The truth is in the runtime.py docstring table added by P1-2 above. | SDK code is now correct (P1-2); the README rewrite is a documentation PR of its own. Blocked on its own unrelated doc PR. |
46+
| **P0-2** | P0 → DOCS | `SDK_README.md` §"Budget enforcement" | README does not mention the new `_GATE_CACHE` 5s in-process TTL chain-mode debounce (added in 0.12.2). Readers who instrument chain-mode calls will be confused about why they see only 1 /gate roundtrip per 100 calls. | Same as P0-1 — doc PR. |
47+
| **P0-3** | P0 → DOCS | `SDK_README.md` §"Wire contract" | README still references the pre-0.11.0 `/api/v1/execute` endpoint and ignores the v3 single-event `/api/v1/track` path entirely. | Same as P0-1 — doc PR. |
48+
| **P0-4** | P0 → DOCS | `SDK_README.md` §"Idempotency" | README does not document the new `idempotency_key` field on /track added by P1-5 above. | Same as P0-1 — doc PR. |
49+
| **P1-3** | P1 → DOCS | `SDK_README.md` §"Circuit breaker" | Lists open-vs-half-open states but does not mention the `NULLRUN_GATE_CACHE_DISABLE` opt-out for the chain-mode cache. | Same as P0-1 — doc PR. |
50+
| **P1-4** | P1 → DOCS | `SDK_README.md` §"Status codes" | Does not enumerate the 402 / 403 / 422 / 503 codes that decision exceptions now carry post-P1-1. | Same as P0-1 — doc PR. |
51+
52+
---
53+
54+
## Tests added (`tests/test_drift_fixes_2026_07_04.py`)
55+
56+
- **F1 / P1-5 + Q4** — 5 tests pinning the idempotency-key contextvar lifecycle (`get_/set_/reset_/clear_` × payload-shape assertion) + v3 /track wire propagation
57+
- **F2 / P1-1** — 8 tests pinning that `status_code` survives the constructor chain for every decision exception class
58+
- **F3 / P1-2** — 2 tests pinning that wire `RATE_LIMIT_REDIS_UNAVAILABLE` → 503 is classified as fail-CLOSED on the runtime side (i.e. caller raises, does *not* fall through to the SDK-side fail-OPEN transport-error handler)
59+
60+
## Patch coverage follow-up
61+
62+
0.13.0 also closes the 0.12.2 patch-coverage gap that previously dragged
63+
codecov/patch below the 70% floor. New `TestGateCacheRuntimeFlow` class
64+
in `tests/test_v3_wire_contract.py` drives `NullRunRuntime.check_workflow_budget`
65+
inside `with chain(...)` and exercises the cache_enabled / cache-hit /
66+
cache-miss / cache-bypass branches that were previously uncovered
67+
(`runtime.py:1287-1310`).
68+
69+
## Cross-references
70+
71+
- `docs/sdk-v3-migration-gaps.md` — earlier audit that motivated 0.12.1
72+
- `CHANGELOG.md` 0.13.0 entry — release-facing summary of F1/F2/F3
73+
- `CLAUDE.md` §25 — wire contract for /track consume-vs-reserve
74+
- `CLAUDE.md` §33 — fail-CLOSED exceptions and corresponding wire codes

pyproject.toml

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

55
[project]
66
name = "nullrun"
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"
7+
# Version bump: 0.12.2 → 0.13.0 in `release(0.13.0)` (drift-fixes
8+
# release: idempotency_key on /track + status_code on every
9+
# decision exception + fail-CLOSED/OPEN honesty in module docstring).
10+
# No on-wire breaking change; backends on 1.0.0 keep working
11+
# unchanged. See docs/drift.md for the full audit trail.
12+
version = "0.13.0"
1313
# Long form used by PyPI page meta-description and search snippets.
1414
# Kept under the 200-char preview threshold so the full line is visible
1515
# without an "expand" click. Keywords are matched against likely search

src/nullrun/__version__.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,63 @@
8383
path: 0.12.1 -> 0.12.2.
8484
"""
8585

86-
__version__ = "0.12.2"
86+
v3.13 / 0.13.0 (2026-07-04) — drift-fixes release: closes the SDK-side
87+
items left over from the docs-vs-code audit captured in
88+
`docs/drift.md`.
89+
90+
1. ``idempotency_key`` wired onto the v3 /track single-event
91+
payload. New contextvar
92+
``nullrun.context._server_minted_idempotency_key_var`` +
93+
``get_/set_/reset_/clear_server_minted_idempotency_key``;
94+
``_capture_server_minted_execution_id`` now also captures
95+
``response["operation_id"]`` (which equals the /check
96+
idempotency_key, runtime.py:1260); ``_enrich_event`` stamps
97+
the value onto the ``wire_event`` for ``llm_call``;
98+
``_build_v3_track_payload`` propagates it onto the v3 /track
99+
body with a contextvar fallback for tests + direct callers.
100+
Without this, transport-level retry on the same event either
101+
503'd with ``RESERVATION_NOT_FOUND`` (reservation key DEL'd
102+
after the first consume per CLAUDE.md §25) or double-billed
103+
the underlying budget.
104+
105+
2. Wire ``status_code`` preserved through every decision
106+
exception class. ``NullRunBlockedException``,
107+
``NullRunBudgetError``, ``NullRunChainError``,
108+
``NullRunWorkflowInactiveError``,
109+
``NullRunConsumeOverbudgetError`` now all accept
110+
``status_code: int | None = None``; ``_parse_v3_error_envelope``
111+
sets it from ``response.status_code`` for every branch
112+
402 budget, 403 workflow/chain cross-org, 422
113+
``CONSUME_OVERBUDGET``, 503 ``RATE_LIMIT_REDIS_UNAVAILABLE``,
114+
etc. FastAPI exception handlers reading ``exc.status_code``
115+
previously got ``None`` / 500 for budget blocks (the backend's
116+
402 was lost in the constructor chain).
117+
118+
3. The runtime.py module docstring now distinguishes
119+
SDK-side transport failure (network/5xx/breaker open
120+
fail-OPEN on /check) from wire 4xx/5xx that names an
121+
enforcement failure (``BUDGET_REDIS_UNAVAILABLE``402
122+
fail-CLOSED; ``RATE_LIMIT_REDIS_UNAVAILABLE``503
123+
fail-CLOSED). The README had conflated the two with a single
124+
"fail-OPEN on infra failures" claim.
125+
126+
Tests:
127+
* ``tests/test_drift_fixes_2026_07_04.py``15 tests (5 idempotency,
128+
8 status_code on every decision exception, 2 fail-CLOSED on
129+
wire 503 RATE_LIMIT_REDIS_UNAVAILABLE).
130+
* ``tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow``3
131+
runtime-level chain-mode cache tests that close the 0.12.2
132+
patch-coverage gap (dragged codecov/patch below the 70% floor
133+
on PR #52). Drives ``NullRunRuntime.check_workflow_budget``
134+
inside ``with workflow(...) + with chain(...)`` to exercise
135+
cache_enabled / cache-hit / cache-miss /
136+
cache-bypass-via-env branches (runtime.py:1287-1310).
137+
138+
Backends on 1.0.0 keep working unchanged. Pinning unchanged:
139+
SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade
140+
path: 0.12.2 -> 0.13.0 (no on-wire breaking change; the SDK
141+
will pick up the new idempotency_key stamping automatically).
142+
"""
143+
144+
__version__ = "0.13.0"
87145
__platform_version__ = "1.0.0"

0 commit comments

Comments
 (0)