Skip to content

Commit b0dea8d

Browse files
authored
release: 0.7.0 — thin-client refactor (#34)
BREAKING CHANGES ---------------- SDK is now a thin client. All enforcement decisions arrive from the backend via /api/v1/gate and /api/v1/execute. Local policy enforcement, its dataclass, and its hardcoded thresholds are removed. Removed: * class Policy, Policy.default_local(), Policy.strict_local(), Policy.from_dict() (was at nullrun.runtime.Policy) * NullRunRuntime.policy property * NullRunRuntime(policy=...) constructor kwarg * NullRunStatus.active_policy, .fallback_policy, .fallback_reason, .last_policy_fetch, .last_policy_fetch_age_seconds fields * Transport.fetch_policy() method * Transport.clear_policy_cache() method * FallbackMode.CACHED enum value * Local loop/rate detectors: LoopTracker, RateTracker, LocalDecision classes * NullRunRuntime._local_check(), _loop_tracker, _rate_tracker instance attrs * _local_loop_threshold, _local_rate_limit (hardcoded 6/1000) * CachedDecision, PolicyCache transport classes * NULLRUN_FALLBACK_MODE env var * NULLRUN_POLICY_FAIL_OPEN env var (backend is authoritative) * NullRunRuntime._fetch_policy() method * WS on_policy_invalidated callback Migration: if you need to display policy values in a UI, fetch them directly via GET /api/v1/orgs/{org_id}/policies. The SDK no longer mirrors them. Audit: Drift D-01 from 2026-06-26 SDK↔backend audit (PolicyResponse lacked fields SDK expected; local defaults silently widened limits). Transport finalizer behavior change ----------------------------------- Transport._atexit_flush_safe is now a no-op that emits a single DEBUG log line. It does NOT persist buffered events to the WAL anymore — by the time weakref.finalize fires, self._buffer / self._lock / self._client are already gone. Crash-safety now lives exclusively in stop() and the context-manager pattern. Callers who relied on the implicit on-exit WAL flush MUST switch to: with nullrun.Transport(api_url=..., api_key=...) as t: ... or call t.stop() explicitly before process exit. Tests ----- * tests/test_signal_safety.py::TestAtexitViaWeakref rewritten to pin the new no-op-finalizer contract (was written against an intended but-unimplemented WAL-persist finalizer). * tests/test_deprecation_warnings.py removed (NULLRUN_FALLBACK_MODE env var is gone; deprecation warning is moot). * tests/test_no_local_policy.py added (pins absence of NullRunRuntime._local_check and the local Policy dataclass). * Various test updates reflecting runtime/transport refactors (-1883 / +1432 in tests/, mostly deletions of policy- and fallback-mode-specific cases). Other ----- * pyproject.toml: version bumped 0.6.1 -> 0.7.0 (was inconsistent with __version__.py before this commit). * CHANGELOG.md: full 0.7.0 entry covering BREAKING CHANGES, the transport-finalizer contract change, and migration guidance. Verification (local on Windows / Python 3.14.2): pytest 913 passed, 13 skipped (0:08:50) ruff check clean on src/ and tests/ mypy src/ clean on 26 source files
1 parent 54cc6fa commit b0dea8d

68 files changed

Lines changed: 1432 additions & 1883 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,74 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [0.7.0] - 2026-06-26
11+
12+
### BREAKING CHANGES
13+
14+
SDK is now a thin client. All enforcement decisions arrive from the
15+
backend via `/api/v1/gate` and `/api/v1/execute`. Local policy
16+
enforcement, its dataclass, and its hardcoded thresholds are removed.
17+
18+
**Removed:**
19+
20+
- `class Policy`, `Policy.default_local()`, `Policy.strict_local()`,
21+
`Policy.from_dict()` (was at `nullrun.runtime.Policy`)
22+
- `NullRunRuntime.policy` property
23+
- `NullRunRuntime(policy=...)` constructor kwarg
24+
- `NullRunStatus.active_policy`, `.fallback_policy`,
25+
`.fallback_reason`, `.last_policy_fetch`,
26+
`.last_policy_fetch_age_seconds` fields
27+
- `Transport.fetch_policy()` method
28+
- `Transport.clear_policy_cache()` method
29+
- `FallbackMode.CACHED` enum value (gate-decision fallback)
30+
- Local loop/rate detectors: `LoopTracker`, `RateTracker`,
31+
`LocalDecision` classes
32+
- `NullRunRuntime._local_check()`, `_loop_tracker`, `_rate_tracker`
33+
instance attrs
34+
- `_local_loop_threshold`, `_local_rate_limit` instance attrs
35+
(hardcoded 6/1000)
36+
- `CachedDecision`, `PolicyCache` transport classes (tied to the
37+
removed CACHED fallback mode)
38+
- `NULLRUN_FALLBACK_MODE` env var
39+
- `NULLRUN_POLICY_FAIL_OPEN` env var (no longer needed — backend is
40+
authoritative)
41+
- `NullRunRuntime._fetch_policy()` method (no local policy fetch on
42+
init)
43+
- WS `on_policy_invalidated` callback (no local policy to invalidate)
44+
45+
**Migration:**
46+
47+
If you need to display policy values in a UI, fetch them directly
48+
via `GET /api/v1/orgs/{org_id}/policies`. The SDK no longer mirrors
49+
them.
50+
51+
**Audit:** Drift D-01 from 2026-06-26 SDK↔backend audit
52+
(`PolicyResponse` lacked fields SDK expected; local defaults silently
53+
widened limits).
54+
55+
### Transport finalizer behavior change
56+
57+
`Transport._atexit_flush_safe` is now a no-op that emits a single
58+
`DEBUG` log line. It does NOT persist buffered events to the WAL
59+
anymore — by the time `weakref.finalize` fires, `self._buffer` /
60+
`self._lock` / `self._client` are already gone, so any attempt to
61+
write them would either no-op or crash. **Crash-safety now lives
62+
exclusively in `stop()` and the context-manager pattern.** Callers
63+
who relied on the implicit on-exit WAL flush must switch to:
64+
65+
```python
66+
with nullrun.Transport(api_url=..., api_key=...) as t:
67+
# use t; __exit__ calls stop() which calls _persist_to_wal
68+
...
69+
```
70+
71+
or call `t.stop()` explicitly before process exit. A `DEBUG` log
72+
line "Transport finalizer fired without explicit stop(); remaining
73+
events may be lost" is the user-visible signal that events were
74+
dropped.
75+
76+
---
77+
1078
## [0.6.1] — 2026-06-24
1179

1280
Additive release — Layers 1, 2, and 3 of the "give the user a chance"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nullrun"
7-
version = "0.6.1"
7+
version = "0.7.0"
88
description = "NullRun Python SDK — Enforcement gateway for AI agents."
99
readme = "README.md"
1010
license = { text = "Apache-2.0" }

src/nullrun/__init__.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -247,15 +247,30 @@ def my_agent():
247247
import nullrun.runtime as _rt_mod
248248
from nullrun.runtime import NullRunRuntime
249249

250-
# Phase 0.3.1: the three singleton slots (NullRunRuntime._instance,
251-
# _rt_mod._runtime, _dec_mod._runtime) must all be assigned
252-
# atomically. Without a lock, concurrent init() calls from
253-
# multiple threads can leave the three slots pointing at two
254-
# different runtimes. The failure mode is silent — the
255-
# decorator's @protect wrapper reads _dec._runtime once and
256-
# never re-resolves, so a missed assignment drops every
257-
# span_start/span_end event for that runtime.
250+
# C3 fix: shut down any existing runtime before constructing a new
251+
# one. Without this, calling init() twice (or init() after a
252+
# previous init() without an explicit shutdown()) leaves the prior
253+
# daemon threads — transport flush, WS control plane, coverage
254+
# reporter — running against the orphaned runtime. They keep
255+
# burning CPU, hold sockets open, and can write to stale module
256+
# slots that no longer reflect the active singleton.
257+
#
258+
# shutdown() is best-effort: if the previous runtime is mid-shutdown
259+
# or in an unrecoverable state, we log and proceed so the new
260+
# runtime can still come up.
258261
with _init_lock:
262+
existing = NullRunRuntime._instance
263+
if existing is not None:
264+
logger.warning(
265+
"nullrun.init() called while a previous runtime is "
266+
"still alive; shutting down the old one to avoid "
267+
"orphan threads (C3 fix)."
268+
)
269+
try:
270+
existing.shutdown()
271+
except Exception as e: # noqa: BLE001 — best-effort
272+
logger.warning("previous runtime shutdown raised during init(): %s", e)
273+
259274
runtime = NullRunRuntime(
260275
api_key=api_key,
261276
api_url=api_url,

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""NullRun Platform SDK."""
22

3-
__version__ = "0.6.1"
3+
__version__ = "0.7.0"
44
__platform_version__ = "1.0.0"

src/nullrun/observability/status.py

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,20 @@
3232
* ``"misconfigured"`` — no api_key, or ``init()`` raised a
3333
config error and the runtime was never bound. The SDK is
3434
not operating; fix the config.
35-
* ``"offline"`` — backend is not reachable AND no cached
36-
policy exists. Every cost-bearing call will be rejected
37-
by the strict-local fallback. Fix the network / backend.
38-
* ``"degraded"`` — one or more of: WS disconnected, using
39-
cached policy after a recent failure, circuit breaker
40-
open, workflow state != Normal. The SDK is operating but
41-
with reduced guarantees. Surface the ``fallback_reason``
42-
or ``workflow_state.reason`` to the user.
35+
* ``"offline"`` — backend is not reachable AND no successful
36+
``/gate`` call has ever landed. Every cost-bearing call will
37+
be rejected by the SDK's fail-CLOSED path. Fix the network /
38+
backend.
39+
* ``"degraded"`` — one or more of: WS disconnected, circuit
40+
breaker open, workflow state != Normal. The SDK is operating
41+
but with reduced guarantees. Surface the ``workflow_state.reason``
42+
to the user.
4343
* ``"ok"`` — everything healthy. This is the steady state.
44+
45+
Note (0.7.0): SDK no longer maintains a local ``Policy`` cache. All
46+
enforcement decisions arrive from the backend via ``/gate`` and
47+
``/execute``. The "cached policy" degradation state from prior
48+
versions is gone — SDK is either talking to the backend or it isn't.
4449
"""
4550

4651
from __future__ import annotations
@@ -108,10 +113,16 @@ class WorkflowState:
108113
Mirrors the shape of the WS ``state_change`` message so the
109114
user can read ``status.workflow_state.state`` and know
110115
whether the body will run on the next call.
116+
117+
CP1 fix (2026-06-26): the backend WsWorkflowState enum has 5
118+
variants, not 3 — Flagged and Tripped were previously silently
119+
treated as Normal. The SDK now handles all 5 explicitly in
120+
``runtime.check_control_plane``; this dataclass reflects the
121+
full set so the operator-facing status mirrors reality.
111122
"""
112123

113124
workflow_id: str
114-
state: str # "Normal" | "Paused" | "Killed"
125+
state: str # "Normal" | "Paused" | "Killed" | "Flagged" | "Tripped"
115126
version: int
116127
reason: str | None = None
117128

@@ -136,13 +147,6 @@ class NullRunStatus:
136147
workflow_id: str | None
137148
api_url: str
138149

139-
# Policy
140-
last_policy_fetch: datetime | None # UTC
141-
last_policy_fetch_age_seconds: float | None
142-
active_policy: Any # Policy | None — forward-declared
143-
fallback_policy: Any # Policy | None — last known-good
144-
fallback_reason: str | None # why fallback is in use
145-
146150
# Connectivity
147151
backend_reachable: bool | None # None = never tested
148152
ws_connected: bool | None # None = not started / unknown
@@ -169,8 +173,8 @@ def summary(self) -> str:
169173
170174
Example outputs:
171175
"NullRunStatus(ok, api_key=nr_live_S, org=…, wf=…)"
172-
"NullRunStatus(degraded, fallback=last_good@3min, reason=5xx)"
173-
"NullRunStatus(offline, no cached policy, ws=False)"
176+
"NullRunStatus(degraded, wf_state=Killed, backend=unreachable)"
177+
"NullRunStatus(offline, ws=False, errors=2)"
174178
"""
175179
bits = [f"NullRunStatus({self.state}"]
176180
if self.api_key_prefix:
@@ -179,16 +183,6 @@ def summary(self) -> str:
179183
bits.append(f"org={self.organization_id[:8]}")
180184
if self.workflow_id:
181185
bits.append(f"wf={self.workflow_id[:8]}")
182-
if self.fallback_policy and self.fallback_policy is not self.active_policy:
183-
# Always show that we are on a fallback — the user
184-
# sees the string "fallback" and knows to look at
185-
# ``fallback_reason`` for the cause.
186-
if self.last_policy_fetch_age_seconds is not None:
187-
bits.append(f"fallback=last_good@{int(self.last_policy_fetch_age_seconds)}s")
188-
else:
189-
bits.append("fallback=last_good")
190-
if self.fallback_reason:
191-
bits.append(f"reason={self.fallback_reason[:40]}")
192186
if self.workflow_state and self.workflow_state.state != "Normal":
193187
bits.append(f"wf_state={self.workflow_state.state}")
194188
if self.backend_reachable is False:

0 commit comments

Comments
 (0)