Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,74 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

---

## [0.7.0] - 2026-06-26

### 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 (gate-decision fallback)
- Local loop/rate detectors: `LoopTracker`, `RateTracker`,
`LocalDecision` classes
- `NullRunRuntime._local_check()`, `_loop_tracker`, `_rate_tracker`
instance attrs
- `_local_loop_threshold`, `_local_rate_limit` instance attrs
(hardcoded 6/1000)
- `CachedDecision`, `PolicyCache` transport classes (tied to the
removed CACHED fallback mode)
- `NULLRUN_FALLBACK_MODE` env var
- `NULLRUN_POLICY_FAIL_OPEN` env var (no longer needed — backend is
authoritative)
- `NullRunRuntime._fetch_policy()` method (no local policy fetch on
init)
- WS `on_policy_invalidated` callback (no local policy to invalidate)

**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, so any attempt to
write them would either no-op or crash. **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:

```python
with nullrun.Transport(api_url=..., api_key=...) as t:
# use t; __exit__ calls stop() which calls _persist_to_wal
...
```

or call `t.stop()` explicitly before process exit. A `DEBUG` log
line "Transport finalizer fired without explicit stop(); remaining
events may be lost" is the user-visible signal that events were
dropped.

---

## [0.6.1] — 2026-06-24

Additive release — Layers 1, 2, and 3 of the "give the user a chance"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "nullrun"
version = "0.6.1"
version = "0.7.0"
description = "NullRun Python SDK — Enforcement gateway for AI agents."
readme = "README.md"
license = { text = "Apache-2.0" }
Expand Down
31 changes: 23 additions & 8 deletions src/nullrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,30 @@ def my_agent():
import nullrun.runtime as _rt_mod
from nullrun.runtime import NullRunRuntime

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

runtime = NullRunRuntime(
api_key=api_key,
api_url=api_url,
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""NullRun Platform SDK."""

__version__ = "0.6.1"
__version__ = "0.7.0"
__platform_version__ = "1.0.0"
50 changes: 22 additions & 28 deletions src/nullrun/observability/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,20 @@
* ``"misconfigured"`` — no api_key, or ``init()`` raised a
config error and the runtime was never bound. The SDK is
not operating; fix the config.
* ``"offline"`` — backend is not reachable AND no cached
policy exists. Every cost-bearing call will be rejected
by the strict-local fallback. Fix the network / backend.
* ``"degraded"`` — one or more of: WS disconnected, using
cached policy after a recent failure, circuit breaker
open, workflow state != Normal. The SDK is operating but
with reduced guarantees. Surface the ``fallback_reason``
or ``workflow_state.reason`` to the user.
* ``"offline"`` — backend is not reachable AND no successful
``/gate`` call has ever landed. Every cost-bearing call will
be rejected by the SDK's fail-CLOSED path. Fix the network /
backend.
* ``"degraded"`` — one or more of: WS disconnected, circuit
breaker open, workflow state != Normal. The SDK is operating
but with reduced guarantees. Surface the ``workflow_state.reason``
to the user.
* ``"ok"`` — everything healthy. This is the steady state.

Note (0.7.0): SDK no longer maintains a local ``Policy`` cache. All
enforcement decisions arrive from the backend via ``/gate`` and
``/execute``. The "cached policy" degradation state from prior
versions is gone — SDK is either talking to the backend or it isn't.
"""

from __future__ import annotations
Expand Down Expand Up @@ -108,10 +113,16 @@ class WorkflowState:
Mirrors the shape of the WS ``state_change`` message so the
user can read ``status.workflow_state.state`` and know
whether the body will run on the next call.

CP1 fix (2026-06-26): the backend WsWorkflowState enum has 5
variants, not 3 — Flagged and Tripped were previously silently
treated as Normal. The SDK now handles all 5 explicitly in
``runtime.check_control_plane``; this dataclass reflects the
full set so the operator-facing status mirrors reality.
"""

workflow_id: str
state: str # "Normal" | "Paused" | "Killed"
state: str # "Normal" | "Paused" | "Killed" | "Flagged" | "Tripped"
version: int
reason: str | None = None

Expand All @@ -136,13 +147,6 @@ class NullRunStatus:
workflow_id: str | None
api_url: str

# Policy
last_policy_fetch: datetime | None # UTC
last_policy_fetch_age_seconds: float | None
active_policy: Any # Policy | None — forward-declared
fallback_policy: Any # Policy | None — last known-good
fallback_reason: str | None # why fallback is in use

# Connectivity
backend_reachable: bool | None # None = never tested
ws_connected: bool | None # None = not started / unknown
Expand All @@ -169,8 +173,8 @@ def summary(self) -> str:

Example outputs:
"NullRunStatus(ok, api_key=nr_live_S, org=…, wf=…)"
"NullRunStatus(degraded, fallback=last_good@3min, reason=5xx)"
"NullRunStatus(offline, no cached policy, ws=False)"
"NullRunStatus(degraded, wf_state=Killed, backend=unreachable)"
"NullRunStatus(offline, ws=False, errors=2)"
"""
bits = [f"NullRunStatus({self.state}"]
if self.api_key_prefix:
Expand All @@ -179,16 +183,6 @@ def summary(self) -> str:
bits.append(f"org={self.organization_id[:8]}")
if self.workflow_id:
bits.append(f"wf={self.workflow_id[:8]}")
if self.fallback_policy and self.fallback_policy is not self.active_policy:
# Always show that we are on a fallback — the user
# sees the string "fallback" and knows to look at
# ``fallback_reason`` for the cause.
if self.last_policy_fetch_age_seconds is not None:
bits.append(f"fallback=last_good@{int(self.last_policy_fetch_age_seconds)}s")
else:
bits.append("fallback=last_good")
if self.fallback_reason:
bits.append(f"reason={self.fallback_reason[:40]}")
if self.workflow_state and self.workflow_state.state != "Normal":
bits.append(f"wf_state={self.workflow_state.state}")
if self.backend_reachable is False:
Expand Down
Loading
Loading