nullrun 0.16.7__tar.gz → 0.16.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {nullrun-0.16.7 → nullrun-0.16.8}/CHANGELOG.md +115 -0
  2. {nullrun-0.16.7 → nullrun-0.16.8}/PKG-INFO +1 -1
  3. {nullrun-0.16.7 → nullrun-0.16.8}/pyproject.toml +1 -1
  4. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/__version__.py +1 -1
  5. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/runtime.py +123 -23
  6. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/transport.py +69 -6
  7. {nullrun-0.16.7 → nullrun-0.16.8}/uv.lock +1 -1
  8. {nullrun-0.16.7 → nullrun-0.16.8}/.codecov.yml +0 -0
  9. {nullrun-0.16.7 → nullrun-0.16.8}/.dockerignore +0 -0
  10. {nullrun-0.16.7 → nullrun-0.16.8}/.gitignore +0 -0
  11. {nullrun-0.16.7 → nullrun-0.16.8}/.pre-commit-config.yaml +0 -0
  12. {nullrun-0.16.7 → nullrun-0.16.8}/Dockerfile +0 -0
  13. {nullrun-0.16.7 → nullrun-0.16.8}/LICENSE +0 -0
  14. {nullrun-0.16.7 → nullrun-0.16.8}/Makefile +0 -0
  15. {nullrun-0.16.7 → nullrun-0.16.8}/README.md +0 -0
  16. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A001.md +0 -0
  17. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A002.md +0 -0
  18. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A003.md +0 -0
  19. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A004.md +0 -0
  20. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B001.md +0 -0
  21. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B002.md +0 -0
  22. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B003.md +0 -0
  23. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B004.md +0 -0
  24. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B005.md +0 -0
  25. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C000.md +0 -0
  26. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C001.md +0 -0
  27. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C003.md +0 -0
  28. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C004.md +0 -0
  29. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-CH001.md +0 -0
  30. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-L001.md +0 -0
  31. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-O001.md +0 -0
  32. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-P001.md +0 -0
  33. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-R001.md +0 -0
  34. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-R002.md +0 -0
  35. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-T001.md +0 -0
  36. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-W002.md +0 -0
  37. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-W003.md +0 -0
  38. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-W004.md +0 -0
  39. {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/README.md +0 -0
  40. {nullrun-0.16.7 → nullrun-0.16.8}/hatch_build.py +0 -0
  41. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/__init__.py +0 -0
  42. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/_handle.py +0 -0
  43. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/_registry.py +0 -0
  44. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/_singleton.py +0 -0
  45. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/actions.py +0 -0
  46. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/audit.py +0 -0
  47. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/breaker/__init__.py +0 -0
  48. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/breaker/circuit_breaker.py +0 -0
  49. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/breaker/exceptions.py +0 -0
  50. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/business_impact.py +0 -0
  51. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/capabilities.py +0 -0
  52. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/context.py +0 -0
  53. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/decorators.py +0 -0
  54. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/extractor.py +0 -0
  55. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/__init__.py +0 -0
  56. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/_safe_patch.py +0 -0
  57. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/auto.py +0 -0
  58. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/auto_requests.py +0 -0
  59. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/autogen.py +0 -0
  60. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/crewai.py +0 -0
  61. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/langgraph.py +0 -0
  62. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/llama_index.py +0 -0
  63. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/integrations/__init__.py +0 -0
  64. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/integrations/fastapi.py +0 -0
  65. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/messages.py +0 -0
  66. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/observability/__init__.py +0 -0
  67. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/observability/error_hooks.py +0 -0
  68. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/observability/status.py +0 -0
  69. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/py.typed +0 -0
  70. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/toolbox/__init__.py +0 -0
  71. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/toolbox/langgraph.py +0 -0
  72. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/toolbox/mcp.py +0 -0
  73. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/tracing.py +0 -0
  74. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/transport_websocket.py +0 -0
  75. {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/uuid7.py +0 -0
@@ -1,3 +1,118 @@
1
+ ## [0.16.8] - 2026-09-11
2
+
3
+ Patch release — closes the NR-A015 wire-shape gap on the SDK side. The
4
+ `/execute` `require_approval` arm (backend v3.79+) mints a fresh
5
+ server-side execution_id for the approval row and echoes it via
6
+ `reservation_id`. Pre-fix `runtime.execute` captured that id into the
7
+ contextvar AFTER `/gate` calls but not after `/execute`, so the post-
8
+ approval `/execute` re-fire sent the stale pre-arm execution_id;
9
+ `consume_approved`'s `WHERE execution_id = $3` predicate missed the
10
+ freshly-stamped row and fell through to the terminal
11
+ `APPROVAL_REPLAY_REJECTED` branch. This release wires the
12
+ post-`/execute` capture and syncs the kwargs dict so the re-fire uses
13
+ the freshly-minted id.
14
+
15
+ Also includes the AUTH-01 / HEART-01 sweep from the 24-driver pass:
16
+ reclassification of httpx transport errors on the auth path, plus a
17
+ public `Runtime.heartbeat()` wrapper.
18
+
19
+ ### Fixed
20
+
21
+ - **DEF-EXECUTE-CAPTURE-WIRING** — `runtime.execute` now calls
22
+ `_capture_server_minted_execution_id(result)` immediately after
23
+ `_transport.execute(...)` and syncs the re-fire kwargs dict to the
24
+ captured id (`src/nullrun/runtime.py`). The post-approval re-fire
25
+ now sends the freshly-minted execution_id stamped on the approval
26
+ row, so `consume_approved`'s `WHERE execution_id = $3` predicate
27
+ matches. Closes the SDK-side leg of NR-A015 on the `/execute`
28
+ require_approval arm.
29
+ - **DEF-WAIT-FOR-APPROVAL-EXEC-ID** — both
30
+ `_wait_for_approval_resolution` call-sites (`check_workflow_budget`
31
+ + `runtime.execute`) now pass the captured server-minted
32
+ execution_id from the contextvar (with the prior
33
+ `org_id`/`workflow_id` sentinel as fallback) instead of the
34
+ `workflow_id` sentinel (`src/nullrun/runtime.py`). Diagnostic
35
+ improvement only — the WS handler matches on `approval_id` — but
36
+ log lines + entry metadata now reflect the server-minted id.
37
+ - **DEF-AUTH-01** — `NullRunRuntime.__init__` auth path no longer
38
+ reclassifies `httpx.RequestError` as `NullRunAuthenticationError`.
39
+ The defensive duplicate arm in `__init__` (backstop for a code path
40
+ that no longer exists) is removed; the real arm in `_authenticate`
41
+ now raises `NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")`
42
+ — matching the convention used by `Transport.heartbeat` for the
43
+ same condition on `/heartbeat`. The previous wrap misled operators:
44
+ a network failure looked like an auth failure, even though the
45
+ message itself acknowledged "this is a transport failure (not an
46
+ auth failure)".
47
+
48
+ **Back-compat**: `NullRunTransportError` and `NullRunAuthenticationError`
49
+ are siblings under `NullRunInfrastructureError`, so the parent class
50
+ still catches both. Cookbook code that branches on
51
+ `except NullRunAuthenticationError:` for retry will need to also
52
+ catch `NullRunTransportError`. Two existing tests
53
+ (`test_authenticate_network_error_raises` in `test_runtime.py` and
54
+ `test_runtime_branches.py`) were locking in the old misclassification
55
+ and have been updated to assert the correct class.
56
+
57
+ ### Added
58
+
59
+ - **DEF-HEART-01** — `NullRunRuntime.heartbeat(chain_id)` public method
60
+ added (thin forwarder to `Transport.heartbeat`). Mirrors the
61
+ `chain_end` / `cancel_execution` pattern. Use for single-shot chain
62
+ TTL extensions; `Runtime.ping_chain()` remains the wall-clock
63
+ scheduler variant. Pure addition — no existing API surface changes.
64
+
65
+ - **`tests/test_2026_09_11_execute_capture_wires_execution_id.py`** (220 lines). Two regression tests pinning the fix:
66
+ - `test_execute_captures_reservation_id_from_response` — verifies the contextvar updates from the `/execute` response and the re-fire uses the captured id (not the stale pre-call one).
67
+ - `test_execute_wait_for_approval_receives_captured_eid` — verifies the WS resolution handler receives the captured execution_id.
68
+ - **`tests/test_2026_09_11_auth_heartbeat_sweep.py`** (~190 lines, 7 tests). Regression tests for AUTH-01 + HEART-01:
69
+ - AUTH-01: `test_auth_connect_error_raises_transport_error_not_auth`, `test_auth_timeout_raises_transport_error_not_auth`, `test_auth_error_class_no_longer_catches_network_error` (back-compat parent-class check).
70
+ - HEART-01: `test_heartbeat_method_exists_on_public_api`, `test_heartbeat_forwards_chain_id_to_transport`, `test_heartbeat_passes_through_transport_error`, `test_ping_chain_still_works_after_heartbeat_added`.
71
+
72
+ ### Compatibility
73
+
74
+ Pure reliability fix — no wire-format change. `/gate`, `/execute`,
75
+ `/track`, `/cancel` payloads are byte-identical to 0.16.7. Backend
76
+ v3.79+ is required for the wire-shape contract (the `reservation_id`
77
+ echo is the v3.79+ field that closes the gap); pre-v3.79 backends
78
+ silently fall through the capture (helper is fail-OPEN on malformed
79
+ values), preserving the pre-fix behaviour for un-deployed backends.
80
+
81
+ ### Why this is needed
82
+
83
+ **NR-A015 (execute capture)** — the user-facing symptom was a
84
+ post-approval `/execute` re-fire landing on `APPROVAL_REPLAY_REJECTED`
85
+ because the SDK stamped the pre-arm `execution_id` into the
86
+ re-fire's kwargs dict, but `consume_approved`'s `WHERE execution_id
87
+ = $3` predicate had to match the freshly-minted id from the
88
+ approval-row bind (backend v3.79+). The terminal error was a
89
+ typed `NullRunApprovalReplayRejectedError(NR-A015)` — operators had
90
+ no signal that the re-fire was sending a stale id rather than a
91
+ truly-replayed call. 0.16.8 captures the `reservation_id` echo
92
+ from `/execute`'s response into the same contextvar that `/gate`
93
+ already uses, and re-emits the captured id on the re-fire kwargs
94
+ dict.
95
+
96
+ **AUTH-01 (transport reclassification)** — operators reading
97
+ `NullRunAuthenticationError` from a failed `__init__` were led to
98
+ rotate the API key because the class name suggested auth failure.
99
+ Pre-fix, the duplicate arm in `NullRunRuntime.__init__` rewrapped
100
+ `httpx.RequestError` as `NullRunAuthenticationError` (with the
101
+ "this is a transport failure (not an auth failure)" wording in the
102
+ message itself — a smoke signal the wrap was wrong). 0.16.8 raises
103
+ `NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")`
104
+ matching the `Transport.heartbeat` convention. Catch-block semantics
105
+ in cookbooks now need `except (NullRunAuthenticationError,
106
+ NullRunTransportError):` for full coverage under
107
+ `NullRunInfrastructureError`.
108
+
109
+ **HEART-01 (public API)** — single-shot chain TTL extensions had to
110
+ reach through `runtime._transport.heartbeat(...)` because
111
+ `NullRunRuntime` exposed only the wall-clock `ping_chain()` scheduler.
112
+ 0.16.8 adds `NullRunRuntime.heartbeat(chain_id)` as a thin
113
+ forwarder to `Transport.heartbeat`, matching the chain_end /
114
+ cancel_execution pattern.
115
+
1
116
  ## [0.16.7] - 2026-09-10
2
117
 
3
118
  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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nullrun
3
- Version: 0.16.7
3
+ Version: 0.16.8
4
4
  Summary: NullRun Python SDK — runtime decision layer for tool-using AI agents.
5
5
  Project-URL: Homepage, https://nullrun.io
6
6
  Project-URL: Documentation, https://docs.nullrun.io
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  name = "nullrun"
7
7
  # Full release history lives in CHANGELOG.md; only the current version
8
8
  # is pinned here.
9
- version = "0.16.7"
9
+ version = "0.16.8"
10
10
  # Kept under the 200-char preview threshold so the full line is visible
11
11
  # without an "expand" click. The headline is the canonical §1 statement
12
12
  # from positioning.md — "runtime decision layer for tool-using AI agents"
@@ -5,5 +5,5 @@ this module is the single source of truth for the runtime version
5
5
  string and the SDK_MIN_VERSION constant.
6
6
  """
7
7
 
8
- __version__ = "0.16.7"
8
+ __version__ = "0.16.8"
9
9
  __platform_version__ = "1.0.0"
@@ -767,15 +767,14 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
767
767
  # Test mode: skip all network calls
768
768
  self._transport.start()
769
769
  else:
770
- try:
771
- self._authenticate()
772
- except NullRunAuthenticationError:
773
- raise # Re-raise auth errors immediately - don't continue in unprotected mode
774
- except httpx.RequestError as e:
775
- raise NullRunAuthenticationError(
776
- f"Auth request failed: {e}. Cannot establish secure connection to NullRun. "
777
- f"Refusing to operate in unprotected mode."
778
- ) from e
770
+ # AUTH-01 (2026-09-11): previously this arm caught ``httpx.RequestError``
771
+ # and re-raised ``NullRunAuthenticationError``, misclassifying network
772
+ # failures as auth failures. Arm B in ``_authenticate`` already catches
773
+ # the same condition with the correct class (``NullRunTransportError``);
774
+ # this defensive duplicate was a backstop for a code path that no longer
775
+ # exists between ``_authenticate()`` and ``self._transport.start()``.
776
+ # Remove: arm B is sufficient.
777
+ self._authenticate()
779
778
  self._transport.start()
780
779
  # Start remote polling unless disabled (internal `polling=False`
781
780
  # for tests/CI). Production always polls.
@@ -1252,19 +1251,22 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
1252
1251
  )
1253
1252
  raise err
1254
1253
  except httpx.RequestError as e:
1255
- # Network error - raise exception, do not fall back silently
1256
- err = NullRunAuthenticationError(
1254
+ # AUTH-01 (2026-09-11): reclassify httpx.RequestError as
1255
+ # ``NullRunTransportError`` instead of ``NullRunAuthenticationError``.
1256
+ # The previous wrap misled operators — the same condition (DNS failure,
1257
+ # connection refused, TLS handshake error, request timeout) is correctly
1258
+ # classified as ``NullRunTransportError(NETWORK_ERROR, "auth")`` by
1259
+ # ``Transport.heartbeat`` (transport.py:2077) and the rest of the SDK.
1260
+ # The ``user_action`` previously embedded here noted "This is a
1261
+ # transport failure (not an auth failure)" — the class should match
1262
+ # the message. ``NullRunTransportError.__init__`` already sets
1263
+ # ``error_code="NR-B001"`` (transport.py:233) and the standard
1264
+ # retryable ``user_action`` (transport.py:234-237).
1265
+ err = NullRunTransportError(
1257
1266
  f"Auth request failed: {e}. Cannot establish secure connection to NullRun. "
1258
1267
  f"Refusing to operate in unprotected mode.",
1259
- error_code="NR-B001",
1260
- user_action=(
1261
- "Could not reach the NullRun backend at "
1262
- f"{self.api_url}. Check network connectivity and the "
1263
- "configured api_url. This is a transport failure (not "
1264
- "an auth failure) — the API key may be valid, the "
1265
- "backend is just unreachable."
1266
- ),
1267
- cause=e,
1268
+ source=TransportErrorSource.NETWORK_ERROR,
1269
+ endpoint="auth",
1268
1270
  )
1269
1271
  self._emit_sdk_error(err, stage="auth")
1270
1272
  raise err from e
@@ -2207,10 +2209,26 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
2207
2209
  f"check_workflow_budget: require_approval id={approval_id} -- "
2208
2210
  f"waiting for WS push (timeout={server_timeout if server_timeout is not None else 'env-default'})"
2209
2211
  )
2212
+ # 2026-09-11: pass the actual server-minted execution_id
2213
+ # (captured above from the /gate response) into the WS
2214
+ # wait so the entry's metadata + diagnostic log lines
2215
+ # reflect the same id the server stamped on the
2216
+ # approval row. Pre-fix this string fell back to
2217
+ # ``str(self.organization_id)`` which made the
2218
+ # ``__nullrun_unknown__`` sentinel leak into exception
2219
+ # payloads (demo
2220
+ # langgraph_openai_approval_demo.py prints
2221
+ # ``execution_id=exc.workflow_id``). The handler matches
2222
+ # purely on ``approval_id``, so this is diagnostic-only
2223
+ # — captured on the consumer side.
2224
+ from nullrun.context import get_server_minted_execution_id
2225
+
2226
+ _captured_eid = get_server_minted_execution_id()
2210
2227
  result = self._wait_for_approval_resolution(
2211
2228
  approval_id=approval_id,
2212
2229
  workflow_id=workflow_id,
2213
- execution_id=str(self.organization_id or "local"),
2230
+ execution_id=_captured_eid
2231
+ or str(self.organization_id or "local"),
2214
2232
  timeout_seconds=server_timeout,
2215
2233
  )
2216
2234
  outcome = (result.get("outcome") or "").lower()
@@ -2342,6 +2360,35 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
2342
2360
 
2343
2361
  return stop
2344
2362
 
2363
+ def heartbeat(self, chain_id: str) -> dict[str, Any]:
2364
+ """POST /api/v1/heartbeat — extend a chain's idle TTL.
2365
+
2366
+ Single-shot wrapper around ``Transport.heartbeat`` matching the
2367
+ ``chain_end`` / ``cancel_execution`` public-API pattern
2368
+ (DEF-HEART-01, 2026-09-11). Use this for one-off TTL extensions;
2369
+ use ``ping_chain`` when you want a wall-clock scheduler that calls
2370
+ this method every N seconds.
2371
+
2372
+ The wire body is ``{"chain_id": chain_id}`` — HMAC headers carry
2373
+ ``organization_id`` + ``trace_id`` automatically via
2374
+ ``_build_signed_headers``, so no extra kwargs are needed at the
2375
+ transport layer (mirrors the simpler heartbeat shape vs. chain_end's
2376
+ ``organization_id``/``trace_id`` injection).
2377
+
2378
+ The transport layer already raises ``NullRunTransportError(
2379
+ NETWORK_ERROR, "heartbeat")`` for network errors (transport.py:2077),
2380
+ so no reclassification is needed at this layer.
2381
+
2382
+ Args:
2383
+ chain_id: Active chain_id (UUID v4) registered via
2384
+ ``with chain(chain_id, op="start")``.
2385
+
2386
+ Returns:
2387
+ Parsed JSON dict (typically ``{"status": "ok", "chain_id": ...,
2388
+ "last_active": ts}``).
2389
+ """
2390
+ return self._transport.heartbeat(chain_id)
2391
+
2345
2392
  def cancel_execution(self, execution_id: str, reason: str | None = None) -> dict[str, Any]:
2346
2393
  """Cancel an in-flight execution via /api/v1/cancel
2347
2394
  .
@@ -2379,7 +2426,21 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
2379
2426
  Returns:
2380
2427
  Parsed JSON dict.
2381
2428
  """
2382
- return self._transport.chain_end(chain_id)
2429
+ # DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix
2430
+ # sent only ``{chain_id, chain_op, execution_id}`` to /gate and the
2431
+ # backend rejected with 422 ``missing field 'organization_id'``.
2432
+ # Fix: forward ``self.organization_id`` (set in ``_authenticate``)
2433
+ # and the contextvar trace_id so the SDK builds a complete
2434
+ # ``GateRequest`` body. ``trace_id`` is sourced from the contextvar
2435
+ # to match the rest of the SDK's wire-shape policy (one trace id
2436
+ # per logical chain).
2437
+ from nullrun.context import get_trace_id
2438
+
2439
+ return self._transport.chain_end(
2440
+ chain_id,
2441
+ organization_id=self.organization_id,
2442
+ trace_id=get_trace_id(),
2443
+ )
2383
2444
 
2384
2445
  def approximate_budget(self) -> dict[str, Any]:
2385
2446
  """UI-only budget estimate via GET /api/v1/budget/approximate
@@ -2963,6 +3024,34 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
2963
3024
  execute_kwargs["action_digest"] = action_digest
2964
3025
  result = self._transport.execute(**execute_kwargs)
2965
3026
 
3027
+ # 2026-09-11: DEF-EXECUTE-CAPTURE-WIRING. The /execute
3028
+ # require_approval arm mints a FRESH execution_id (server-
3029
+ # side) for the approval row + writes the binding, then
3030
+ # echoes the new id back via ``reservation_id`` (mirrored by
3031
+ # the backend's GateResponse::require_approval constructor —
3032
+ # see backend/src/enforcement/gate_wire_adapter.rs v3.79+).
3033
+ # Without this capture below, the contextvar stays at the
3034
+ # /gate-minted value, and the post-approval /execute re-fire
3035
+ # (line ~3037) sends the OLD execution_id back to the
3036
+ # server. ``consume_approved``'s ``WHERE execution_id = $3``
3037
+ # predicate then misses the row stamped with the freshly-
3038
+ # minted one; the diagnostic SELECT walks all alternatives
3039
+ # without match and falls through to the terminal
3040
+ # replay-race branch (``APPROVAL_REPLAY_REJECTED``) — the
3041
+ # SDK raises NR-A015. Capture here is fail-OPEN (drops
3042
+ # malformed values silently via the helper's UUID parse),
3043
+ # matching ``check_workflow_budget``'s behaviour.
3044
+ _capture_server_minted_execution_id(result)
3045
+
3046
+ # Sync the kwargs dict to the captured id so the post-approval
3047
+ # re-fire (line ~3083, ``self._transport.execute(**execute_kwargs)``)
3048
+ # uses the freshly-minted execution_id. The contextvar update
3049
+ # alone is not enough — the re-fire path does NOT re-read from
3050
+ # the contextvar; it reuses the kwargs built before /execute.
3051
+ _captured_after_execute = get_server_minted_execution_id()
3052
+ if _captured_after_execute is not None:
3053
+ execute_kwargs["execution_id"] = _captured_after_execute
3054
+
2966
3055
  # Update metrics (thread-safe)
2967
3056
  metrics.inc_runtime("execute_calls")
2968
3057
 
@@ -2986,10 +3075,21 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
2986
3075
  log_prefix="runtime.execute",
2987
3076
  )
