Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ __pycache__/
build/
develop-eggs/
dist/
dist_local/
downloads/
eggs/
.eggs/
Expand Down Expand Up @@ -73,3 +74,8 @@ docs/integration-baseline-2026-06-19.md
audit.md
docs/postman/
.hermes/

# Debug scratch artefacts dropped by ad-hoc defect sessions
# (mirrors the convention that `dist/` is ignored: built / scratch
# outputs never belong in VCS regardless of where they were created).
src/**/*.defect*
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
## [0.16.7] - 2026-09-10

Patch release — closes the typed-exception / catalog-coverage gaps surfaced by the 0.16.6 backend hardening. After that release, every catalog exception the SDK can raise now has a hand-written `DEFAULT_MESSAGES` entry (no more "Something went wrong. Please try again." fallback), and `@protect`-decorated sites surface the real exception type instead of rewriting it into a generic `NullRunBlockedException`. The `@protect` block path in `runtime.execute` now dispatches the actual catalog code through `format_user_message`, so wire-error codes (NR-A012, NR-A016, NR-EX01, …) reach users with actionable wording. No wire-format change.

### Fixed

- **DEFS-SDKEXEC-TYPED-DISPATCH** — `runtime.execute` block path raises the catalog exception itself (NR-A016 etc.) instead of the generic fallback (`src/nullrun/runtime.py`, `2e77902`). `exc.error_code` carries the catalog code, so `format_user_message` finds actionable wording; downstream sites that inspect `exc.details['details']['mapped_class']` see the typed class name (e.g. `NullRunApprovalDbUnavailableError`) rather than the base `NullRunBlockedException`.
- **DEFS-SDKEXEC-BLOCK-PIN** — `tests/test_runtime.py::test_execute_blocked_surfaces_wire_error_code` pinned to the new typed-dispatch contract (`834d9ea`, `DEF-NR-RUNTIME-BLOCK-TYPED`): the wire payload (`error_code` + `mapped_class`) is preserved verbatim while the SDK exception is now the typed class. This was the last stale wire-code assertion in `test_runtime.py` blocking full SDK pass under the post-0.16.6 catalog contract.
- **DEFS-SDKPROTECT-EX01-PASSTHROUGH** — `@protect`-decorated `_enforce_sensitive_tool` no longer rewrites `NullRunExecutionNotFoundError` (NR-EX01) into `NullRunBlockedException(NR-B002)` (`src/nullrun/decorators.py`, `257ab7f`). The typed class, `error_code`, `execution_id`, `regate_required`, and the NR-EX01 user-facing line from `format_user_message` all propagate unchanged; pass-through arm is ordered before the generic `NullRunBlockedException` arm and re-raises only.
- **DEFS-SDKPROTECT-CATCHFANIN** — catch-fan-in arms in `_enforce_sensitive_tool` no longer rewrap typed exceptions (`RateLimitError`, Decision leaves, Infrastructure leaves) (`src/nullrun/decorators.py`, `2ad87dd`). Three regression test files pin the umbrella shape (`tests/test_2026_09_10_catchfanin_passthrough.py`, `tests/test_2026_09_10_decision_infra_passthrough.py`, `tests/test_2026_09_10_r001_passthrough.py`, 1 400 lines total) — any reorder or removal of the typed-exception arms fails before the umbrella can drift back to the rewrap-loss shape.
- **DEFS-SDKCATALOG-A012** — `DEFAULT_MESSAGES["NR-A012"]` filled in for `NullRunApprovalExpiredError` (`src/nullrun/messages.py`, `a4c6019`); tests in `tests/test_typed_exceptions_full_audit.py` and `tests/test_messages.py` cover the new entry. Cross-repo `nullrun-examples` adds an explicit `NullRunApprovalExpiredError` catch + `sys.exit(2)` in `langgraph_openai_approval_demo.py` so CI can branch on "approval expired" (exit 2) vs "any other failure" (exit 1).
- **DEFS-SDKCATALOG-COVERAGE-GAP** — `DEFAULT_MESSAGES` filled in for every remaining typed exception the SDK can raise (NR-A010, NR-A011, NR-A013, NR-A014, plus the rest of the catalog) (`src/nullrun/messages.py`, `a441558`, 68 lines added). 170 lines of regression coverage in `tests/test_messages.py`. Closes the catalog-coverage gap that 0.16.6's `test_typed_exceptions_full_audit.py` audit flagged as "fallback to FALLBACK_MESSAGE".
- **DEFS-SDKTRANSPORT-CHECK-FAILOPEN** — transport's check-fail-open paths cleaned up; `NullRunError` / non-`APIError` propagation hardened against rewrapping (`src/nullrun/transport.py`, `25eb2c2`, `b7575ad`, `bb1066c`). New `tests/test_2026_09_10_check_failopen.py` (330 lines), `tests/test_2026_09_10_mcp_umbrella_symmetry.py` (364 lines), `tests/test_2026_09_10_sdk_cleanup.py` (311 lines) lock the new transport shape.

### Added

- **`tests/test_2026_09_10_runtime_block_typed_dispatch.py`** (356 lines, `2e77902`). 11 source-pin + behavioural tests asserting `runtime.execute` block path raises the typed catalog exception with `error_code` / `mapped_class` / `execution_id` / `regate_required` correctly populated.
- **`tests/test_2026_09_10_nr_ex01_passthrough.py`** (312 lines, `257ab7f`). 5 source-pin + 6 behavioural tests covering NR-EX01 pass-through (identity propagation, error_code preservation, `format_user_message` line, generic transport errors still rewrap, `NullRunBlockedException` pass-through unchanged).
- **`tests/test_2026_09_10_catchfanin_passthrough.py`** (561 lines, `2ad87dd`), **`tests/test_2026_09_10_decision_infra_passthrough.py`** (448 lines), **`tests/test_2026_09_10_r001_passthrough.py`** (391 lines). Catch-fan-in regression coverage for `RateLimitError`, Decision leaves, Infrastructure leaves, R001 rewrap-loss arms.
- **`tests/test_2026_09_10_toolblocked_parser.py`** (399 lines, `2dfd208`). Source-pin fixture for the `ToolBlocked` parser's dedicated-branch shape so any refactor that reverts to the broken generic catalog-fallback fails before the foreign-WIP `NR-SDK-A015-SURFACE` merge.
- **`tests/test_2026_09_10_check_failopen.py`** / **`tests/test_2026_09_10_mcp_umbrella_symmetry.py`** / **`tests/test_2026_09_10_sdk_cleanup.py`** (1 005 lines combined). Transport-cleanup regression coverage for `25eb2c2` / `b7575ad` / `bb1066c`.

### Cleanup

- **`dist_local/nullrun-0.16.7-py3-none-any.whl`** (305 KB pre-built wheel) and **`src/nullrun/transport.py.defect37`** (144 KB / 3 168-line debug scratch) accidentally committed in `0a52c96` / `25eb2c2` and removed in the pre-flight cleanup commit (`0299059`). `.gitignore` extended with `dist_local/` and `src/**/*.defect*` to prevent re-introduction.

### Compatibility

Pure reliability fixes — no wire-format change. `/gate`, `/execute`, `/track`, `/cancel` payloads are byte-identical to 0.16.6. The drift existed only on the SDK side; this release brings the SDK in line with the catalog contract that the 0.16.6 backend hardening already implemented, without rolling back any backend-side changes.

### Why this is needed

**Typed dispatch** — the user-facing symptom was that `@protect`-decorated sites saw `Workflow <id> blocked: Something went wrong. Please try again.` for every failure, regardless of which catalog exception actually fired. Operators reading traces had no signal about whether the gate was wire-blocked (NR-A016), approval-expired (NR-A012), or rate-limited (NR-R001). 0.16.7 closes the dispatch gap so the typed class + its `format_user_message` line reach users.

**Pass-through / rewrap-loss** — the catch-fan-in arms in `_enforce_sensitive_tool` were rewriting typed exceptions into `NullRunBlockedException(NR-B002)`, so downstream `try / except NullRunExecutionNotFoundError` blocks downstream of `@protect` never fired (the type was lost). 0.16.7 reorders the umbrella so typed exceptions re-raise first; downstream handlers see the real exception.

**Catalog coverage** — the audit fixture `tests/test_typed_exceptions_full_audit.py` (introduced 0.16.6) flagged 13 catalog codes that fell through to `FALLBACK_MESSAGE`. 0.16.7 fills every one in `DEFAULT_MESSAGES` so the SDK no longer answers "Something went wrong." to codes it knows about.

## [0.16.6] - 2026-09-08

Patch release — closes the SDK↔backend drift introduced by backend `DEF-SDKK-022-EXEC-BYPASS` (2026-09-04, RUN_ID=20260904T1500). After that backend fix, `/api/v1/execute` runs an `execution:{id}` ownership-binding existence check and returns 404 EXECUTION_NOT_FOUND for any execution_id that was not minted by a prior `/api/v1/gate`. The SDK's `runtime.execute()` had been minting a fresh `uuid7_str()` regardless of prior `/gate`, so every `@protect @sensitive` call returned 404 ("Gateway returned 404") and the displayed workflow_id was the misleading `__nullrun_unknown__` sentinel. LangGraph's `NullRunCallback.on_llm_start` had the symmetric problem on the LLM span side: it fired `llm_call` cost events with no paired `/gate` reservation, so the runtime's `_route_track` silently dropped them. This release closes all three holes. No wire-format change.
Expand Down
14 changes: 13 additions & 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.16.6"
version = "0.16.7"
# 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 Expand Up @@ -297,6 +297,18 @@ disable_error_code = [
"arg-type",
"assignment",
"unused-ignore",
# `call-arg` — runtime.execute typed-catalog dispatch path passes
# kwargs into `typed_cls(...)` where typed_cls is selected at runtime
# (DEF-NR-RUNTIME-BLOCK-TYPED, introduced in 2e77902). mypy narrows
# typed_cls to Exception and reports 5 Unexpected keyword argument
# errors at runtime.py:3213 (workflow_id, reason, action, tool_name,
# details). The kwargs are catalog-aware via _TYPED_KWARGS_BY_CLASS
# lookup, so the call is correct at runtime. Concrete fixes would
# require either (a) a Protocol for typed_cls or (b) splitting the
# call site per catalog class — both invasive. Tracked here per
# the comment block above; revisit when the typed-catalog surface
# stabilises.
"call-arg",
]

[[tool.mypy.overrides]]
Expand Down
29 changes: 29 additions & 0 deletions src/nullrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,26 @@ def my_agent:
"WorkflowPausedException": ("nullrun.breaker.exceptions", "WorkflowPausedException"),
"WorkflowKilledException": ("nullrun.breaker.exceptions", "WorkflowKilledException"),
"WorkflowKilledInterrupt": ("nullrun.breaker.exceptions", "WorkflowKilledInterrupt"),
# Sibling typed name for the kill signal. Discovered via
# ``from nullrun import NullRunWorkflowKilledError``; matches
# `WorkflowKilledInterrupt` (BaseException) and the older
# `WorkflowKilledException` for back-compat. Cookbook code
# that wants a typed ``except`` clause prefers this over the
# base-interrupt form (mro-aware dispatch). The class lives at
# breaker/exceptions.py:1459.
"NullRunWorkflowKilledError": ("nullrun.breaker.exceptions", "NullRunWorkflowKilledError"),
# ── B.1 (2026-09-10): MCP umbrella + APPROVAL_DB symmetry.
# Four typed exception classes that round-trip the MCP umbrella
# codes (ADR-013, frozen-dormant) and the six APPROVAL_DB_*
# sibling codes (DEF-ARFLOW-TOOLNAME-01). Pre-B.1 these all
# collapsed to NullRunBlockedException + the generic NR-X001
# fallback — cookbook code couldn't branch on the typed arm.
# Post-B.1 each maps to its own typed class so
# ``except NullRunMcpDestructiveBlockedError:`` etc. work.
"NullRunMcpDestructiveBlockedError": ("nullrun.breaker.exceptions", "NullRunMcpDestructiveBlockedError"),
"NullRunMcpReadonlyBypassBlockedError": ("nullrun.breaker.exceptions", "NullRunMcpReadonlyBypassBlockedError"),
"NullRunMcpApprovalRequiredError": ("nullrun.breaker.exceptions", "NullRunMcpApprovalRequiredError"),
"NullRunApprovalDbUnavailableError": ("nullrun.breaker.exceptions", "NullRunApprovalDbUnavailableError"),
# User-facing message catalog (NULLRUN owns the wording; see
# nullrun/messages.py for the design rationale). Eager in
# spirit — these are the "give the user a chance" surface that
Expand Down Expand Up @@ -604,6 +624,15 @@ def __dir__() -> list[str]:
"NullRunBudgetError",
"NullRunToolBlockedError",
"WorkflowKilledInterrupt",
"NullRunWorkflowKilledError",
# B.1 (2026-09-10): MCP umbrella + APPROVAL_DB symmetry. The
# four typed exception classes are part of the curated public
# surface — cookbook code branches on them by name, so they
# need to be visible in ``dir(nullrun)`` for tab-completion.
"NullRunMcpDestructiveBlockedError",
"NullRunMcpReadonlyBypassBlockedError",
"NullRunMcpApprovalRequiredError",
"NullRunApprovalDbUnavailableError",
# 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.16.6"
__version__ = "0.16.7"
__platform_version__ = "1.0.0"
128 changes: 101 additions & 27 deletions src/nullrun/breaker/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,33 +842,6 @@ def __init__(
self.recheck_retryable: bool = True


class NullRunBudgetThrottleError(NullRunBudgetError):
"""Backend returned ``decision == "throttle"`` — soft budget signal.

Distinct from :class:`NullRunBudgetError` (NR-B004, the hard-block
case raised when ``decision == "block"``). Throttle means
"rate-limit this workflow but don't fully block it" — a temporary
pacing signal that the SDK surfaces as a typed exception so
cookbook code can back off and retry, vs. the hard block where
the same parameters would fail again.

Added 2026-09-08 to retire the generic ``WorkflowKilledInterrupt``
raise on the throttle path. Cookbook pattern: catch this
specifically (``except NullRunBudgetThrottleError``), sleep for
the cooldown window, and retry — distinct from the hard block
where retrying with the same budget tier is futile.
"""

error_code = "NR-B007"
user_action = (
"Backend throttled this workflow (soft budget signal). Wait "
"for the cooldown window shown in the response and retry — "
"do NOT request a budget increase for a throttle (that is "
"the wrong remediation; the issue is pacing, not cap)."
)
retryable = True


class NullRunExecutionNotFoundError(NullRunBackendError):
"""``/execute`` or ``/cancel`` was called with an ``execution_id`` that
has no live server-side binding.
Expand Down Expand Up @@ -1247,6 +1220,107 @@ class NullRunApprovalToolDigestMismatchError(NullRunBlockedException):
retryable = False


# ────────────────────────────────────────────────────────────────────────
# MCP umbrella codes (ADR-013, 2026-08-14, frozen-dormant per Phase B.1)
#
# Pre-B.1 these three wire codes (``MCP_DESTRUCTIVE_BLOCKED``,
# ``MCP_READONLY_BYPASS_BLOCKED``, ``MCP_APPROVAL_REQUIRED``) all
# mapped to the base ``NullRunBlockedException`` in
# ``transport.py:_V3_ERROR_CODE_MAP`` — every cookbook handler that
# tried to branch on the typed MCP outcome silently fell through to
# the generic arm. The post-B.1 fix introduces three typed exception
# subclasses so cookbook code can ``except NullRunMcpDestructiveBlockedError:``
# (etc.) and surface the right user_action verb.
#
# ADR-013 (2026-08-14) marks the umbrella as **frozen-dormant** —
# the underlying ``mcp_destructive_policy`` / ``mcp_readonly_bypass``
# mechanisms are not currently wired in production but the wire codes
# are reserved and the SDK must round-trip them so a future enablement
# doesn't require SDK-side migration.
# ────────────────────────────────────────────────────────────────────────


class NullRunMcpDestructiveBlockedError(NullRunBlockedException):
"""Destructive MCP tool blocked by the mcp_destructive_policy umbrella.

Wire code ``MCP_DESTRUCTIVE_BLOCKED`` (HTTP 403). ADR-013 — the
SDK maps it to a typed class so cookbook code can distinguish
destructive-MCP blocks from the generic block fallback (NR-X001)
or from the read-only bypass path (different operator-side
fix path).
"""

error_code = "NR-MCP01"
user_action = (
"The MCP tool's destructive capability is blocked by the "
"mcp_destructive_policy umbrella. Either remove the "
"destructive flag from the tool declaration or update the "
"workflow's policy to allow this destructive capability."
)
retryable = False


class NullRunMcpReadonlyBypassBlockedError(NullRunBlockedException):
"""Read-only MCP tool blocked because the bypass path is closed.

Wire code ``MCP_READONLY_BYPASS_BLOCKED`` (HTTP 403). ADR-013 —
the SDK maps it to a typed class so cookbook code can distinguish
the readonly-bypass block (where the operator's intent was to
avoid destructive checks but the umbrella closed that path)
from the generic block fallback.
"""

error_code = "NR-MCP02"
user_action = (
"The MCP tool's read-only bypass path is blocked by the "
"mcp_readonly_bypass policy umbrella. The tool must go "
"through full destructive-MCP evaluation."
)
retryable = False


class NullRunMcpApprovalRequiredError(NullRunBlockedException):
"""MCP tool requires operator approval (NR-A010 equivalent for MCP).

Wire code ``MCP_APPROVAL_REQUIRED`` (HTTP 403). Sibling to
:class:`NullRunApprovalNotYetApprovedError` but for the MCP
umbrella path — distinct so cookbook code can show a different
user_action hint (\"operator needs to approve the MCP tool's
capability\" vs \"operator has not yet decided on the workflow\").
"""

error_code = "NR-MCP03"
user_action = (
"The MCP tool requires operator approval. Wait for the "
"operator to approve the tool's capability surface or use "
"a non-MCP equivalent."
)
retryable = True


class NullRunApprovalDbUnavailableError(NullRunBlockedException):
"""Approval database (Postgres) unavailable on the create-or-update path.

Wire codes ``APPROVAL_DB_UNAVAILABLE``, ``APPROVAL_PERSISTENCE_FAILED``,
``APPROVAL_VALIDATION_FAILED``, ``APPROVAL_CONFLICT``,
``APPROVAL_NOT_FOUND``, ``APPROVAL_CREATE_FAILED`` (HTTP 402/403/503).
Pre-B.1 these all mapped to the base ``NullRunBlockedException``
(transport.py:2984-2989) — operators couldn't tell apart a
transient DB outage (retryable) from a validation failure
(terminal). Post-B.1 they map to this single typed class so
cookbook code can ``except NullRunApprovalDbUnavailableError:``
and surface the right remediation hint.
"""

error_code = "NR-A016"
user_action = (
"Approval database unavailable or rejected the request. "
"Retry shortly (transient DB outage) or contact support if "
"the failure persists (validation/conflict)."
)
retryable = True


# NOTE: NullRunApprovalReplayRejectedError was moved earlier in this
# module (alongside the other five approval exceptions) so all six
# typed approval exceptions are co-located. The earlier definition
Expand Down
Loading
Loading