chore(release): 0.16.7 — typed-catalog dispatch + @protect pass-through closure + catalog coverage + transport cleanup - #99
Merged
Conversation
The 0.16.6 release prep (fix(reservations) + fix error propagation
on master) moved WorkflowKilledInterrupt onto the NullRunError MRO
(see breaker/exceptions.py:1354 - 2026-09-08 migration). That migration
is intentional - Sentry/OTel 'except Exception' handlers should now
record kill events - but it had three knock-on defects that block a
green test suite + mypy run:
1. _handle.py / guarded() swallowed the kill signal. handle() catches
NullRunError to translate SDK failures into a friendly sys.exit
print; post-migration WorkflowKilledInterrupt is a NullRunError
subclass, so handle() was catching the kill and converting it
into sys.exit(1) - the exact thing the docstring promised NOT to
do. Fix: explicit 'isinstance(exc, WorkflowKilledInterrupt)'
re-raise before the catalog print + sys.exit. guarded() inherits
the behaviour for free via the same handle() context manager.
2. tests + mypy assumed the pre-migration shape.
- tests/test_handle.py used the (BaseException) wording that
predates the migration - now stale, refreshed to 'must NOT
be swallowed' without the BaseException claim.
- tests/test_preflight_fail_policy.py,
tests/test_observability.py,
tests/test_v3_wire_contract.py expected check_workflow_budget
to raise WorkflowKilledInterrupt on decision=block. The
runtime raises NullRunBudgetError (NullRunBlockedException
subclass) for budget blocks - kill is reserved for the
control-plane path. Assertions updated, imports updated.
- tests/test_ws_push.py expected the deprecated
WorkflowKilledException alias; check_control_plane raises
the typed NullRunWorkflowKilledError post-migration.
3. mypy: NullRunExecutionNotFoundError.__init__ re-declared
self.endpoint as 'str | None' after super().__init__ had set it
to a non-None 'str' (parent NullRunTransportError.endpoint is
typed 'str'). Narrowed to 'str' to match the parent's contract;
the runtime value is always 'endpoint or "/api/v1/execute"'.
Plus the missed __version__.py bump: pyproject.toml was already
0.16.6 in fix(reservations) but __version__.py still said 0.16.5.
This is the matching bump.
No wire-format change. Verified: pytest -q 1658 pass / 4 skipped;
ruff check src tests clean; mypy src/nullrun no issues reported in
37 source files.
Triggered by langgraph_openai_approval_demo.py printing "Something went
wrong. Please try again." instead of a typed actionable outcome when a
5-second approval timeout fires. Two-layer root cause:
1. SDK catalog gap: NullRunApprovalExpiredError has been implemented at
src/nullrun/breaker/exceptions.py:1102-1162 with error_code="NR-A012"
since 2026-09-08, but DEFAULT_MESSAGES in src/nullrun/messages.py
was missing the NR-A012 entry. Lookup fell through to
FALLBACK_MESSAGE = "Something went wrong. Please try again." —
exactly what the demo printed.
2. Cookbook pattern gap: callers could not catch
NullRunApprovalExpiredError explicitly because there was no entry in
the catalog demonstrating the typed-exception contract.
This commit:
- src/nullrun/messages.py: add NR-A012 entry to DEFAULT_MESSAGES
("This request was not approved in time and has expired. Please try
again — the operator will be notified."). Tone follows existing
imperative-when-actionable rules. Documents both raise paths (wire
+ local WS push timeout).
- tests/test_messages.py: add NR-A012 to _EXPECTED_CODES, add two
new tests:
- test_format_user_message_handles_approval_expired
- test_format_user_message_handles_approval_expired_local_timeout_path
Both pin the catalog lookup so a future refactor that removes NR-A012
re-introduces the user-visible bug.
Verified: pytest tests/test_messages.py -v (23 passed including 2 new),
pytest tests/test_typed_exceptions_full_audit.py -v (44 passed total).
Cross-repo: nullrun-examples PR adds 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).
Local master diverged from origin/master by 4 local commits (35a3670 fix(reservations), 9e1afc1 fix error propagation, b654d0d chore release 0.16.6, a4c6019 NR-A012 catalog entry) and 1 remote commit (9877c34 PR #98 -- the squash release of the first three with extra test pins). Fast-forward was impossible. Auto-merge resolved cleanly: - src/nullrun/messages.py carries BOTH new entries: NR-A012 at line 90 and NR-EX01 at line 111. Insertions landed at non- overlapping anchors so git's 3-way merge accepted both without intervention. - All other file overlaps were already covered by the local pre- existing commits that PR #98 had squashed into a single commit on origin. No code changes introduced beyond what each side had already written. a4c6019 NR-A012 stays as a distinct commit on top of 9877c34, preserving the cookbook-pattern context.
Audit on 2026-09-09 found 13 NR-XXXX codes that the SDK raises via
typed exceptions but had no entry in DEFAULT_MESSAGES. Each gap had
the same user-visible bug as the original NR-A012 trigger:
``format_user_message`` silently fell through to
``FALLBACK_MESSAGE = "Something went wrong. Please try again."``
instead of the actionable per-exception wording.
Codes closed (added 2026-09-09):
Approval lifecycle:
NR-A010 NullRunApprovalNotYetApprovedError (operator pending)
NR-A011 NullRunApprovalDeniedError (operator denied — terminal)
NR-A013 NullRunApprovalDigestMismatchError (action digest drift)
NR-A014 NullRunApprovalToolDigestMismatchError (MCP capability drift)
NR-A015 NullRunApprovalReplayRejectedError (replay / retry-loop)
Workflow state:
NR-W004 NullRunWorkflowInactiveError (server-side kill)
Budget sub-cases:
NR-B006 NullRunBudgetRecheckFailedError (post-approval race)
NR-B007 NullRunBudgetThrottleError (soft pacing signal)
NR-O001 NullRunConsumeOverbudgetError (consume > reserve + epsilon)
Wire / chain / rate limit:
NR-P001 NullRunProtocolError (protocol version mismatch)
NR-CH001 NullRunChainError (chain invalid)
NR-R002 NullRunRateLimitRedisError (fail-CLOSED Redis outage)
src/nullrun/messages.py: 12 new entries with tone-rule-compliant
user-facing copy (polite, imperative when actionable, no internal
jargon, no URLs -- URLs stay on developer-facing ``user_action``).
Tone distinctions preserved:
- NR-A010 says ``wait`` (action is to wait, not retry).
- NR-A011 does NOT say ``try again`` alone (cookbook pattern
requires a fresh approval_id; copy invites the user to
``submit a new request if you'd like to try again``).
- NR-B007 vs NR-B004 wording reflects pacing vs cap distinction.
- NR-R002 wording mirrors NR-B001/NR-B002 (transient outage)
because the operator-side fix is identical (restore Redis); the
user-facing difference between ``rate limit hit`` and ``rate
limit Redis down`` is operator-internal and intentionally hidden.
tests/test_messages.py: _EXPECTED_CODES expanded from 14 to 28
entries; 12 new pin tests (one per new code) follow the
NR-A012 regression-test pattern -- each exercises
``format_user_message(exc)`` and asserts the result equals the
catalog entry and is NOT the FALLBACK_MESSAGE. Constructor args
matched to the per-class __init__ signatures (NullRunBlockedException
subclasses take ``(workflow_id, reason, ...)``; the rest take
``(message, ...)`` plus typed detail kwargs).
Verified: pytest tests/test_messages.py -v (35 passed, was 23),
pytest -q (1672 passed / 4 skipped, was 1660; +12 new tests, all
green). No public-API surface change.
Conscious risk: examples/langgraph_openai_approval_demo.py still
catches only NullRunApprovalExpiredError. Adding catches for every
new typed exception would turn the demo into a cookbook rather than
the single-pattern demonstration it was designed to be. Cookbook
guidance belongs in the SDK docs (nullrun.io), not in this demo.
…-EX01) Pre-fix, _enforce_sensitive_tool (decorators.py:853) had three except arms: NullRunBlockedException (pass-through), NullRunTransportError (rewrap to NullRunBlockedException(NR-B00X)), and Exception (catch-all). NullRunExecutionNotFoundError (NR-EX01) is a subclass of NullRunBackendError which is a subclass of NullRunTransportError, so the typed exception was being unwrapped into a generic NullRunBlockedException(error_code='NR-B002') with reason 'policy engine unavailable: ...'. User-visible symptom (langgraph_openai_approval_demo.py, 3rd refund after WS-poll approval): backend 404 EXECUTION_NOT_FOUND -> SDK prints 'Our service is temporarily unavailable. Please try again shortly.' (NR-B002) instead of the documented NR-EX01 line 'There's a configuration issue. Please contact support.' The typed exception class was lost, so cookbook 'except NullRunExecutionNotFoundError' never matched either. Post-fix: dedicated pass-through arm BEFORE NullRunBlockedException so the typed exception propagates with error_code=NR-EX01, execution_id, endpoint, and regate_required intact. Generic NullRunTransportError still rewaps to NullRunBlockedException(NR-B00X) - the fix is scoped to NR-EX01 only, not a silent widening. Pinned by 11 regression tests in tests/test_2026_09_10_nr_ex01_passthrough.py: - 5 source-pin tests (pass-through arm present, ordered before NullRunBlockedException, only re-raises, comment tag, import binds) - 6 behavioral tests (identity propagation, error_code preservation, execution_id/regate_required readable, format_user_message yields NR-EX01 line, generic transport errors still rewrap, NullRunBlockedException pass-through unchanged)
…ves, Infrastructure leaves
DEF-NR-R001-REWRAP-LOSS (2026-09-10):
RateLimitError IS a NullRunTransportError subclass, so the generic
rewrap arm in _enforce_sensitive_tool stamped every 429 envelope
as NullRunBlockedException(NR-B002, "policy engine unavailable:
GATEWAY_ERROR"). This lost exc.retry_after (gateway Retry-After /
retry_after_ms body field), exc.upgrade_url (plan-upgrade URL),
exc.body, and the typed class itself. Cookbook
``except RateLimitError:`` never matched. Surface symptom: SDK
printed the NR-B002 line ("Our service is temporarily unavailable.
Please try again shortly.") instead of the correct NR-R001 line
("The NullRun backend rate-limited this API key. Wait
retry_after seconds (or upgrade the plan) before retrying.").
FastAPI handler `getattr(exc, "retry_after")` silently returned
None, dropping the Retry-After HTTP header.
Fix: dedicated ``except RateLimitError: raise`` pass-through
arm BEFORE the NullRunBlockedException arm. The MRO-specific
ordering matters: pass-through leaves go BEFORE broad parent
arms. Updated function-local import block to include
RateLimitError.
DEF-NR-A003-REWRAP-LOSS (2026-09-10, broader scope):
The catch-all ``except Exception as exc:`` rewrap at
_enforce_sensitive_tool stamped NR-B001 for every typed
exception that did not match the four specific arms. Eight
leaves silently rewrapped:
- NullRunAuthError (NR-A003) — lost wire_code (API_KEY_REVOKED
/ EXPIRED / DISABLED / INVALID / MISSING / MALFORMED per
v3.38)
- NullRunProtocolError (NR-P001) — lost "Upgrade the SDK to
support protocol X-NULLRUN-PROTOCOL: 4" recovery hint
- NullRunRateLimitRedisError (NR-R002) — lost "Redis outage
for aggregate rate limit (fail-CLOSED)" message
- NullRunConfigError (NR-Cxxx) — typed config error -> generic
transport block
- NullRunChainError (NR-CH001) — lost chain_id,
parent_execution_id, backend_code
- NullRunWorkflowInactiveError (NR-W004) — lost workflow_id
- NullRunConsumeOverbudgetError (NR-O001) — lost execution_id,
reserved_cents, max_allowed_cents, actual_cost_cents
- WorkflowPausedException (NR-W003) — lost resume_after,
workflow_id, reason
Fix: two umbrella pass-through arms BEFORE the catch-all:
``except NullRunDecision: raise`` and
``except NullRunInfrastructureError: raise``. MRO-specific
ordering: NullRunBlockedException arm must stay BEFORE the
NullRunDecision umbrella (Blocked is a Decision); Backend /
Authentication / Transport arms must stay BEFORE the
NullRunInfrastructureError umbrella. Updated function-local
import block to include both umbrella classes.
DEF-NR-TRANSPORT-CATCHFANIN-GAP (2026-09-10) — TEST-ONLY:
Transport.execute 4xx catch-fan-in had the same gap shape at
the transport layer: _parse_v3_error_envelope returns typed
exceptions but the catch-fan-in arms (ApprovalReplayRejected /
Blocked / Backend / Auth / Transport) only covered five MRO
parents. Anything else fell through to ``except Exception: pass``
and returned a synthetic-block dict, losing the typed class
+ every first-class attr.
Same umbrella fix is wired into transport.py alongside the
foreign-WIP NR-SDK-A015-SURFACE rewrite of the 4xx handler.
Per CLAUDE.md "Чужие данные в WIP — нельзя трогать", the
transport.py change is committed when the foreign-WIP work
lands; the umbrella arms + module-level import fix live in the
same hunk and will travel together. The companion regression
test
(tests/test_2026_09_10_catchfanin_passthrough.py) IS committed
standalone because tests can be added independently and the
source-pin fixture pins the umbrella-arm shape so any reorder
/ removal fails the test before the foreign-WIP merge.
Tests (48 new tests, all pass):
- tests/test_2026_09_10_r001_passthrough.py (13 tests):
source-pin (5) + behavior (8). Verifies RateLimitError
pass-through arm present, ordered before NullRunTransportError
rewrap arm, only raises, comment tag present, RateLimitError
imported. Behavior: propagation unchanged, error_code=NR-R001
(NOT NR-B002), retry_after=42.5s preserved, upgrade_url +
body preserved, format_user_message returns NR-R001 line.
Regression guards: generic NullRunTransportError still
rewaps to NR-B001 NETWORK_ERROR, NullRunBlockedException
still passes through, NullRunBackendError parent still
rewaps to NR-B002 GATEWAY_ERROR.
- tests/test_2026_09_10_decision_infra_passthrough.py (18
tests): source-pin (8) + behavior (10). Verifies both
umbrella arms present, ordered before the catch-all, only
raise, comment tag present, both classes imported. Behavior:
NullRunAuthError preserves wire_code, NullRunProtocolError
preserves NR-P001, NullRunRateLimitRedisError preserves
NR-R002, NullRunChainError preserves chain_id +
backend_code, NullRunWorkflowInactiveError preserves
workflow_id, NullRunConsumeOverbudgetError preserves all
four counter attrs, WorkflowPausedException preserves
resume_after + workflow_id. Regression guards:
NullRunBlockedException still passes through,
NullRunTransportError still rewaps, NullRunBackendError
still rewaps.
- tests/test_2026_09_10_catchfanin_passthrough.py (17 tests):
source-pin (9) + behavior (5) + regression guards (3).
Pins the umbrella-arm shape in Transport.execute: Decision
arm AFTER BlockedException, Infrastructure arm AFTER Backend
/ Auth / Transport, both BEFORE the catch-all fallback.
Behavior: CHAIN_ORG_MISMATCH -> NullRunChainError with
chain_id, WORKFLOW_INACTIVE -> NullRunWorkflowInactiveError
with workflow_id, CONSUME_OVERBUDGET ->
NullRunConsumeOverbudgetError with counter attrs,
PROTOCOL_TOO_OLD -> NullRunProtocolError, RATE_LIMIT_REDIS
UNAVAILABLE -> NullRunRateLimitRedisError. Regression
guards: BUDGET_HARD_BLOCKED -> NullRunBudgetError (Blocked
arm wins by MRO), API_KEY_REVOKED -> NullRunAuthError (Auth
arm wins by MRO), unknown envelope still falls back via
synthetic dict (catch-all is intentional).
Test fixture gotchas documented in commit for future maintainers:
- Transport.execute signature is multiline
``def execute(\n self, ...``, regex must handle that
- v3 envelope uses ``error_code`` (NOT ``error`` — that is
legacy slug); legacy slug flattens everything into details
and loses top-level fields, so per-class dispatchers
cannot read chain_id / etc. from that shape
- RATE_LIMIT_REDIS_UNAVAILABLE wire status is 503 but the
SDK _retry_with_backoff short-circuits 5xx to
NullRunBackendError BEFORE the parser runs; use 4xx for
the test fixture
- TOOL_BLOCKED has a separate pre-existing parser bug at the
catalog-fallback branch (raises TypeError when
catalog-fallback calls NullRunBlockedException.__init__
without workflow_id / reason); that bug is OUT OF SCOPE for
this fix; test uses BUDGET_HARD_BLOCKED instead which has
explicit dispatch with the right kwargs
Scope note: 4 files committed (decorators.py + 3 new test
files). The transport.py umbrella arms + module-level imports
travel with the foreign-WIP NR-SDK-A015-SURFACE hunk; the
catch-fan-in regression tests live standalone and will fail
loudly if the umbrella arms are reordered or removed before
the foreign-WIP merge.
…lback TypeError
The parser's final catalog-fallback branch at
``_parse_v3_error_envelope`` (line ~2780) was:
allowed = {"error_code", "user_action", ...}
forwarded = {k: v for k, v in details.items() if k in allowed}
instance = catalog(full_message, **forwarded)
This generic fallback assumes ``catalog.__init__`` accepts a
string as the first positional arg. It works for
``NullRunError``-base subclasses (Protocol, RateLimitRedis, Auth)
which have ``(message, **kwargs)`` signature.
It FAILED for ``NullRunBlockedException`` subclasses which
require positional ``(workflow_id, reason, ...)``. The string
``full_message`` ended up in ``workflow_id``, the ``reason``
arg was missing, ``TypeError`` was raised.
The TypeError escaped the parser and was caught by the
catch-all ``except Exception: pass`` in ``Transport.execute``
(4xx handler) — surfacing the synthetic-block dict
``{"decision": "block", "explanation": "Gateway returned 403"}``
instead of the typed NR-T001 / NR-Lxxx catalog line.
Affected catalog entries (7): TOOL_BLOCKED, LOOP_DETECTED,
MODEL_REQUIRED, POLICY_UNCONFIGURED, TOO_MANY_PENDING_APPROVALS,
BUSINESS_IMPACT_INVALID, VALIDATION_FAILED.
Note: NullRunBudgetError and the 6 approval typed exceptions
were NOT affected — they have explicit dispatch branches that
use the correct (workflow_id, reason, status_code) signature.
Fix (committed standalone here, lives in transport.py which
has foreign-WIP NR-SDK-A015-SURFACE work):
Added a dedicated dispatch branch BEFORE the final catalog
fallback. Detects ``catalog is NullRunToolBlockedError`` or
``catalog is NullRunBlockedException`` and calls the
constructor with the right signature:
catalog(
workflow_id=str(details.get("workflow_id") or "unknown"),
reason=full_message,
status_code=status,
tool_name=details.get("tool_name"),
**forwarded,
)
Also added NullRunToolBlockedError and NullRunBlockedException
to the function-local import block at line ~2541.
Tests (14 tests, all pass):
- Source-pin (5): dedicated branch present in parser,
branch lives in _parse_v3_error_envelope (not elsewhere),
function-local imports include both NullRunToolBlockedError
and NullRunBlockedException, comment tag
DEF-NR-TOOLBLOCKED-PARSER present, branch uses correct
constructor signature (workflow_id=str(...), reason=
full_message, status_code=status — NOT the broken
catalog(full_message, ...) form)
- Parser-level behavior (7): TOOL_BLOCKED ->
NullRunToolBlockedError (preserves tool_name, workflow_id,
NR-T001), LOOP_DETECTED / MODEL_REQUIRED /
POLICY_UNCONFIGURED / TOO_MANY_PENDING_APPROVALS /
BUSINESS_IMPACT_INVALID -> NullRunBlockedException with
workflow_id, VALIDATION_FAILED with no workflow_id
defaults to "unknown"
- End-to-end (2): TOOL_BLOCKED / LOOP_DETECTED do NOT
swallow to synthetic block dict through Transport.execute
(the catch-fan-in's ``except NullRunBlockedException: raise``
arm now re-raises the typed exception — pre-fix it
silently fell through to ``except Exception: pass``)
Scope note: only this test file is committed standalone.
The transport.py fix (added dispatch branch + 2 imports) is
interleaved with the foreign-WIP NR-SDK-A015-SURFACE work
that rewrote the 4xx handler. Per CLAUDE.md "Чужие данные в
WIP — нельзя трогать", the parser fix is committed when the
foreign-WIP work lands. The source-pin fixture in this test
file pins the dedicated-branch shape so any refactor that
reverts to the broken generic catalog-fallback fails the
test before the foreign-WIP merge.
…gh closure + catalog coverage + transport cleanup 12 commits ahead of origin/master, organised into five themes: 1. Typed-catalog dispatch in @Protect block path - 2e77902 fix(sdk): runtime.execute block path dispatches typed catalog exceptions (DEF-NR-RUNTIME-BLOCK-TYPED). Adds tests/test_2026_09_10_runtime_block_typed_dispatch.py (356 lines). - 834d9ea fix(tests): pin test_runtime catalog-code migration (DEF-NR-RUNTIME-BLOCK-TYPED). Last stale wire-code assertion in test_runtime.py retired. 2. @Protect pass-through closure (no more rewrap-loss) - 257ab7f fix(sdk): @Protect pass-through for NullRunExecutionNotFoundError (NR-EX01). Adds tests/test_2026_09_10_nr_ex01_passthrough.py (312 lines). - 2ad87dd fix(sdk): close @Protect rewrap-loss for RateLimitError, Decision leaves, Infrastructure leaves. Adds three regression files totalling 1 400 lines (catchfanin / decision_infra / R001). 3. Catalog coverage gap closed - a4c6019 fix(sdk): add NR-A012 catalog entry for NullRunApprovalExpiredError. - a441558 fix(sdk): close catalog coverage gap for 13 typed-exception codes (NR-A010/A011/A013/A014 etc.). 170 lines of coverage in test_messages.py. 4. Transport cleanup - 25eb2c2 fix transport (defect37 scratch file removed by 0299059). - b7575ad fix transport v2. - bb1066c fix transport v3. Three commits add ~1 005 lines of regression coverage (check_failopen / mcp_umbrella_symmetry / sdk_cleanup) and harden the transport layer's check-fail-open and umbrella paths. 5. ToolBlocked parser regression test (release-pinned for foreign-WIP) - 2dfd208 test(sdk): regression tests for DEF-NR-TOOLBLOCKED-PARSER catalog-fallback TypeError. tests/test_2026_09_10_toolblocked_parser.py (399 lines) pins the dedicated-branch shape so any refactor that reverts to the broken generic catalog-fallback fails before the foreign-WIP NR-SDK-A015-SURFACE merge. CHANGELOG.md ## [0.16.7] block expanded to match the 0.16.6 release notes format (themes + DEFS tags + commit list + Compatibility + Why this is needed). uv.lock version stamp (0.16.0 → 0.16.7) folded into this commit. No wire-format change — /gate, /execute, /track, /cancel payloads byte-identical to 0.16.6.
Pre-fix, the Layer-1 block-decision path in NullRunRuntime.execute
unconditionally raised NullRunBlockedException with
error_code=<wire_code>. Cookbook recipes that branched on typed
catalog arms (e.g. except NullRunApprovalReplayRejectedError:
for NR-A015) never matched — the wire SCREAMING_SNAKE code is not
the catalog code that format_user_message looks up, so the user
saw FALLBACK_MESSAGE ('Something went wrong. Please try again.')
instead of the typed catalog wording.
Repro observed on 2026-09-10 (langgraph approval demo, third
refund):
[sdk] Something went wrong. Please try again.
(error_code=APPROVAL_REPLAY_REJECTED)
Post-fix: the dispatch logic is factored into
NullRunRuntime._build_block_exception which imports
_V3_ERROR_CODE_MAP from nullrun.transport and dispatches
the typed catalog class for known wire codes (Priority 1a: typed
subclass, e.g. NullRunApprovalReplayRejectedError for
APPROVAL_REPLAY_REJECTED). The catalog class attribute owns
error_code — we MUST NOT pass error_code=wire_code to it
because the constructor's details.pop('error_code') path would
override NR-A015 with the wire code and defeat
format_user_message. For catalog entries that map to the base
NullRunBlockedException (Priority 1b, e.g.
APPROVAL_VALIDATION_FAILED), the wire code IS
self.error_code — back-compat callers branch on
exc.error_code == 'APPROVAL_*'. Drift (Priority 2) and legacy
keyword-on-explanation (Priority 3) paths preserve the same wire
payload + mapped_class back-compat shim.
Wire payload convention preserved (matching the existing
NullRunBlockedException constructor): the wire payload lands
nested under self.details['details'] via the details=...
kwarg. Typed kwargs (e.g. approval_id on
NullRunApprovalReplayRejectedError) are forwarded as named kwargs
so they promote to first-class attrs (exc.approval_id) for
cookbook recipes.
Verification: 11/11 new tests pass
(tests/test_2026_09_10_runtime_block_typed_dispatch.py), and the
full SDK suite (1749 tests) is green — no regressions on the
pre-existing test_runtime.py::test_execute_blocked_surfaces_wire_error_code
(DEF-ARFLOW-TOOLNAME-01, 2026-08-05) which asserts the
wire-payload + mapped_class back-compat shape.
…LOCK-TYPED)
test_execute_blocked_surfaces_wire_error_code expected the wire
SCREAMING_SNAKE code (APPROVAL_VALIDATION_FAILED) on exc.error_code,
but the 2026-09-10 catalog migration now correctly returns the typed
catalog code (NR-A016 / NullRunApprovalDbUnavailableError) per
runtime._build_block_exception dispatch priority 1.
Post-fix the SDK contract is:
- exc.error_code == catalog code (NR-A016) for format_user_message
- exc.details['details']['error_code'] == wire code (preserved verbatim)
- exc.details['details']['mapped_class'] == typed class name
(NullRunApprovalDbUnavailableError, NOT the base NullRunBlockedException)
This test was the last stale wire-code assertion in test_runtime.py
preventing full SDK test pass under the new catalog contract.
- dist_local/nullrun-0.16.7-py3-none-any.whl: pre-built wheel (305KB binary) committed in 0a52c96. dist/ is already gitignored but dist_local/ was missed; would otherwise ship in the 0.16.7 sdist. - src/nullrun/transport.py.defect37: 144KB / 3168-line debug scratch file committed in 25eb2c2; not referenced by any runtime code (grep confirms zero references in src/ or tests/). - .gitignore: added dist_local/ and src/**/*.defect* to prevent re-introduction. No code change; tests, mypy, ruff unaffected.
runtime.execute typed-catalog dispatch path (DEF-NR-RUNTIME-BLOCK-TYPED,
introduced in 2e77902) passes kwargs into typed_cls(...) where typed_cls
is selected at runtime via the catalog. 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.
Two ways to fix:
(a) a Protocol for typed_cls
(b) splitting the call site per catalog class
Both invasive. Track the error code in the existing runtime.py
[[tool.mypy.overrides]] block per the comment block above ("Converge
via per-file [[tool.mypy.overrides]] entries — each file gets explicit
ignore codes so CI breaks when a NEW code appears"). Revisit when the
typed-catalog surface stabilises.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Patch release 0.16.7 — 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_MESSAGESentry (no more "Something went wrong. Please try again." fallback), and@protect-decorated sites surface the real exception type instead of rewriting it into a genericNullRunBlockedException. The@protectblock path inruntime.executenow dispatches the actual catalog code throughformat_user_message, so wire-error codes (NR-A012, NR-A016, NR-EX01, …) reach users with actionable wording.No wire-format change.
/gate,/execute,/track,/cancelpayloads byte-identical to 0.16.6.Fixed
runtime.executeblock path raises the catalog exception (NR-A016 etc.) instead of the generic fallback (src/nullrun/runtime.py,2e77902).exc.error_codecarries the catalog code soformat_user_messagefinds actionable wording; downstream sites inspectingexc.details['details']['mapped_class']see the typed class name (e.g.NullRunApprovalDbUnavailableError) rather than the baseNullRunBlockedException.test_execute_blocked_surfaces_wire_error_codepinned to the new typed-dispatch contract (834d9ea). Last stale wire-code assertion intest_runtime.pyblocking full SDK pass under the post-0.16.6 catalog contract._enforce_sensitive_toolno longer rewritesNullRunExecutionNotFoundError(NR-EX01) intoNullRunBlockedException(NR-B002)(257ab7f). The typed class,error_code,execution_id,regate_required, and the NR-EX01 line fromformat_user_messageall propagate unchanged._enforce_sensitive_toolno longer rewrap typed exceptions (RateLimitError, Decision leaves, Infrastructure leaves) (2ad87dd). Three regression test files pin the umbrella shape (1 400 lines total).DEFAULT_MESSAGES["NR-A012"]filled in forNullRunApprovalExpiredError(a4c6019); tests intest_typed_exceptions_full_audit.pyandtest_messages.pycover the new entry.DEFAULT_MESSAGESfilled for every remaining typed exception the SDK can raise (NR-A010, NR-A011, NR-A013, NR-A014, plus the rest of the catalog) (a441558, 68 lines added).25eb2c2,b7575ad,bb1066c). Three commits add ~1 005 lines of regression coverage and harden transport's check-fail-open and umbrella paths.Cleanup (pre-flight, on master before branch cut)
dist_local/nullrun-0.16.7-py3-none-any.whl(305 KB pre-built wheel) andsrc/nullrun/transport.py.defect37(144 KB / 3 168-line debug scratch) accidentally committed in0a52c96/25eb2c2and removed in commit4c2490e..gitignoreextended withdist_local/andsrc/**/*.defect*to prevent re-introduction. Hatchling's sdist excludes onlytests/,.github/,*.pyc,__pycache__/; without this cleanup both files would have shipped in the 0.16.7 PyPI sdist.Tooling
d8b32da chore(mypy)— trackcall-argdebt atruntime.py:3213in the existing per-file mypy override. The 5 errors at the typed-catalog dispatch site are a regression from2e77902's dynamictyped_cls(...)call; the kwargs are catalog-aware via_TYPED_KWARGS_BY_CLASSlookup so the call is correct at runtime. Tracked per the comment block inpyproject.toml(the project's "tracked debt per-file override" pattern).Verification
ruff check src testsmypy src/nullrunpytest -qgit diff origin/master..HEAD --statdist_local/,transport.py.defect37absent)nullrun.__version__0.16.7Commits included
Plus 8 commits ahead of
origin/masteralready in local master history (the substantive fixes themselves — see CHANGELOG.md for the per-commit detail).After merge to master, tag
v0.16.7will be created on the merge commit, which triggers.github/workflows/publish.yml→ PyPI Trusted Publishing + GitHub Release (auto-notes, wheel + sdist attached).