Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
---


## [0.13.0] - 2026-07-04

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.

### Added

- **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.

### Changed

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

### Fixed

- **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.
- **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).

### Tests

- `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.
- `tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow` — 3 runtime-level chain-mode cache tests as described above.

### Audit

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


## [0.12.2] - 2026-07-04

Bug-fix release. Two related correctness fixes layered on top of 0.12.1; no wire-format change.
Expand Down
74 changes: 74 additions & 0 deletions docs/drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# SDK 0.12.2 → 0.13.0 drift audit (2026-07-04)

This document records the drift that was discovered between the
**documented** behaviour in `SDK_README.md` / `CLAUDE.md` and the
**actual** runtime behaviour surfaced during pre-publish manual
review of 0.12.2. It is the companion audit to `sdk-v3-migration-gaps.md`
(which captured the earlier `execution_id` propagation gaps).

**Scope.** SDK-side code only. Backend drift is owned by the
`nullrun-backend` repo under a separate audit log. PR review caught
the bulk of items; runtime tests + manual repro surfaced the rest.

**Why a document, not a CHANGELOG.** The CHANGELOG entry describes
what shipped in 0.13.0; this document is the trail of breadcrumbs so
the next person who reads the SDK can see *why* each item was filed
the way it was filed, and which items are deferred.

---

## Severity model

| Tier | Definition | Disposition |
|---|---|---|
| **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. |
| **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). |
| **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. |
| **Q?** | Open question — agreed intent but ambiguous wire spec, owner undecided. | Track; resolve before next wire spec bump. |

---

## Findings (closed in 0.13.0)

| ID | Tier | Surface | Description | Fix in 0.13.0 |
|---|---|---|---|---|
| **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). |
| **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. |
| **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. |

---

## Findings (deferred — docs only)

| ID | Tier | Surface | Description | Why deferred |
|---|---|---|---|---|
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |

---

## Tests added (`tests/test_drift_fixes_2026_07_04.py`)

- **F1 / P1-5 + Q4** — 5 tests pinning the idempotency-key contextvar lifecycle (`get_/set_/reset_/clear_` × payload-shape assertion) + v3 /track wire propagation
- **F2 / P1-1** — 8 tests pinning that `status_code` survives the constructor chain for every decision exception class
- **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)

## Patch coverage follow-up

0.13.0 also closes the 0.12.2 patch-coverage gap that previously dragged
codecov/patch below the 70% floor. New `TestGateCacheRuntimeFlow` class
in `tests/test_v3_wire_contract.py` drives `NullRunRuntime.check_workflow_budget`
inside `with chain(...)` and exercises the cache_enabled / cache-hit /
cache-miss / cache-bypass branches that were previously uncovered
(`runtime.py:1287-1310`).

## Cross-references

- `docs/sdk-v3-migration-gaps.md` — earlier audit that motivated 0.12.1
- `CHANGELOG.md` 0.13.0 entry — release-facing summary of F1/F2/F3
- `CLAUDE.md` §25 — wire contract for /track consume-vs-reserve
- `CLAUDE.md` §33 — fail-CLOSED exceptions and corresponding wire codes
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ build-backend = "hatchling.build"

[project]
name = "nullrun"
# Version bump: 0.12.1 → 0.12.2 in `release(0.12.2)` (fresh
# execution_id per /check + in-process chain-mode gate cache). The
# runtime commit bumped `src/nullrun/__version__.py` together with
# this field — same drift prevention as #50, but proactive this
# time (caught during pre-merge audit, not after a publish error).
version = "0.12.2"
# Version bump: 0.12.2 → 0.13.0 in `release(0.13.0)` (drift-fixes
# release: idempotency_key on /track + status_code on every
# decision exception + fail-CLOSED/OPEN honesty in module docstring).
# No on-wire breaking change; backends on 1.0.0 keep working
# unchanged. See docs/drift.md for the full audit trail.
version = "0.13.0"
# Long form used by PyPI page meta-description and search snippets.
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. Keywords are matched against likely search
Expand Down
61 changes: 60 additions & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,66 @@
1.0.0 keep working unchanged. Pinning unchanged:
SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade
path: 0.12.1 -> 0.12.2.

---

v3.13 / 0.13.0 (2026-07-04) — drift-fixes release: closes the SDK-side
items left over from the docs-vs-code audit captured in
`docs/drift.md`.

1. ``idempotency_key`` wired onto the v3 /track single-event
payload. New contextvar
``nullrun.context._server_minted_idempotency_key_var`` +
``get_/set_/reset_/clear_server_minted_idempotency_key``;
``_capture_server_minted_execution_id`` now also captures
``response["operation_id"]`` (which equals the /check
idempotency_key, runtime.py:1260); ``_enrich_event`` stamps
the value onto the ``wire_event`` for ``llm_call``;
``_build_v3_track_payload`` propagates it onto the v3 /track
body with a contextvar fallback for tests + direct callers.
Without this, transport-level retry on the same event either
503'd with ``RESERVATION_NOT_FOUND`` (reservation key DEL'd
after the first consume per CLAUDE.md §25) or double-billed
the underlying budget.

2. Wire ``status_code`` preserved through every decision
exception class. ``NullRunBlockedException``,
``NullRunBudgetError``, ``NullRunChainError``,
``NullRunWorkflowInactiveError``,
``NullRunConsumeOverbudgetError`` now all accept
``status_code: int | None = None``; ``_parse_v3_error_envelope``
sets it from ``response.status_code`` for every branch —
402 budget, 403 workflow/chain cross-org, 422
``CONSUME_OVERBUDGET``, 503 ``RATE_LIMIT_REDIS_UNAVAILABLE``,
etc. FastAPI exception handlers reading ``exc.status_code``
previously got ``None`` / 500 for budget blocks (the backend's
402 was lost in the constructor chain).

3. The runtime.py module docstring now distinguishes
SDK-side transport failure (network/5xx/breaker open →
fail-OPEN on /check) 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.

Tests:
* ``tests/test_drift_fixes_2026_07_04.py`` — 15 tests (5 idempotency,
8 status_code on every decision exception, 2 fail-CLOSED on
wire 503 RATE_LIMIT_REDIS_UNAVAILABLE).
* ``tests/test_v3_wire_contract.py::TestGateCacheRuntimeFlow`` — 3
runtime-level chain-mode cache tests that close the 0.12.2
patch-coverage gap (dragged codecov/patch below the 70% floor
on PR #52). Drives ``NullRunRuntime.check_workflow_budget``
inside ``with workflow(...) + with chain(...)`` to exercise
cache_enabled / cache-hit / cache-miss /
cache-bypass-via-env branches (runtime.py:1287-1310).

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; the SDK
will pick up the new idempotency_key stamping automatically).
"""

__version__ = "0.12.2"
__version__ = "0.13.0"
__platform_version__ = "1.0.0"
37 changes: 36 additions & 1 deletion src/nullrun/breaker/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,11 +426,17 @@ def __init__(
chain_id: str | None = None,
backend_code: str | None = None,
details: dict[str, Any] | None = None,
status_code: int | None = None,
**kwargs: Any,
) -> None:
self.chain_id = chain_id
self.backend_code = backend_code or self.error_code
self.details = details or {}
# 2026-07-04 (drift.md P1-1): preserve the wire HTTP
# status. Chain errors map to 402/403/404 depending on
# the specific code — FastAPI handlers reading
# ``exc.status_code`` should see the right one.
self.status_code = status_code
super().__init__(message, **kwargs)


Expand Down Expand Up @@ -476,13 +482,18 @@ def __init__(
max_allowed_cents: int | None = None,
actual_cost_cents: int | None = None,
epsilon_cents: int | None = None,
status_code: int | None = None,
**kwargs: Any,
) -> None:
self.execution_id = execution_id
self.reserved_cents = reserved_cents
self.max_allowed_cents = max_allowed_cents
self.actual_cost_cents = actual_cost_cents
self.epsilon_cents = epsilon_cents
# 2026-07-04 (drift.md P1-1): CONSUME_OVERBUDGET maps to
# 422 on the wire (CLAUDE.md §25) — surface it so FastAPI
# handlers don't fall back to 500.
self.status_code = status_code
super().__init__(message, **kwargs)


Expand Down Expand Up @@ -514,9 +525,14 @@ def __init__(
message: str,
*,
workflow_id: str | None = None,
status_code: int | None = None,
**kwargs: Any,
) -> None:
self.workflow_id = workflow_id
# 2026-07-04 (drift.md P1-1): WORKFLOW_INACTIVE maps to
# 403 on the wire (CLAUDE.md §13) — surface it so FastAPI
# handlers don't fall back to 500.
self.status_code = status_code
super().__init__(message, **kwargs)


Expand Down Expand Up @@ -687,6 +703,18 @@ class NullRunBlockedException(NullRunDecision):
``None`` when the block is workflow-scoped rather than
tool-scoped.
details: Free-form structured payload forwarded by the caller.
status_code: HTTP status code the backend sent on the wire
when the block was derived from a server response (e.g.
402 for ``BUDGET_HARD_BLOCKED``, 403 for
``TOOL_BLOCKED`` / ``WORKFLOW_INACTIVE``, 429 for
``RATE_LIMIT_EXCEEDED``). ``None`` for client-side
blocks (sensitive-tool pre-check, loop detection, retry
storm) where there was no wire response. Lets FastAPI /
Starlette exception handlers map to the correct HTTP
status without re-deriving it from
``type(exc).__name__``. Drift fix 2026-07-04
(drift.md P1-1: SDK_README's NR-B004 → 429 claim was
wrong; the real wire status is 402).
"""

error_code = "NR-X001" # generic block; subclasses override
Expand All @@ -703,12 +731,19 @@ def __init__(
reason: str,
action: str = "block",
tool_name: str | None = None,
status_code: int | None = None,
**details: Any,
) -> None:
self.workflow_id = workflow_id
self.reason = reason
self.action = action
self.tool_name = tool_name
# 2026-07-04 (drift.md P1-1): wire HTTP status preserved
# so FastAPI exception handlers can return the correct
# status without re-deriving from the error class. ``None``
# when the block fired client-side (loop detection, retry
# storm, sensitive-tool pre-check).
self.status_code = status_code
self.details = details
tool_suffix = f", tool={tool_name}" if tool_name else ""
# ``code`` / ``user_action`` / ``retryable`` can be overridden
Expand All @@ -722,7 +757,7 @@ def __init__(
retryable = self.retryable
super().__init__(
f"Workflow {workflow_id} blocked: {reason} "
f"(action={action}{tool_suffix}, details={details})",
f"(action={action}{tool_suffix}, status_code={status_code}, details={details})",
error_code=error_code,
user_action=user_action,
retryable=retryable,
Expand Down
Loading
Loading