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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,10 @@ docs/postman/
# (mirrors the convention that `dist/` is ignored: built / scratch
# outputs never belong in VCS regardless of where they were created).
src/**/*.defect*

# Top-level files dropped by mis-decoded absolute Windows paths
# (e.g. `git status C:\Users\Anatolii\...` writes a file named
# `C:UsersAnatolii` to the repo root when the path separator is
# lost and the byte is rendered as the U+FFFD placeholder).
# Quoted because gitignore treats `:` as a path separator on its own.
"/C:UsersAnatolii*"
136 changes: 136 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,139 @@
## [0.22.1] - 2026-10-15

Patch release, no protocol change. The SDK's public exception surface
is restored after QA pass u3h (2026-10-15) reported 24 classes the
SDK RAISES were not importable from the top-level `nullrun`
namespace. The classes existed, `_V3_ERROR_CODE_MAP` constructed
them, and enforcement was never affected — only the documented
public name was missing. The defect survived because the documented
catch (`except NullRunError:`) kept matching the instance; a narrower
`except NullRunApprovalDeniedError:` only fails when someone goes
looking for the name.

The second behavioural fix closes a discarded-coroutine class of bug
on the WS dispatch path: `on_policy_invalidated` was wired as
`async def` while the dispatcher calls it without `await`, so the
callback was created and silently discarded (the dispatcher's
`try/except` cannot see an un-awaited coroutine either). The
`logger.info` and the user-supplied callback never fired — only a
stderr `RuntimeWarning` that nobody reads. Same bug class as the
approval-resolved fix from 2026-07-24, missed on the sibling
callback.

The third change aligns stale docstrings with current behaviour:
the kill-vs-on_error-hook asymmetry between sync and async `@protect`
was documented as a `BaseException` bypass that never existed (the
class is `WorkflowKilledInterrupt -> NullRunError -> BreakerError ->
Exception` — verified), and chasing the truth exposed an actual
behavioural split governed by `unify_block`, not by base-class
membership. The new `tests/test_guard_kill_semantics.py` pins the
real behaviour so the comments describing it cannot drift the way the
old ones did.

### Migration

None on a healthy configuration. Three things differ from 0.22.0:

1. **24 more exception names are reachable via `from nullrun import
X`** (DEF-PUBEX-01). Code that imports a previously-missing name
will now succeed. The names are also added to `__all__` so
tab-completion and `from nullrun import *` reach them. See the
`Fixed` section below for the full list and rationale by tier
(infrastructure / transport / decision / approval).
2. **`on_policy_invalidated` fires on every WS message** (DEF-WS-SYNC-01).
Pre-0.22.1 the wrapper was an un-awaited coroutine; the
`logger.info` and the user-supplied callback never ran. Code that
subscribed to policy-cache invalidations expecting a callback
will now actually get one.
3. **No wire or backend change.** This is purely SDK-side restoration
and comment alignment.

### Fixed

- **DEF-PUBEX-01 (public exception surface)** — 24 classes the SDK
RAISES were missing from `nullrun.__init__._LAZY_EXPORTS` and
`__all__`. `from nullrun import NullRunApprovalDeniedError`
raised `ImportError` while `except NullRunError:` in the same
file kept matching the instance, so the defect survived every
existing test. Now registered in `_LAZY_EXPORTS`, grouped by tier
(root / markers, infrastructure, transport, decision, approval),
and reachable via `from nullrun import X`. `BreakerError` and
`TransportErrorSource` are deliberately absent from `__all__`:
the first is the pre-Layer-1 root alias kept for old cookbook
code (already offered by tab-completion via `NullRunError`), the
second is an enum a caller passes to `RateLimitError`, not
something to catch. `NullRunUnclassifiedRefusalError` lives in
`nullrun.breaker.categories`, not `.exceptions`, and is the
reason a caller must NOT assume every block carries a policy
verdict — ADR-062 §2.2 requires it to RAISE when a refusal
carries no category the SDK trusts. The full list: BreakerError,
NullRunDecision, NullRunInfrastructureError,
NullRunTransportError, BreakerTransportError,
InsecureTransportError, NullRunProtocolError,
NullRunMalformedGateResponseError, NullRunRateLimitRedisError,
NullRunBudgetRecheckFailedError, NullRunExecutionNotFoundError,
RateLimitError, TransportErrorSource, NullRunUnclassifiedRefusalError,
NullRunDeniedError, NullRunChainError,
NullRunConsumeOverbudgetError, NullRunWorkflowInactiveError,
NullRunApprovalNotYetApprovedError,
NullRunApprovalResponseMissingError, NullRunApprovalDeniedError,
NullRunApprovalExpiredError, NullRunApprovalReplayRejectedError,
NullRunApprovalDigestMismatchError,
NullRunApprovalToolDigestMismatchError.
- **DEF-WS-SYNC-01 (sync `on_policy_invalidated` wrapper)** —
`Transport.connect_websocket` wired `on_policy_invalidated` as
`async def` while the WS dispatch path calls it without `await`.
Constructing an un-awaited coroutine raises nothing, so the
dispatcher's `try/except` could not catch it either. The body
never ran; the `logger.info` and the user-supplied
`on_policy_invalidated` never fired. Same shape as the approval
fix from 2026-07-24, missed on the sibling callback. Now a plain
`def`. `wrapped_key_rotated` stays `async def` because it awaits
`self._refetch_credentials()` and its dispatcher DOES await it.

### Cleanup

- Dropped `C:UsersAnatolii` (216 bytes — contents are a one-line
`fatal: ambiguous argument` message from a previous shell that
mis-decoded an absolute Windows path and wrote the error output
into the repo root). `.gitignore` extended with `"/C:UsersAnatolii*"`
so the shape cannot recur; the broader class
("Windows path decoded without its separator") produces
single-byte replacement-char filenames that don't pattern-match
cleanly, so the entry is narrow on purpose.

### Documentation

- **DEF-DRIFT-01 (kill-vs-on_error-hook asymmetry)** — the
`@protect` / `on_error` / `check_workflow_budget` / `messages.NR-B007`
docstring blocks were aligned with current behaviour. The
`BaseException` explanation for the hook bypass was wrong
(`WorkflowKilledInterrupt -> NullRunError -> BreakerError ->
Exception`, verified); the real split is the `unify_block` flag
in `_protect_body`. Sync `@protect` re-wraps as
`NullRunBlockedException` (error code `NR-W002`), the hook fires;
async `@protect` lets the raw `WorkflowKilledInterrupt`
propagate, the hook does NOT fire. `decision == "throttle"` does
not exist — the only producer is an uncalled phase-2 schema stub.
Fail-OPEN is narrower than "any transport error": a 5xx WITH a
refusal body is HONOURED (decision_source=`GATEWAY`, blocks);
only unreachable / synthetic-source paths fail open. `NR-B007`
was removed 2026-09-10 (it only ever fired on the never-sent
`throttle` decision); `WorkflowPausedException` is NOT zombie
and must not be lumped in with `NR-B007` — it is still raised
for a genuinely paused workflow.

### Verification

| Check | Result |
|---|---|
| `ruff check src tests` | All checks passed |
| `mypy src/nullrun` | Success: no issues found in 39 source files |
| `pytest -q` | **1849 passed, 12 skipped** in 124.76s (vs baseline 1828 / 12 — **+21 new tests** from `test_public_exception_surface`, `test_policy_invalidated_ws_sync_callback`, `test_guard_kill_semantics`) |
| Scratch diff | clean (`dist_local/`, `*.defect*` absent) |
| `nullrun.__version__` | `0.22.1` |
| Wire-format compatibility | unchanged from 0.22.0 |

## [0.22.0] - 2026-10-09

The SDK's enforcement question can now go to a local box that holds a
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "nullrun"
# Full release history lives in CHANGELOG.md; only the current version
# is pinned here.
version = "0.22.0"
version = "0.22.1"
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. The headline is the canonical §1 statement
# from positioning.md — "runtime decision layer for tool-using AI agents"
Expand Down
140 changes: 136 additions & 4 deletions src/nullrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,10 @@ def on_error(hook):

What does NOT fire the hook:

*:class:`WorkflowKilledInterrupt` (BaseException subclass)
— kill is a non-recoverable signal, not an error.
*:class:`WorkflowKilledInterrupt` on the async ``@protect``
path — kill is a non-recoverable signal, not an error. On the
sync path it is re-wrapped as ``NullRunBlockedException``
(error code ``NR-W002``) and that wrapper DOES fire the hook.
* Non-``NullRunError`` exceptions (e.g. raw ``httpx`` errors
from SDK-internal code paths not yet migrated to the
structured hierarchy).
Expand Down Expand Up @@ -484,6 +486,85 @@ def my_agent:
"NullRunBackendError": ("nullrun.breaker.exceptions", "NullRunBackendError"),
"NullRunBudgetError": ("nullrun.breaker.exceptions", "NullRunBudgetError"),
"NullRunToolBlockedError": ("nullrun.breaker.exceptions", "NullRunToolBlockedError"),
# The rest of the exception hierarchy.
#
# 2026-10-15 (QA u3h): 24 classes the SDK can RAISE were missing
# from this table, so `from nullrun import
# NullRunApprovalDeniedError` raised ImportError while
# `except NullRunError:` in the caller's own code still caught
# the instance. The classes existed, `_V3_ERROR_CODE_MAP`
# constructed them, and enforcement was never affected — only the
# public import was broken. The failure was invisible because the
# documented catch is the BASE class: a cookbook written against
# `except NullRunError:` passes in production and the missing
# narrower name is only discovered when a user goes looking for
# it. That is the whole point of the typed catalog (ADR-062
# §2.2), so the names have to be reachable.
#
# Grouped by tier, so a reader can see the hierarchy without
# reading exceptions.py:
# root / markers — catch-all `except` targets
# infrastructure — the SDK could not reach or read the backend
# decision — the backend reached a deliberate verdict
# transport — network / protocol shape
"BreakerError": ("nullrun.breaker.exceptions", "BreakerError"),
"NullRunDecision": ("nullrun.breaker.exceptions", "NullRunDecision"),
"NullRunInfrastructureError": ("nullrun.breaker.exceptions", "NullRunInfrastructureError"),
# Infrastructure — transport, protocol, backend-unreadable.
"NullRunTransportError": ("nullrun.breaker.exceptions", "NullRunTransportError"),
"BreakerTransportError": ("nullrun.breaker.exceptions", "BreakerTransportError"),
"InsecureTransportError": ("nullrun.breaker.exceptions", "InsecureTransportError"),
"NullRunProtocolError": ("nullrun.breaker.exceptions", "NullRunProtocolError"),
"NullRunMalformedGateResponseError": (
"nullrun.breaker.exceptions",
"NullRunMalformedGateResponseError",
),
"NullRunRateLimitRedisError": ("nullrun.breaker.exceptions", "NullRunRateLimitRedisError"),
"NullRunBudgetRecheckFailedError": ("nullrun.breaker.exceptions", "NullRunBudgetRecheckFailedError"),
"NullRunExecutionNotFoundError": ("nullrun.breaker.exceptions", "NullRunExecutionNotFoundError"),
"RateLimitError": ("nullrun.breaker.exceptions", "RateLimitError"),
"TransportErrorSource": ("nullrun.breaker.exceptions", "TransportErrorSource"),
# `resolve_refusal_category` raises this when the gate refuses
# without a category the SDK trusts (ADR-062 §2.2: an
# unclassifiable refusal must RAISE, never be guessed). It lives
# in `breaker.categories`, not `breaker.exceptions`, and is the
# reason a caller must NOT assume every block carries a policy
# verdict.
"NullRunUnclassifiedRefusalError": (
"nullrun.breaker.categories",
"NullRunUnclassifiedRefusalError",
),
# Decision — the backend refused, and the reason is knowable.
"NullRunDeniedError": ("nullrun.breaker.exceptions", "NullRunDeniedError"),
"NullRunChainError": ("nullrun.breaker.exceptions", "NullRunChainError"),
"NullRunConsumeOverbudgetError": ("nullrun.breaker.exceptions", "NullRunConsumeOverbudgetError"),
"NullRunWorkflowInactiveError": ("nullrun.breaker.exceptions", "NullRunWorkflowInactiveError"),
# The approval family. One class per wire code (NR-A010 … NR-A015)
# so a caller can branch on WHICH approval failure occurred
# instead of string-matching the message. The base
# `NullRunBlockedException` collapses all of them into one.
"NullRunApprovalNotYetApprovedError": (
"nullrun.breaker.exceptions",
"NullRunApprovalNotYetApprovedError",
),
"NullRunApprovalResponseMissingError": (
"nullrun.breaker.exceptions",
"NullRunApprovalResponseMissingError",
),
"NullRunApprovalDeniedError": ("nullrun.breaker.exceptions", "NullRunApprovalDeniedError"),
"NullRunApprovalExpiredError": ("nullrun.breaker.exceptions", "NullRunApprovalExpiredError"),
"NullRunApprovalReplayRejectedError": (
"nullrun.breaker.exceptions",
"NullRunApprovalReplayRejectedError",
),
"NullRunApprovalDigestMismatchError": (
"nullrun.breaker.exceptions",
"NullRunApprovalDigestMismatchError",
),
"NullRunApprovalToolDigestMismatchError": (
"nullrun.breaker.exceptions",
"NullRunApprovalToolDigestMismatchError",
),
# Layer 2: on_error context type
"ErrorContext": ("nullrun.observability.error_hooks", "ErrorContext"),
# Layer 3: status dataclasses
Expand Down Expand Up @@ -522,8 +603,22 @@ def my_agent:
# nullrun/_handle.py for the rationale). Pair with @nullrun.protect
# so a typical ``run an agent and print a friendly message on
# failure`` script needs no explicit try/except around
# NullRunError. WorkflowKilledInterrupt (BaseException) still
# propagates — kill is never swallowed.
# NullRunError.
#
# Kill behaviour is ASYMMETRIC between the two @protect
# wrappers, and this comment used to paper over it by calling
# WorkflowKilledInterrupt a BaseException:
# async @protect → raw WorkflowKilledInterrupt propagates →
# guard re-raises it explicitly (see _handle.py) → the
# agent loop sees the kill.
# sync @protect → the wrapper substitutes
# NullRunBlockedException (error_code NR-W002), which is a
# NullRunDecision and NOT a WorkflowKilledInterrupt
# subclass → guard's isinstance re-raise does not match →
# it prints a report and sys.exit(1).
# So `with nullrun.guard()` + a sync @protect turns a kill into
# a graceful exit. Pinned by
# tests/test_guard_kill_semantics.py.
#
# History: 0.18.4 renamed this from ``handle`` to ``guard``.
# The module file name is still ``_handle.py`` for the
Expand Down Expand Up @@ -627,6 +722,43 @@ def __dir__() -> list[str]:
"NullRunMcpReadonlyBypassBlockedError",
"NullRunMcpApprovalRequiredError",
"NullRunApprovalDbUnavailableError",
# The narrower `except` targets. Same 2026-10-15 reasoning as the
# `_LAZY_EXPORTS` block above: every name here is a class the SDK
# raises, and a caller cannot branch on a name that tab-completion
# does not offer. Ordering follows the tier hierarchy —
# infrastructure, then transport, then decision, then approval —
# so the list reads top-down as "how bad is this".
#
# `BreakerError` is deliberately absent: it is the pre-Layer-1
# root alias, kept only so `except BreakerError` in old cookbook
# code keeps matching. It is reachable via
# `nullrun.breaker.exceptions` and adds nothing to the
# tab-completion list, where `NullRunError` is the name to use.
# `TransportErrorSource` is likewise absent — it is an enum a
# caller passes to `RateLimitError`, not something to catch.
"NullRunDecision",
"NullRunInfrastructureError",
"NullRunTransportError",
"BreakerTransportError",
"InsecureTransportError",
"NullRunProtocolError",
"NullRunMalformedGateResponseError",
"NullRunRateLimitRedisError",
"NullRunBudgetRecheckFailedError",
"NullRunExecutionNotFoundError",
"RateLimitError",
"NullRunUnclassifiedRefusalError",
"NullRunDeniedError",
"NullRunChainError",
"NullRunConsumeOverbudgetError",
"NullRunWorkflowInactiveError",
"NullRunApprovalNotYetApprovedError",
"NullRunApprovalResponseMissingError",
"NullRunApprovalDeniedError",
"NullRunApprovalExpiredError",
"NullRunApprovalReplayRejectedError",
"NullRunApprovalDigestMismatchError",
"NullRunApprovalToolDigestMismatchError",
# User-facing message catalog — the single entry point for
# turning an SDK exception into a string safe to display to
# end users. ``set_user_message`` lets a deployment brand its
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.22.0"
__version__ = "0.22.1"
__platform_version__ = "1.0.0"
Loading
Loading