2988
3077
 
3078
+ # 2026-09-11: same rationale as in
3079
+ # ``check_workflow_budget`` above — the entry's
3080
+ # ``execution_id`` slot should reflect the server-minted
3081
+ # id stamped on the approval row (captured via
3082
+ # ``_capture_server_minted_execution_id(result)`` at line
3083
+ # ~2968), not the workflow_id sentinel. The WS handler
3084
+ # matches on approval_id only, so this is diagnostic.
3085
+ from nullrun.context import get_server_minted_execution_id
3086
+
3087
+ _captured_eid = get_server_minted_execution_id()
2989
3088
  approval_result = self._wait_for_approval_resolution(
2990
3089
  approval_id=str(approval_id),
2991
3090
  workflow_id=workflow_id or UNKNOWN_WORKFLOW_ID,
2992
- execution_id=str(workflow_id or UNKNOWN_WORKFLOW_ID),
3091
+ execution_id=_captured_eid
3092
+ or str(workflow_id or UNKNOWN_WORKFLOW_ID),
2993
3093
  timeout_seconds=server_timeout,
2994
3094
  )
2995
3095
  outcome = str(approval_result.get("outcome") or "").lower()
@@ -33,6 +33,7 @@ from nullrun.breaker.exceptions import (
33
33
  NullRunAuthenticationError,
34
34
  NullRunBackendError,
35
35
  NullRunBlockedException,
36
+ NullRunConfigError,
36
37
  NullRunDecision,
37
38
  NullRunExecutionNotFoundError,
38
39
  NullRunInfrastructureError,
@@ -2088,6 +2089,10 @@ class Transport:
2088
2089
  def chain_end(
2089
2090
  self,
2090
2091
  chain_id: str,
2092
+ *,
2093
+ organization_id: str | None = None,
2094
+ trace_id: str | None = None,
2095
+ operation_id: str | None = None,
2091
2096
  ) -> dict[str, Any]:
2092
2097
  """Close a chain explicitly via /api/v1/gate with chain_op=end
2093
2098
  .
@@ -2105,20 +2110,78 @@ class Transport:
2105
2110
 
2106
2111
  Args:
2107
2112
  chain_id: Chain to close.
2113
+ organization_id: Organization identifier (REQUIRED on the
2114
+ wire — ``GateRequest`` deserialization fails with
2115
+ 422 ``missing field 'organization_id'`` without it,
2116
+ see ``backend/src/proxy/http/gate/internal.rs:156``).
2117
+ ``Runtime.chain_end`` always passes
2118
+ ``self.organization_id`` from ``_authenticate``.
2119
+ trace_id: Distributed trace ID. Auto-generated UUIDv4 if
2120
+ not provided.
2121
+ operation_id: Idempotency key — set explicitly when the
2122
+ caller wants the backend's per-``operation_id``
2123
+ dedup (matches ``runtime.check_workflow_budget``
2124
+ behaviour). Auto-generated UUIDv4 if not provided.
2108
2125
 
2109
2126
  Returns:
2110
2127
  Parsed JSON dict (typically ``{"decision": "allow"
2111
2128
  "chain_id":...}``).
2112
2129
  """
2113
- # 2026-07-04 (B3): POST /api/v1/gate with
2130
+ # DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix
2131
+ # POSTed only ``{chain_id, chain_op, execution_id}`` to /gate. The
2132
+ # backend's ``GateRequest`` struct
2133
+ # (backend/src/proxy/http/gate/internal.rs:156) marks
2134
+ # ``organization_id``, ``execution_id``, ``trace_id``, ``mode`` as
2135
+ # REQUIRED — the deserializer validates them BEFORE chain_op-specific
2136
+ # dispatch, so even the ``chain_op=end`` control-plane path
2137
+ # returns 422 ``missing field 'organization_id'`` on the old
2138
+ # body. Live wire trace against api.nullrun.io confirms the
2139
+ # 422. Fix: build the same full GateRequest body every other
2140
+ # /gate caller builds (``check`` at transport.py:1481, the
2141
+ # capture site at runtime.py:1953).
2142
+ if organization_id is None:
2143
+ raise NullRunConfigError(
2144
+ "Transport.chain_end requires organization_id — "
2145
+ "NullRunRuntime.chain_end always passes it from "
2146
+ "_authenticate; a direct Transport.chain_end call must "
2147
+ "pass organization_id explicitly. The backend's GateRequest "
2148
+ "struct (backend/src/proxy/http/gate/internal.rs:156) "
2149
+ "rejects requests without organization_id with 422 "
2150
+ "VALIDATION_ERROR."
2151
+ )
2152
+ # v0.16.1 (Phase-1+ wire-shape): the backend's
2153
+ # ``backend/src/proxy/http/gate/gate.rs:148`` version-gate
2154
+ # fail-CLOSED-rejects any proto>=3 /gate call that omits
2155
+ # ``action_digest``. ``chain_end`` is a control-plane op with
2156
+ # no business_impact, so we emit the same NoImpact sentinel
2157
+ # digest that ``runtime.check_workflow_budget`` produces at
2158
+ # runtime.py:1978.
2159
+ from nullrun.business_impact import (
2160
+ BusinessImpact as _BusinessImpact,
2161
+ )
2162
+ from nullrun.business_impact import (
2163
+ compute_action_digest as _compute_action_digest,
2164
+ )
2165
+
2114
2166
  request = {
2167
+ "organization_id": organization_id,
2168
+ # Fresh UUIDv4 per call (canonical hyphenated form so it
2169
+ # parses through the backend's ``Uuid::parse_str`` if it
2170
+ # ever reads it on this path).
2171
+ "execution_id": str(uuid.uuid4()),
2172
+ "trace_id": trace_id or str(uuid.uuid4()),
2173
+ "tool": None,
2174
+ "input": None,
2175
+ # ``mode="auto"`` — chain_end is a control-plane operation,
2176
+ # not a budget-consuming call. The orchestrator's
2177
+ # ``gate_reserve_v3`` Lua call receives ``chain_op=End``
2178
+ # and skips the budget reserve path, so the mode string
2179
+ # only needs to satisfy GateRequest's required-field check.
2180
+ "mode": "auto",
2181
+ "operation_id": operation_id or str(uuid.uuid4()),
2115
2182
  "chain_id": chain_id,
2116
2183
  "chain_op": "end",
2117
- # execution_id is required by the backend's gate handler
2118
- # even on chain_end — the handler reads it but does not
2119
- # mint a reservation for op=end. Use a fresh uuidv7
2120
- # call (the server ignores it on this path).
2121
- "execution_id": uuid.uuid4().hex,
2184
+ "action_digest": _compute_action_digest(_BusinessImpact.no_impact()),
2122
2185
  }
2123
2186
  # 2026-07-06 (bug-fix): same body-before-headers reorder as
2124
2187
  body = _signed_request_body(request)
@@ -2870,7 +2870,7 @@ wheels = [
2870
2870
 
2871
2871
  [[package]]
2872
2872
  name = "nullrun"
2873
- version = "0.16.7"
2873
+ version = "0.16.8"
2874
2874
  source = { editable = "." }
2875
2875
  dependencies = [
2876
2876
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes