Skip to content

chore(release): 0.16.7 — typed-catalog dispatch + @protect pass-through closure + catalog coverage + transport cleanup - #99

Merged
maltsev-dev merged 17 commits into
masterfrom
release/0.16.7
Sep 10, 2026
Merged

maltsev-dev merged 17 commits into
masterfrom
release/0.16.7

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

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_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. /gate, /execute, /track, /cancel payloads byte-identical to 0.16.6.

Fixed

  • DEFS-SDKEXEC-TYPED-DISPATCHruntime.execute block path raises the catalog exception (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 inspecting exc.details['details']['mapped_class'] see the typed class name (e.g. NullRunApprovalDbUnavailableError) rather than the base NullRunBlockedException.
  • DEFS-SDKEXEC-BLOCK-PINtest_execute_blocked_surfaces_wire_error_code pinned to the new typed-dispatch contract (834d9ea). 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_enforce_sensitive_tool no longer rewrites NullRunExecutionNotFoundError (NR-EX01) into NullRunBlockedException(NR-B002) (257ab7f). The typed class, error_code, execution_id, regate_required, and the NR-EX01 line from format_user_message all propagate unchanged.
  • DEFS-SDKPROTECT-CATCHFANIN — catch-fan-in arms in _enforce_sensitive_tool no longer rewrap typed exceptions (RateLimitError, Decision leaves, Infrastructure leaves) (2ad87dd). Three regression test files pin the umbrella shape (1 400 lines total).
  • DEFS-SDKCATALOG-A012DEFAULT_MESSAGES["NR-A012"] filled in for NullRunApprovalExpiredError (a4c6019); tests in test_typed_exceptions_full_audit.py and test_messages.py cover the new entry.
  • DEFS-SDKCATALOG-COVERAGE-GAPDEFAULT_MESSAGES filled 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).
  • DEFS-SDKTRANSPORT-CHECK-FAILOPEN / MCP-UMBRELLA / SDK-CLEANUP — transport cleanup sequence (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) and src/nullrun/transport.py.defect37 (144 KB / 3 168-line debug scratch) accidentally committed in 0a52c96 / 25eb2c2 and removed in commit 4c2490e. .gitignore extended with dist_local/ and src/**/*.defect* to prevent re-introduction. Hatchling's sdist excludes only tests/, .github/, *.pyc, __pycache__/; without this cleanup both files would have shipped in the 0.16.7 PyPI sdist.

Tooling

  • d8b32da chore(mypy) — track call-arg debt at runtime.py:3213 in the existing per-file mypy override. The 5 errors at the typed-catalog dispatch site are a regression from 2e77902's dynamic typed_cls(...) call; the kwargs are catalog-aware via _TYPED_KWARGS_BY_CLASS lookup so the call is correct at runtime. Tracked per the comment block in pyproject.toml (the project's "tracked debt per-file override" pattern).

Verification

Check Result
ruff check src tests All checks passed
mypy src/nullrun Success: no issues found in 37 source files
pytest -q 1784 passed, 4 skipped in 98.9s (vs 1658 baseline 0.16.6 — 126 new regression tests added by this release)
Scratch files in git diff origin/master..HEAD --stat clean (none — dist_local/, transport.py.defect37 absent)
nullrun.__version__ 0.16.7
Wire-format compatibility unchanged from 0.16.6

Commits included

ef20429 chore(release): 0.16.7 — typed-catalog dispatch + @protect pass-through closure + catalog coverage + transport cleanup
caaeea0 fix transport
abde98d fix(sdk): runtime.execute block path dispatches typed catalog exceptions
a0c233c fix transport v2
d4e8362 fix transport v3
fa38990 fix(tests): pin test_runtime catalog-code migration (DEF-NR-RUNTIME-BLOCK-TYPED)
4c2490e chore: remove accidentally-committed scratch artifacts
d8b32da chore(mypy): track call-arg debt at runtime.py:3213

Plus 8 commits ahead of origin/master already in local master history (the substantive fixes themselves — see CHANGELOG.md for the per-commit detail).

After merge to master, tag v0.16.7 will be created on the merge commit, which triggers .github/workflows/publish.yml → PyPI Trusted Publishing + GitHub Release (auto-notes, wheel + sdist attached).

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

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.50467% with 23 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/runtime.py 68.18% 9 Missing and 5 partials ⚠️
src/nullrun/transport.py 76.31% 7 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit 06ad495 into master Sep 10, 2026
5 checks passed
@maltsev-dev
maltsev-dev deleted the release/0.16.7 branch September 10, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant