Minor release. Two enforcement paths that could not complete their own
contract are closed. The MCP path could not finish an approval round
trip at all: a retry after the operator approved landed in
**passthrough and was forwarded to the MCP server, so the gate never
saw the grant. The zero-code LLM path booked the call only AFTER the
provider had already answered — the llm_call event is the only thing
that moves the budget counter, so a call the ceiling was supposed to
stop was counted instead of stopped, and the control centre showed the
sum of spend that had already happened. Both fixes are SDK-side; there
is no wire-protocol change. The public surface grows by ten
keyword-only parameters across four public methods, and one changed
return value.
Nothing breaks on a healthy configuration, and the one behavioural change is the point of the release rather than a side effect. Five things differ from 0.22.2:
MCPAdapter.call_toolcan carry an approval grant. The method gains three NAMED keyword-only parameters —approval_id,business_impact,action_digest— placed BEFORE**passthroughso they can never fall into the passthrough and be forwarded to the MCP server. A caller that wroteadapter.call_tool("x", {}, approval_id="...")was sending the approval id to the MCP server as a tool argument; now it goes to the gate.NullRunRuntime.executegainsapproval_idandtool_arguments. Both are appended AFTERtools, so the existing positional order of the parameters above is unchanged. Each key is added to the wire only when the value is notNone— an absent field means "no grant", which is not the same wire statement as a null.tool_argumentsis the canonical argument bag the backend already resolves (ADR-060 §4); the SDK now sends it alongside the legacyinputfield instead of only the latter.check_workflow_budgetreturns the reservation id. The method gainsmodel,estimated_tokens,max_output_tokensanduse_gate_cache, and its return type changes fromNonetostr | None— the server-minted reservation the backend settles when the matching/trackarrives, orNonewhen no reservation was minted. Callers that ignored the return value are unaffected. The@protectpre-flight still sendsestimated_tokens=1, because that path answers "is ANY budget left?" and not "what will THIS call cost?".NullRunRuntime.trackgainsreservation_id. The reservation travels with the cost event explicitly rather than through a contextvar: the contextvar is one slot per task, so two concurrent calls would swap reservations.- The zero-code LLM path now reserves BEFORE the provider request. A call that the ceiling rejects never reaches the provider — the request is not sent at all. Previously the SDK could only report the spend after the fact. This is visible: a capped zero-code caller will now see refusals where it previously saw successful calls that were billed anyway.
- NR-058 (SDK half) — the MCP path could not complete an approval
round trip.
MCPAdapter.call_toolhad noapproval_idparameter, so a retry after the operator approved landed in**passthroughand was forwarded to the MCP SERVER — the gate never saw the grant. The adapter also sent neitherbusiness_impactnoraction_digest, so the approval row was never digest-bound and the backend could not detect a tampered payload at consume time. The three parameters are now named keyword-only members of the public signature and are forwarded intoruntime.execute(...), which gained the matchingapproval_idparameter —Transport.executealready accepted it and put it on the wire; the runtime was the only missing link. Separately,/api/v1/executeruns an existence check on the Redis bindingexecution:{id}and answers 404 EXECUTION_NOT_FOUND when no prior/gateminted it, so a DIRECTadapter.call_tool(...)never reached the MCP server at all. The adapter now runs the pre-flight itself whenget_server_minted_execution_id()isNoneand SKIPS it otherwise — that path reserves money at the gate, and a second/gatewould charge the org twice for one logical call. The condition is the same contextvarruntime.executereads, so the two agree by construction. A refusal from the pre-flight propagates and the MCP client is NOT called. (5021ed3, +558/-4; test filetests/test_mcp_approval_wire.py, 15 tests) - NR-015 (SDK half) — the canonical argument bag never reached the
wire. The tool arguments travelled only as the legacy
inputfield, while the backend resolves the canonical bag astool_arguments→tool_params→ Null (ADR-060 §4) — so every params-based rule saw "no bag", and the approval decision had no argument snapshot to bind to. The same dict now reaches the wire as BOTHinput(the tool payload, unchanged) andtool_arguments(the canonical bag). No new wire field is invented:tool_argumentsis the field the backend already resolves. (5021ed3) - NR-059 — the zero-code LLM path booked the call after the provider
had already been paid. On the zero-code path (httpx / requests
transport, no
@protect, noset_call_context) the SDK sent thellm_callevent ONLY AFTER the provider's response. The event is the only thing that moves the budget counter, so a call the cap should have stopped was counted, not stopped. Now the httpx (sync and async) and requests transports run the pre-flight BEFORE the request leaves: they read the model, the prompt size and the output cap out of the request body and reserve them, and a gate refusal is raised outward so the provider request is never sent. The reservation travels with the event explicitly (track(..., reservation_id=...)), because the contextvar carrier is one slot per task and two parallel calls would swap reservations. A call that produced no usage (network error, empty body, no usage block) releases its reservation with a zero-valued/track— otherwise the reservation would hold budget until the reclaim worker ran. When the scope already holds a fresh reservation (the@protectpath reserves once for the whole scope) the pre-flight is SKIPPED: a second reservation would orphan the first. Token estimation is ~3 characters per token, 1 token per CJK character, base64 is not counted as text, and the estimate is capped at 32768 tokens. The output cap is declared ONLY if the caller declared one — without it there is no upper bound, and pricing at the catalog maximum would freeze a monthly budget on a single call. The README now states, per path, what the ceiling stops and what only the NEXT call can stop. (4bc9322, +1210/-35; test filetests/test_zero_code_preflight.py, 20 tests)
- Lint hygiene in two test files —
ruff check src testswas red onorigin/master:tests/test_websocket_extra_is_declared.pyimportedsystwice (F811 + I001), a duplicate left behind when the 3.10tomlifallback was added in 0.22.2, and the newtests/test_zero_code_preflight.pyhad one blank line too many after its import block. CI only gatesruff check src/, so neither was caught there; the local gate named in the release runbook isruff check src tests. Both edits are import-ordering only — no behaviour changed.
| Check | Result |
|---|---|
ruff check src tests |
All checks passed |
mypy src/nullrun |
Success: no issues found in 39 source files |
pytest -q |
1948 passed, 12 skipped in 192.05s (vs baseline 1913 / 12 — +35 new tests from test_mcp_approval_wire (15) and test_zero_code_preflight (20)) |
| Scratch diff | clean (dist_local/, *.defect* absent) |
nullrun.__version__ |
0.23.0 |
| Wire-format compatibility | unchanged from 0.22.2 — no protocol change; approval_id and tool_arguments are the fields the backend already resolves, and each is sent only when set (absent ≠ null) |
5021ed3 fix(sdk): MCP approval re-entry — approval_id on the gate, digest binding, canonical argument bag, missing /gate pre-flight (NR-058 / NR-015, SDK half)
4bc9322 fix(budget): LLM-путь резервирует стоимость звонка ДО запроса к провайдеру (NR-059)
Patch release, no protocol change. Three unrelated bugs closed in one
shipping window: a status field that structurally could not report
the failure it existed to report (backend_reachable initialised to
None and never written), a packaging extra that the SDK's own
ImportError told users to install but that pyproject.toml never
declared, and a pre-flight that priced at the input rate and so
silently under-reserved every real LLM call. None is a wire
change — all three are SDK-side. The status and packaging fixes are
behaviourally invisible to a healthy configuration; the reserve fix
is visible to anyone whose max_output_tokens is currently being
ignored.
Three things differ from 0.22.1. None is silent on a healthy configuration, but the third is a new opt-in signal that the wire contract was always supposed to carry:
NullRunStatusreflects a backend that has actually been attempted. A backend unreachable sinceinit()used to readbackend_reachable=Noneand so the DEGRADED headline stayedSTATE_OK— a monitoring surface that structurally could not report the failure it exists to report. The writer is now called from every site that learns whether the backend answered (both/gatepaths incheck_workflow_budget, both/auth/verifyoutcomes); "answered" means transport-level, not HTTP-2xx — a 402/403/429 is the backend replying and stays reachable, only DNS / refused / TLS / timeout sets it False.pip install nullrun[websocket]actually installswebsockets. The error message named an extrapyproject.tomlnever declared (only the opentelemetry pair anddevwere listed). The failure mode was worse than a missing feature: a user told precisely how to install it typed it, and got a second error.websocketswas inuv.lockonly as a transitive oflangsmith, so the WS wire test's coverage depended on a dependency edge nobody here controls. Now declared with floor14.0(theadditional_headersspelling replacedextra_headersin the asyncio client in 14.0; an older install wouldTypeErrorat connect time, later and in production traffic, instead of at theImportErrorthe user was told to fix).set_call_context(max_output_tokens=…)reaches/gate. The pre-flight has always sentestimated_tokens=1, which prices at the INPUT rate and covers no output at all. Output bills at the completion rate (5× input on the live catalog), so a real 20k-token Opus response costs 50¢ against a 1¢ reserve:/trackthen rejects the row withCONSUME_OVERBUDGETand the period counter under-counts the spend. Now the caller's declared cap is sent when set, the key is ABSENT (not present-and-null) when not, and a cap of zero or less is treated as "not declared" rather than silently under-reserving. The via-edge box'stoken_split()falls back to the declared cap before the one-token estimate, never the other way.
-
DEF-STATUS-WRITER-01 —
NullRunStatus.backend_reachablewas initialised toNonein__init__and read instatus()— and never assigned anywhere in the SDK. The comment above the initialisation claimed the pair was "set in_authenticateand updated on every successful / failed backend call thereafter"; no such writer had ever existed. Two silent consequences: the DEGRADED headline was gated onis Falseso it never fired (pinned toNone), and the MISCONFIGURED arm was gated on_last_backend_attempt_at is not Noneso it was dead for the same reason. Fix: one writer_note_backend_attempt(ok)called from the four sites that actually learn whether the backend answered. "Ok" means "the backend answered", NOT "the call succeeded": a 402/403/429 and a 401 are the backend replying, and only a transport-level failure sets it False. Collapsing those would brand a healthy server that correctly refused a call as unreachable and drive DEGRADED off a credential problem. The pair is read under a new lock because gate calls arrive on the tool thread while_authenticateruns on the caller's thread. (b076643, +100/-8; test filestest_status_backend_reachable_has_a_writer.py,test_status_ws_connected_reads_real_attribute.py) -
DEF-SDK-16 (packaging: declare the
websocketextra) —WebSocketConnection.connectraiseswebsockets library not available. Install with: pip install nullrun[websocket], butnullrunhad nowebsocketextra.[project.optional-dependencies]declared exactly two — the opentelemetry pair anddev— andwebsocketsappeared nowhere inpyproject.toml. Two declarations, not one:websocket = ["websockets>=14.0,<17.0"]anddevalso gainswebsockets(sotests/test_ws_push.pyresolves through a declared edge, not throughlangchain-core -> langsmith -> websocketswhich nobody here controls). A new source pin asserts the general property: everynullrun[<extra>]install hint appearing anywhere undersrc/must name an extrapyproject.tomlactually declares. (a8cb823, +24/-1;tests/test_websocket_extra_is_declared.py)
- DEF-MOT-01 (reserve: forward caller-declared
max_output_tokensto/gate) — per ADR-016 §2.1 the backend prices an output reserve from TWO independent signals (the caller's declared cap and its own per-model catalog cap) and refuses to reserve from either one alone. The SDK's half of that contract is now narrow and specific: when the caller set a cap it is sent, when the caller did not the key is ABSENT (not present-and-null — a null is a value the backend has to defend against, not an honest "I don't know"), and a cap of zero or less is treated as "not declared" rather than silently under-reserving. New ContextVar_call_max_output_tokens_var, readerget_call_max_output_tokens(), and amax_output_tokens=parameter onset_call_context(...). The via-edge box'stoken_split()falls back to the declared cap before the one-token estimate; every step down errs toward charging MORE, never less. (f095c46, +251/-9; test filetests/test_max_output_tokens_reserve.py)
| Check | Result |
|---|---|
ruff check src tests |
All checks passed |
mypy src/nullrun |
Success: no issues found in 39 source files |
pytest -q |
1913 passed, 12 skipped in 156.34s (vs baseline 1849 / 12 — +64 new tests from test_status_backend_reachable_has_a_writer, test_status_ws_connected_reads_real_attribute, test_websocket_extra_is_declared, test_max_output_tokens_reserve) |
| Scratch diff | clean (dist_local/, *.defect* absent) |
nullrun.__version__ |
0.22.2 |
| Wire-format compatibility | unchanged from 0.22.1 (no protocol change; the max_output_tokens key is new-but-OPTIONAL, the backend ignores its absence as before) |
Patch release, no protocol change. The SDK's public exception surface
is restored after QA pass u3h (2026-10-15) reported 24 classes the
SDK RAISES were not importable from the top-level nullrun
namespace. The classes existed, _V3_ERROR_CODE_MAP constructed
them, and enforcement was never affected — only the documented
public name was missing. The defect survived because the documented
catch (except NullRunError:) kept matching the instance; a narrower
except NullRunApprovalDeniedError: only fails when someone goes
looking for the name.
The second behavioural fix closes a discarded-coroutine class of bug
on the WS dispatch path: on_policy_invalidated was wired as
async def while the dispatcher calls it without await, so the
callback was created and silently discarded (the dispatcher's
try/except cannot see an un-awaited coroutine either). The
logger.info and the user-supplied callback never fired — only a
stderr RuntimeWarning that nobody reads. Same bug class as the
approval-resolved fix from 2026-07-24, missed on the sibling
callback.
The third change aligns stale docstrings with current behaviour:
the kill-vs-on_error-hook asymmetry between sync and async @protect
was documented as a BaseException bypass that never existed (the
class is WorkflowKilledInterrupt -> NullRunError -> BreakerError -> Exception — verified), and chasing the truth exposed an actual
behavioural split governed by unify_block, not by base-class
membership. The new tests/test_guard_kill_semantics.py pins the
real behaviour so the comments describing it cannot drift the way the
old ones did.
None on a healthy configuration. Three things differ from 0.22.0:
- 24 more exception names are reachable via
from nullrun import X(DEF-PUBEX-01). Code that imports a previously-missing name will now succeed. The names are also added to__all__so tab-completion andfrom nullrun import *reach them. See theFixedsection below for the full list and rationale by tier (infrastructure / transport / decision / approval). on_policy_invalidatedfires on every WS message (DEF-WS-SYNC-01). Pre-0.22.1 the wrapper was an un-awaited coroutine; thelogger.infoand the user-supplied callback never ran. Code that subscribed to policy-cache invalidations expecting a callback will now actually get one.- No wire or backend change. This is purely SDK-side restoration and comment alignment.
- DEF-PUBEX-01 (public exception surface) — 24 classes the SDK
RAISES were missing from
nullrun.__init__._LAZY_EXPORTSand__all__.from nullrun import NullRunApprovalDeniedErrorraisedImportErrorwhileexcept NullRunError:in the same file kept matching the instance, so the defect survived every existing test. Now registered in_LAZY_EXPORTS, grouped by tier (root / markers, infrastructure, transport, decision, approval), and reachable viafrom nullrun import X.BreakerErrorandTransportErrorSourceare deliberately absent from__all__: the first is the pre-Layer-1 root alias kept for old cookbook code (already offered by tab-completion viaNullRunError), the second is an enum a caller passes toRateLimitError, not something to catch.NullRunUnclassifiedRefusalErrorlives innullrun.breaker.categories, not.exceptions, and is the reason a caller must NOT assume every block carries a policy verdict — ADR-062 §2.2 requires it to RAISE when a refusal carries no category the SDK trusts. The full list: BreakerError, NullRunDecision, NullRunInfrastructureError, NullRunTransportError, BreakerTransportError, InsecureTransportError, NullRunProtocolError, NullRunMalformedGateResponseError, NullRunRateLimitRedisError, NullRunBudgetRecheckFailedError, NullRunExecutionNotFoundError, RateLimitError, TransportErrorSource, NullRunUnclassifiedRefusalError, NullRunDeniedError, NullRunChainError, NullRunConsumeOverbudgetError, NullRunWorkflowInactiveError, NullRunApprovalNotYetApprovedError, NullRunApprovalResponseMissingError, NullRunApprovalDeniedError, NullRunApprovalExpiredError, NullRunApprovalReplayRejectedError, NullRunApprovalDigestMismatchError, NullRunApprovalToolDigestMismatchError. - DEF-WS-SYNC-01 (sync
on_policy_invalidatedwrapper) —Transport.connect_websocketwiredon_policy_invalidatedasasync defwhile the WS dispatch path calls it withoutawait. Constructing an un-awaited coroutine raises nothing, so the dispatcher'stry/exceptcould not catch it either. The body never ran; thelogger.infoand the user-suppliedon_policy_invalidatednever fired. Same shape as the approval fix from 2026-07-24, missed on the sibling callback. Now a plaindef.wrapped_key_rotatedstaysasync defbecause it awaitsself._refetch_credentials()and its dispatcher DOES await it.
- Dropped
C:UsersAnatolii(216 bytes — contents are a one-linefatal: ambiguous argumentmessage from a previous shell that mis-decoded an absolute Windows path and wrote the error output into the repo root)..gitignoreextended with"/C:UsersAnatolii*"so the shape cannot recur; the broader class ("Windows path decoded without its separator") produces single-byte replacement-char filenames that don't pattern-match cleanly, so the entry is narrow on purpose.
- DEF-DRIFT-01 (kill-vs-on_error-hook asymmetry) — the
@protect/on_error/check_workflow_budget/messages.NR-B007docstring blocks were aligned with current behaviour. TheBaseExceptionexplanation for the hook bypass was wrong (WorkflowKilledInterrupt -> NullRunError -> BreakerError -> Exception, verified); the real split is theunify_blockflag in_protect_body. Sync@protectre-wraps asNullRunBlockedException(error codeNR-W002), the hook fires; async@protectlets the rawWorkflowKilledInterruptpropagate, the hook does NOT fire.decision == "throttle"does not exist — the only producer is an uncalled phase-2 schema stub. Fail-OPEN is narrower than "any transport error": a 5xx WITH a refusal body is HONOURED (decision_source=GATEWAY, blocks); only unreachable / synthetic-source paths fail open.NR-B007was removed 2026-09-10 (it only ever fired on the never-sentthrottledecision);WorkflowPausedExceptionis NOT zombie and must not be lumped in withNR-B007— it is still raised for a genuinely paused workflow.
| Check | Result |
|---|---|
ruff check src tests |
All checks passed |
mypy src/nullrun |
Success: no issues found in 39 source files |
pytest -q |
1849 passed, 12 skipped in 124.76s (vs baseline 1828 / 12 — +21 new tests from test_public_exception_surface, test_policy_invalidated_ws_sync_callback, test_guard_kill_semantics) |
| Scratch diff | clean (dist_local/, *.defect* absent) |
nullrun.__version__ |
0.22.1 |
| Wire-format compatibility | unchanged from 0.22.0 |
The SDK's enforcement question can now go to a local box that holds a
signed lease instead of to api.nullrun.io — opt-in via
NULLRUN_EDGE_URL, refuses to fall back to the cloud. The write-ahead
log was reworked around three loss paths the previous release did not
cover: a kill -9 while a refused event was held, a 200 batch that
hid per-item refusals, and a recovery that unlinked the WAL before
the events were provably safe. ADR-068 closes the wire: the SDK no
longer puts a price on the wire (the backend prices the cached
fraction off provider, and the SDK reports tokens because only it
knows them).
Minor, not patch: the /track wire loses a field (cost_cents, always
zero, accept-and-dropped on the backend — see Migration #2), and
/track batch refuses now classify the 422 by whether the backend
already booked the spend before deciding whether to quarantine. The
classification change is behaviourally visible to code that watched
the DLQ for CONSUME_OVERBUDGET rows — they now appear as
NullRunConsumeOverbudgetError.recorded=True deliveries, and the
batch's other events are no longer poisoned by an overage that was
charged and recorded.
Five things differ from 0.21.0. None is silent on a healthy configuration, but the first three can change retry / DLQ outcomes without a code change on the caller's side.
- A
CONSUME_OVERBUDGET422 withdetails.reservation_recorded=Trueis delivered, not quarantined. The backend had alreadyINCRBY'd the spend onto the period counter and written thecost_eventsrow; the 422 is about the reservation ceiling, not delivery. The singleton for that event is removed from the retry path, the rest of the batch is re-queued, andNullRunConsumeOverbudgetErrornow carries arecordedtri-state (True/False/Nonefor unparseable bodies) so a caller can tell the two apart. Code that watched the DLQ for these events to surface them later will no longer see them there; the newevents_recorded_overagecounter is the metric, the exception is the programmatic signal. /trackno longer sendscost_cents. The field was always zero, the backend has#[serde(default)]on it, and an older SDK that still sends it is accepted and ignored. Pure simplification, no protocol bump, no synchronous release.local_cost_centsin the return value is unchanged (always zero, never on the wire).- 4xx classification happens before quarantine, with a different
answer per class.
408/429are transient: re-queued, never dead-lettered at any attempt count.401/403stay in the WAL with a "blocked on the key" log line — the data is valid, the key is rotated or revoked, and an operator action is the recovery.413is the batch, not the events: the batch is halved and resent.400/422are bisected to a single event, with exactly one event lost rather than the whole batch. Code that relied on "any 4xx is a permanent failure at attempt N" is wrong for the first three classes now and would be wrong even on the fourth (which has always been bisected, not dropped wholesale). - A held refusal survives
kill -9. The DLQ-overflow holdover is now in<wal>.holdover— deliberately not.wal(rewritten wholesale by every buffer persist) and not.inflight(overwritten by the next send). The newdlq_holdover_*metrics count the events the in-memory index is holding;dlq_holdover_persist_failuresis the one counter that means possible loss. Behavioural: a recovery loads the holdover as a hold, not a replay — re-sending on restart would reintroduce the head-of-buffer starvation the hold was built to stop, on every restart, when the operator is most likely watching. - The
/trackbatch path forwards theproviderlabel. Cached pricing differs by provider (Anthropic reports the cached fraction OUTSIDEinput_tokens; OpenAI / Gemini / Mistral report it INSIDEprompt_tokens). The single-event v3 path whitelisted its keys and droppedprovider; the batch path was already forwarding the whole enriched event. The two paths now agree. Absent or emptyproviderstays off the wire — the backend still distinguishes "we could not determine the provider" (counted, fail-CLOSED) from one it named.
-
DEF-40 (client-side pricing) — ADR-068 §4f. The SDK was not computing a price (
_build_v3_track_payloadhardcodedcost_cents: 0), but sending the field at all kept the shape of a client-priced request alive on every track call. The wire- contract test now asserts ABSENCE ofcost_centson both a minimal and a fully populated payload — an exact-dict match is what turns "the SDK never offers a price" into a checked property rather than a comment. -
SDK via-edge (E1–E4, E9 of ADR-067) — the local box as the gate, with three things refused up front: a silent fallback to the cloud (a fail-OPEN wearing a disguise — the operator reads "the box said yes" while the cloud answered and the box was never asked), a caller that writes the money (the box prices from the rates the cloud signed into the grant, the SDK reports tokens because only it knows them), and a box that picks its own lease (
NULLRUN_EDGE_LEASE_IDis required, not discovered — a box that chose its own budget enforces a number nobody granted)./enforcealready decides AND charges, so/executehas no box equivalent and makes no call; re-asking would either reach the cloud or double-charge the same event. The runtime fix is the load-bearing part:_require_gate_decisionrejects any body whosedecision_sourceit does not know, andedge_leasewas not in the set — every via-edge decision would have raisedNullRunMalformedGateResponseErrorand failed CLOSED on its own successes, which in an outage reads as "the box is broken" rather than "the SDK has not heard of this source yet". The name is added by import, so the allowlist and the producer cannot drift. -
WAL durability (six loss paths closed) — a verification pass over the WAL / DLQ layer found six defects. Five are the same shape: a refusal that the operator can see in the DLQ but that the code itself no longer accounts for. The sixth is a retry loop that did not retry slowly.
- The DLQ-overflow holdover was memory-only. A process that held
a refusal and then flushed again had silently overwritten the
only on-disk trace of it. Now in
<wal>.holdover, drained byevent_idrather than file deletion (the in-memory index is bounded and can be shorter than the file). - A 429 was retried with no delay at all — the
last_retry_after_secondsparameter was documented, threaded through the signature, and passed by no caller, so the existing 429 test asserted the call COUNT, which does not depend on how long you wait between calls. Jitter on the retry-after path is one-sided[wait, wait*(1+jitter)]— a rate limit states a floor, and spreading downward is how a 429 becomes a sustained 429. - A full DLQ let one bad event stop the whole stream. A refused
batch whose DLQ write failed was re-queued at the head of the
buffer, ahead of every healthy event behind it, and re-sent
every cycle forever — a terminal refusal that cannot succeed on
retry. Held in memory (
dlq_holdover, alertable because it counts events unrecorded RIGHT NOW, wheredlq_overflow_totalcounts refusals over time) and drained as soon as there is room. /track/batchrefuses individual events and still answers 200. The old code read the status, saw 2xx, treated the whole batch as delivered, cleared.inflightand dropped the refused events — out of the buffer and out of the DLQ, with no trace anywhere. "Data is not lost" held only for transport failures, which is a much weaker claim than it reads as. The write order is the load-bearing part: every event must be durable — in the DLQ, or still covered by a retained.inflight— BEFORE.inflightis cleared._replay_from_walunlinked each recovery file BEFORE calling_do_flush. A crash between the unlink and a successful flush lost the batch outright. Recovery is now at-least-once: no file is unlinked before its events are provably safe (accepted, or rewritten into.wal).- The circuit breaker counted 4xx rejections as transport
failures. With
max_failed_flush=10, ten consecutive rejected batches opened the circuit on a backend that was up and answering — blocking every buffered event for the whole recovery window, for a problem the breaker cannot fix. A 4xx is the server answering, and a local serializationTypeErroris our own bug; neither says anything about backend health. Both are now trapped inside the call and handled after it.
- The DLQ-overflow holdover was memory-only. A process that held
a refusal and then flushed again had silently overwritten the
only on-disk trace of it. Now in
-
WAL guarantees are explicit, private, and observable (68dc375). Files are now created
0600rather than throughopen(), which applies the process umask to whatever0666it asks for — an event payload is a prompt, a completion and its tool arguments, and the default umask left those world-readable in/tmp, on a shared volume, and in any container several services mount. Files left by an earlier run are tightened at startup, because a file's mode survives every atomic rename. Mutations are serialised with an advisoryflock; the scope is the file mutation, never the network send — holding it across a request would serialise every worker behind one another's latency. The wait is bounded and measured against a clock, not counted in naps. The DLQ size cap deletes nothing: at the cap the append is refused.
- gate/12-unmapped — The backend error-code parity test in the
NULLRUN repo was a blind pin: it mirrored 51 codes by hand, so it
stayed green no matter what the backend added. Rewritten to derive
from the backend enum, it immediately named 12 codes the SDK had
no mapping for. Every one of them fell through
_map_v3_error_code's fallback and surfaced as the wrong exception type — a budget ceiling block reported as a generic backend error, a circuit-breaker trip reported as a protocol error. Added mappings for all 12, choosing the type each code's blast radius actually warrants (money ceilings and circuit trips become the blocked/budget types, lookup failures become backend errors, approval digest failures become the DB-unavailable type that already means "do not retry blindly"). Also removed theif decision == "throttle"arm inruntime.py—GateDecisionhas noThrottlevariant and the only producer is an uncalled phase-2 schema stub, so the branch was unreachable. (104fc98) - gate/no-none-variant —
check_workflow_budget()substitutedno_impact()when the call context carried no envelope, putting{"kind":"none"}on the wire. The gate's enum isMoney | ToolCall, so the request was refused by the deserialiser with 422 before any policy ran.business_impactisOptionon the wire, so the honest encoding of "no envelope" is absence. The digest stays: it is mandatory at protocol >= 3, and is still computed over the sameno_impact()canonical bytes, so a stored digest does not change across the upgrade. Measured on a live stand before choosing this, not inferred: omitting the field returns 200 at/gateand 200 at/execute. (55205c4) - track/422-fields — The overage body's
detailsblock (handlers.rs, theConsumeOverbudget422) carriesreserved_millicents,max_allowed_millicents,actual_cost_cents,soft_passandreservation_recorded. It does not carryreserved_cents,max_allowed_centsorepsilon_cents, and never has. The dispatcher read those three, so on a parsed 422 all of them were permanentlyNone— whileruntime.py's fail-closed table told callers to reconcile the delta from exactly those three. A caller following the documentation computedNone - None + None. Now reads the two millicents fields, the cents actual, andsoft_pass.epsilonis deliberately NOT reconstructed: the backend does not publish its configured tolerance on this body, and deriving one by subtraction would be arithmetic dressed as evidence. The honest recovery ismax_allowed_millicents - reserved_millicents, which the caller can do from the two numbers that do arrive. The*_centsparams are retained for callers that construct the exception themselves, and their docstring now says plainly that nothing on the wire fills them. (a8f881c) - edge/zero-remainder-assertion —
(last.get("remaining_millicents") or -1) == 0cannot pass. Zero is the value the assertion exists to observe, and0 or -1is -1, so the check reported the grant as NOT at zero on the one run where it was exactly zero. It failed against a live box for exactly that reason: the box returned remaining=0, which is correct, and the assertion meant to prove it said it had failed. This is the failure mode acceptance 0.1 exists to catch — the green that is not green — pointed at itself rather than at the product. (a5e87eb)
- SDK via-edge is opt-in via
NULLRUN_EDGE_URL. Unset — the default, and every existing user — and no code path is constructed and nothing is touched. Set, and the enforcement question goes to a box holding a signed lease instead of toapi.nullrun.io. The companion envs areNULLRUN_EDGE_LEASE_ID(required), the per-box token, and the existingNULLRUN_API_KEY(the box checks the caller's key against the fingerprint inside the grant; without it the box cannot tell which org is spending). An unreachable box is a refusal, and under STRICT that refusal is final. (cda7df1) /tracksingle-event v3 path forwardsprovider. Cached pricing differs by provider (Anthropic reports OUTSIDEinput_tokens, OpenAI / Gemini / Mistral report INSIDEprompt_tokens); the arithmetic is only correct for one of them and the backend has to be told which._build_llm_call_eventhas always stampedprovidervia_provider_label(host), and the batch path already forwarded it (it ships the whole enriched event). The single-event v3 path whitelisted its keys and dropped it. An absent or emptyproviderstays off the wire, so the backend can still tell "we could not determine the provider" (counted, fail-CLOSED) from a provider it named. (fd4d945)- Recovery writes
.inflightbefore send, clears after. A crash mid-send previously lost the batch — it had already left_bufferand was in HTTP, so neither the buffer nor the periodic WAL held it. A.inflightfile is now written before the send and cleared once the send lands. A stale one self-heals on the next successful flush. Re-delivery is only safe because the backend dedups onevent_id(thecost_event_id_dedupPRIMARY KEY), so every event is now guaranteed an id before it reaches disk. (1005d3a)
- mypy: 0 issues in 39 source files (the 5 in
instrumentation/auto.pyreproduce identically without these changes, same shape as the 0.21.0 baseline note). The twoarg-typeerrors atedge.py:367-368introduced bycda7df1were fixed by widening_float_env/_int_envfromdict[str, str]toMapping[str, str](c37db4a). - CI rejects a
Co-Authored-Bytrailer on this branch — acommit-msgpre-commit hook (scripts/reject_coauthor_trailer.sh) is the strong place (it fails before the commit exists); a workflow step is the backstop for the case a hook cannot cover (no pre-commit installed, a--no-commitpush, a CI bypass). Scope isorigin/master..HEAD, not--all— a whole-history grep counts trailers on the 200+ commits already published and nobody's to rewrite. (fc73d05, ef11c3e) - Ruff fix: import order in
runtime.py—edge.EDGE_LEASEwas placed in the wrong block after the via-edge import. The--fixis a one-liner re-sort and was applied as part of pre-flight.
- README states the via-edge opt-in envs and the three things
the mode refuses to do (silent fallback, caller-priced money,
self-picked lease). The held-refusal survival story is in the
dlq_holdover_*metric definitions. - The
/track422 overage field rename is documented inline inNullRunConsumeOverbudgetError's docstring — the*_centsparams remain for callers that construct the exception themselves, and the docstring now says plainly that nothing on the wire fills them.
| Check | Result |
|---|---|
ruff check src tests |
All checks passed |
mypy src/nullrun |
0 issues in 39 source files (the 5 in instrumentation/auto.py reproduce identically without these changes, same shape as 0.21.0's 6-error baseline note) |
pytest -q |
1828 passed, 12 skipped in 41.23s (vs baseline 1694 / 1 — +134 new tests, +11 more skipped; 5 from the gate-block-typed-dispatch file (already in 0.21.0), the rest from the WAL hardening, via-edge, and track-delivery slices) |
| Scratch diff | clean |
nullrun.__version__ |
0.22.0 |
| Wire-format | non-additive on /track single (the cost_cents field is removed; the provider field is added when set; the 422 overage details block has renamed fields — see Migration); additive on /gate (edge_lease is added to the decision_source allowlist, but the gate is internal to the SDK and only via-edge emits it) |
c37db4a fix(edge): type the env helpers as Mapping, not dict
ef11c3e ci: ship the commit-msg hook the trailer check assumes
66b8ef9 feat(track): stop putting a price on the wire — send tokens only
a5e87eb fix(edge): the zero-remainder assertion tested zero with `or`
8dedb27 test(edge): let the WAL gate run on a single cpu
85e98cd test(edge): the SDK against a live box, no mocks
cda7df1 feat(edge): SDK via-edge — the box is the gate, the cloud is not
fd4d945 feat(track): send `provider`, or the backend cannot price the cached fraction
104fc98 fix(gate): map all 12 unmapped backend codes, drop the dead throttle arm
55205c4 fix(gate): stop sending {"kind":"none"} -- the gate has no such variant
a8f881c fix(track): read the 422 overage fields the backend actually sends
f3c83b5 fix(track): a recorded CONSUME_OVERBUDGET is delivered, not quarantined
1f4193b fix(sdk): a held refusal must survive kill -9, and a long Retry-After must not be sat through
fc73d05 ci: reject a Co-Authored-By trailer on this branch
29ea059 fix(transport): four ways a refused event lost its evidence
68dc375 feat(wal): make the WAL's guarantees explicit, private, and observable
59ac884 fix(transport): settle a 200 per item — a 2xx is not delivery
550c234 fix(transport): classify 4xx before quarantining, and stop the breaker miscounting rejections
1005d3a fix(transport): stop losing the WAL on the recovery path
Closes the round-trip DEF-TC14-002 (QA cycle RUN_ID 20261002T0826)
on the SDK side. Two QA cycles in a row found the same defect from
two angles: 0.20.0's audit said "operator approves an action and the
agent still cannot run it", and this cycle's TC-4 said "/gate blocks
on a rate limit but the SDK raises NullRunBudgetError so callers
branch on the wrong cause". Both were the same root: the SDK was
sending a constant sentinel instead of the business impact envelope
the backend was looking for, and was classifying refusals by what
the SDK's wrapper assumed rather than by what the wire said.
Minor, not patch: 0.18.5's deprecation sweep deleted the
BusinessImpact.tool_call() constructor along with the curated
surface, and 0.21.0 restores it. The class shape is unchanged, but
the re-export path is — see Migration #1. The /track path now raises
typed enforcement rejections where it dropped them silently (see
Migration #2), and the /gate pre-flight now routes through the typed
dispatcher so a rate-limit refusal does not present as
NullRunBudgetError (see Migration #3).
Three things differ from 0.20.0. None are silent on a healthy configuration, but each can be a working loop becoming a throwing one for code that suppressed the failure before.
BusinessImpact.tool_call()is back. Importable fromnullrun.business_impact. The 0.18.5 deprecation sweep (aee8110) deleted it along with the curated surface reduction while the module docstring kept claiming it was there — the same prose/code disagreement that made the round-trip audit-difference read as a backend bug. The constructor emits exactly what the backend's internally-tagged serde produces, includingextractor_idandextractor_version(the backend has noskip_serializing_ifon these, so they are inside the hashed bytes and must be present here too). If you imported it from a private path, the import no longer needs to be private.- The v3
/tracksingle path raises typed enforcement rejections instead of dropping them. A 422CONSUME_OVERBUDGETused to be a WARNING log line and a return value ofTRACK_OK={'allowed': True, 'actions': [], 'local_cost_cents': 0}— the call was treated as successful at the agent layer. It now raisesNullRunConsumeOverbudgetErrorcarryingreserved_cents,actual_cost_cents,max_allowed_centsandepsilon_cents. Network errors and 5xx that name no enforcement failure still drop and log; widening the raise to those would freeze the agent loop on a dead backend, which is the failure mode the fail-OPEN rows exist to prevent. - The /gate pre-flight now types its refusal instead of
assuming budget. A rate-limit block, a tool-block and a
circuit-breaker trip used to reach the caller as
NullRunBudgetError(NR-B004). The dispatcher routes by wire code:RATE_LIMIT_EXCEEDED→NullRunRateLimitError,TOOL_BLOCKED→NullRunToolBlockedError,CIRCUIT_BREAKER_TRIPPED→ typed breaker class. A response with no machine-readable code still raisesNullRunBudgetError(the legacy tier is pinned).cost_limit_exceededis bumped only forNullRunBudgetError, so a rate-limit block no longer over-counts the spend cap.
Carried over from 0.20.0 and still true on 0.21.0 — the same class of break and the same shape of fix:
- An unclassifiable refusal now raises where 0.19.0 let the call
proceed.
NullRunUnclassifiedRefusalErroris a sibling ofNullRunTransportError(not a subclass), so an existingexcept NullRunTransportError:arm will not catch it. NullRunRuntime.execute(..., mode="inline")is gone.register_strict_mode_forced/is_strict_mode_forced/@guarded/nullrun.handle/nullrun.status()/nullrun.auto_instrumentare all gone (last touched in 0.18.5–0.20.0).
- ADR-065 (DEF-TC14-002) —
@protectbinds approvals to nothing. Since 0.18.5 every@protectcall sent a constant{"kind":"none"}sentinel; a constant hashes to a constant, so the digest the backend stored on the approval row at/gatematched the digest it recomputed at/executefor every tool — while binding the approval to nothing at all. The backend's refuse-the-reentry check therefore had no data to refuse against, and the operator's approval card did not correspond to any particular action. The fix is in five steps: (1)BusinessImpact.tool_call()is restored withextractor_id/extractor_version; (2) the envelope is carried on the call context as a contextvar, the same homeset_call_contextuses for the model and the tool list; (3)Transport.checkreads the envelope and sends it alongside the digest (the allowlist builder was dropping it before); (4)@protectbuilds the envelope before the/gatepre-flight — building it after means the two calls would carry different envelopes, and the backend would refuse every re-entry as a side effect; (5) a build failure (non-ASCII tool name, > 128 bytes) degrades tono_impact()and logs, so the tool still runs while the approval carries no trust binding and the server refuses the re-entry. That is a deliberate fail-OPEN on metadata — raising out of the decorator would take down a tool call over a metadata field. Acompute_action_digestrefactor exposes the canonical bytes for direct assertion; the shared fixture digest is byte-identical to the backend'sDIGEST_FIXTURE_HEX_TOOL_CALL(9975a8b7…6ed0526966a6). - DEF-TC29-001 —
Transport.checkwas droppingtool_classandmcp_annotationsfrom the/gatebody. The MCP integration had been computing them for the call context since it landed, but the transport's explicit allowlist builder did not include them, so a destructive MCP tool arrived at the gate astool_class=None, mcp_annotations=None— the negative case the backend pins, not the positive case the publicset_mcp_tool_contextAPI implied.effective_tool_class()falls back to name-based classification on the negative case, so this is a dead feature with a misleading API today — but the day the server-side flag flips, destructive MCP tools will silently degrade without a wire-level signal. Now sent unconditionally when set; absent means "unknown", not "false". - DEF-TC4-001 —
/gatepre-flight was raisingNullRunBudgetErrorfor every refusal. A rate-limit block (NR-R002), a tool-block (NR-T003) and a circuit-breaker trip (NR-B010) all reached the caller as NR-B004 "budget exhausted", which sends the operator looking for a spend-cap misconfiguration when the actual cause is a throttle policy. The pre-flight now routes through_build_block_exceptionand resolves the wire code in the same order the backend resolves the HTTP status:details["error_code"], then the top-levelerror_codethatTransport.checkalready copies onto its 4xx dict, thenexplanation. The dispatcher handles all three catalog families (decision / transport / infra), not just theNullRunBlockedExceptionone —sourceis never forwarded through**detailsbecause it collides with the keyword the class passes down itself. Two backend codes that had drifted out of the SDK catalog (BUDGET_WORKFLOW_BLOCKED,402;BUDGET_CACHE_EXCEEDED,402) are registered in the companion commit; the backend loggedBUDGET_WORKFLOW_BLOCKEDx389 in production before that registration, so the wire had been answering questions the SDK could not classify. - DEF-TC6-006 —
_route_trackwas wrappingtransport.track_singlein a bareexcept Exceptionthat logged at WARNING and returned. The transport layer had already classified the response — a 422CONSUME_OVERBUDGETbecomes a typedNullRunConsumeOverbudgetError— and the catch discarded it. The drop-and-log policy the catch implements is the one the ADR-008 table states for the/trackbatch path, a NETWORK error; the v3 single path has no such row. The fix re-raisesNullRunDecisionafter the existing cache invalidation and telemetry, so the blast-radius mitigation (DEF-CACHE-STALE-ALLOW-AFTER-OVERBUDGET) is not traded away for the reporting fix.
- DEF-TC21-001 —
WorkflowKilledInterruptwas documented asBaseException-only in three places (docs/errors/NR-W002.md,src/nullrun/breaker/exceptions.py's class catalog, theNullRunErrordocstring), but the class has been anExceptionsubclass since 0.16.6'sBreakerErrorreparenting (9877c34). The behaviour is correct and deliberate — agent recovery is meant to catch a kill and surface the structurederror_code/user_action;tests/test_decision_split.pydocuments the override. Only the prose was wrong, and it was wrong in the direction that would lead the next maintainer to revert working code.docs/errors/NR-W002.mdalso pointed atdocs/kill-contract.md§6, a file that does not exist.tests/test_exception_hierarchy.pyhad the same disease: the test was namedtest_killed_interrupt_does_not_inherit_from_exceptionwhile assertingissubclass(WorkflowKilledInterrupt, Exception). Renamed. - DEF-TC6-005 —
status().ws_connectedwas structurally pinned toNone.WebSocketConnectionhas an_runningflag (set in_connect, cleared by the receive loop'sfinally); the SDK was readingis_openviagetattr(..., None).is_openappears exactly once in the SDK: on the reading side, with no writer, no test and no producer — so thegetattrdefault fired on every call and the three states (never-established / live / dropped) collapsed to one. The fourth test in the new file asserts that the attributestatus()reads exists on a really-constructedWebSocketConnectionAND thatis_opendoes not — a stubbed connection cannot catch it, because the stub would carry whatever attribute the test author assumed.
- ADR-065: the five-step restoration of the
BusinessImpactenvelope is recorded in the SDK-side commit chain; the backend-side companion is in the NULLRUN repo. The shared fixture digest is pinned at byte-identity (9975a8b7…6ed0526966a6) so a future serializer change in either repo flips a test rather than degrading silently.
| Check | Result |
|---|---|
ruff check src tests |
All checks passed |
mypy src/nullrun |
Success: no issues found in 37 source files (the 6 errors in instrumentation/auto.py reproduce identically without these changes) |
pytest -q |
1694 passed, 1 skipped (~ baseline 1597 / 1 — +97 new tests; 5 from the gate-block typed-dispatch file alone, 5 from the track-propagation file, 4 from the WS-status file, 1+3 from the approval-roundtrip file, 1 from the gate-business-impact-wire file, 4 from the business-impact-tool-call file, 1 from the call-impact-context file) |
| Scratch diff | clean |
nullrun.__version__ |
0.21.0 |
| Wire-format | additive on /gate (carries business_impact, tool_class, mcp_annotations when set; absent means "unknown", not "false"); non-additive on /track (the v3 single path now raises typed enforcement rejections where it dropped them — caller-observable) |
d11eb78 test(protect): pin the unbuildable-envelope degradation
88ddec8 fix(protect): build the tool_call envelope before the /gate pre-flight
015407b feat(gate): send the context envelope at /gate instead of a sentinel
e3176d9 feat(sdk): carry one BusinessImpact envelope per logical action
4320ac0 feat(sdk): restore the tool_call BusinessImpact constructor
6108308 docs(kill): stop claiming the kill signal is BaseException-only
b64dc8a fix(mcp): forward tool class and annotations to /gate
ea7c9ee fix(track): propagate enforcement rejections from the v3 /track path
9ccf168 fix(sdk): read the attribute the WS connection actually has
76efa7b fix(sdk): type the /gate pre-flight refusal instead of assuming budget
4ec5460 fix(sdk): register the two backend budget codes in the SDK catalog
The remaining half of DEF-MP-TS12-ENF-01 (QA cycle RUN_ID
20260929T1338). 0.19.0 closed the paths it found by reading; these
close the ones that only showed up when the properties were asserted
end-to-end.
Minor, not patch: an unclassifiable refusal now raises where 0.19.0 let the call proceed. That is a working loop becoming a throwing one, which is a behavioural break and not a bug fix — read Migration before upgrading.
Six things differ from 0.19.0. Only the first three can surprise you at runtime, and the third is the one worth reading twice.
-
A
/gatetest double must carrydecision_source. Real backend answers always do — it is a non-OptionStringonGateResponse. A hand-written fixture that omits it now raisesNullRunMalformedGateResponseError. Fix: add"decision_source": "gateway"next to"decision": "allow". -
NULLRUN_SENSITIVE_FAIL_OPENagainst production now needs a second variable.NULLRUN_ALLOW_SENSITIVE_FAIL_OPEN=1must be set too, otherwise the opt-out is refused, enforcement falls back to its own fail-CLOSED default, and the attempt is logged at ERROR with a metric. Non-production behaviour is unchanged. -
An unclassifiable refusal now raises where 0.19.0 let the call proceed. This is the intended fix, and it is the one that can turn a working loop into a throwing one.
NullRunUnclassifiedRefusalErroris importable fromnullrun.breaker.categories(it is not re-exported at the top level) and carrieserror_code="NR-P003"andretryable=True. It is aNullRunInfrastructureErrorand a sibling ofNullRunTransportError— deliberately not a subclass of it. So an existingexcept NullRunTransportError:arm, which in 0.19.0 caught everything the gate could not classify and failed open, will not catch this one. That is the point: it is what stops a failed-open arm from swallowing a refusal. If you have such an arm, you have three options and they are not equivalent:from nullrun.breaker.categories import NullRunUnclassifiedRefusalError from nullrun.breaker.exceptions import NullRunError, NullRunTransportError try: ... except NullRunUnclassifiedRefusalError: raise # recommended: the SDK was right except NullRunTransportError: ... # still fails open, as before
Catching it alongside
NullRunTransportErrorrestores 0.19.0's behaviour exactly — which means it restores the bypass. Do not widen the arm toNullRunError: that also swallows real policy refusals, which is a larger hole than the one this closes. -
on_deniedis new. Default"raise", which is 0.19.0's behaviour. Set"message"to get aNullRunDeniedErrorcarrying the server-authoredagent_messageforcategory="denied"only. Any other value raisesValueErrorat construction rather than at first refusal. -
NULLRUN_SKIP_BUDGET_CHECKoutside production now logs and increments a metric when it skips the check. Enforcement behaviour is unchanged. -
/executerefusal bodies carrycategoryandagent_message. Additive on the wire. If you parse that body yourself and reject unknown keys, relax that.
Carried over from 0.19.0 and still true on 0.20.0, because it is the same class of break and the same shape of fix:
NullRunRuntime.execute(..., mode="inline")is gone and has no replacement — every call goes through/execute. Drop the argument;mode="auto"(the default) already always contacts the gateway.nullrun.runtime.register_strict_mode_forced/is_strict_mode_forcedare gone, along with@guarded,nullrun.handle(renamednullrun.guardin 0.18.5),nullrun.status(), andnullrun.auto_instrument.
- A
/gatebody must state who decided before it counts as a verdict._require_gate_decisionrejects a body whosedecision_sourceis absent or unrecognised, raisingNullRunMalformedGateResponseError. The runtime's rule wasdecision_source != fallback → honour the wire decision, which read a missingdecision_sourceas more trustworthy thanfallback. So{"decision": "allow"}— a captive portal's login JSON, an intercepting proxy's stub, anything on-path — was enough to authorise a call no policy engine evaluated. The field is a non-OptionStringon the backend'sGateResponse(gate/internal.rs:638) and every producer sets"gateway", so a real answer always carries one; there is no legitimate body this rejects. This is a behaviour change on the/gatepath: a hand-rolled/gateresponse in an existing test double must now includedecision_source. NULLRUN_SENSITIVE_FAIL_OPENis refused against production. It was read straight into the enforcement path, letting a sensitive tool's body run with no policy evaluation at all. Its siblingNULLRUN_SKIP_BUDGET_CHECKhas been production-guarded since it was caught doing the same; the asymmetry was an oversight, and this half is the more dangerous one — that one skips a pre-flight, this one skips the gate. The guard refuses the bypass rather than raising, so enforcement falls back to its own fail-CLOSED default and the attempt is logged at ERROR with a metric. Requires bothNULLRUN_SENSITIVE_FAIL_OPEN=1andNULLRUN_ALLOW_SENSITIVE_FAIL_OPEN=1; the documented dev/test use is unchanged.- The non-prod budget bypass is visible. When
NULLRUN_SKIP_BUDGET_CHECK=1skips the check, it now logs and increments a metric instead of being indistinguishable from a normal call.
-
@protectno longer loses a LangChain tool when it is the outer decorator. Applied to a@toolresult,@protectwrapped the object withfunctools.wrapsand returned a plain function — so.invoke,.name,.args_schemaand.descriptionwere gone, an agent loop could not bind the tool, and a tool the loop cannot see raises no refusal. Enforcement was silently absent in that ordering. The reported symptom was not even about NullRun:convert_to_openai_toolon the result raisesNameError: name 'Annotated' is not defined, which reads as a LangChain type bug rather than "your tool is not a tool any more".@nullrun.protect # now fine @tool def charge(amount: int) -> str: ... @tool # was already fine @nullrun.protect def charge(amount: int) -> str: ...
protectwraps the tool'sfunc/coroutinein place and returns the same object, so both orderings gate identically. Duck-typed on.invoke+.namerather thanisinstance(BaseTool), becauselangchain_coreis an optional dependency.handle_tool_error=Trueremains safe: it catches onlyToolException, so an enforcement refusal still aborts rather than becoming model-visible text.
- Every gate refusal is classified, and an unclassifiable one
raises.
NullRunUnclassifiedRefusalError(anNullRunInfrastructureError) is raised when a refusal carries nocategoryor one the SDK does not know. ADR-062 §2.2: absent or unrecognised means ask, never guess. The exception hierarchy is what makes this safe —NullRunUnclassifiedRefusalErrorandNullRunTransportErrorare siblings, not parent and child, so the fail-OPENexcept NullRunTransportErrorarms cannot swallow it. on_deniedselects the shape of adeniedrefusal and nothing else.on_denied="message"turns acategory="denied"refusal into aNullRunDeniedErrorcarrying the server-authoredagent_message— the only text the SDK guarantees is safe to relay to a model.budgetandhaltkeep their own exceptions even with the flag set: an agent told "that tool is not allowed" has an obvious next move, and that move walks into a budget wall or an operator's stop.infrais not reached by this flag at all — a 503 is converted by the 5xx band and the STRICT fallback, which is the correct outcome (an outage is not a denial)./executerefusals carrycategoryandagent_message. The MCP path (MCPAdapter.call_tool) is a different enforcement path from/gate, so anything the category work added to the/gateblock site was absent there by default. Both properties are now proven on the real adapter rather than inferred fromruntime.execute's lack of a fail-OPENexcept.
- README states the decorator ordering for LangChain tools and shows it
in both directions, with the reason (an unbound tool cannot refuse).
on_denied="message"is documented as the operator-facing "explain, don't crash" mode, including thathandle_tool_error=Truedoes not provide the same thing and would not be safe if it did. - README "Known limitations" states the trust boundary precisely:
the SDK trusts the channel and says so. Certificate verification
cannot be switched off by configuration, plain
httpis refused, and the residual risk is named — an on-path responder that can present a certificate the OS already trusts forapi.nullrun.io, which is not an exotic thing to find on a managed network. NullRun's responses are not signed, so there is no after-the-fact detection. - README names the second version skew beside the first: an SDK
older than the
decision_sourcecheck accepts a forged allow. Both skews point the same way — on an older SDK a real refusal and a fabricated permission are both read permissively, and neither is visible in the SDK's output. Pin the version if either matters. - The
transport.pycomment that claimedis_fail_closedmarks a fail-closed response on the wire is corrected. It does not:GateErrorCode::is_fail_closedis an in-process Rust method that is never serialised, and a client written against it could not have found it. ADR-064 owns the actual discriminator (ADR-063 §4.7 option 2, which pointed at the same non-existent marker).
Closes the SDK-side bypasses found auditing DEF-MP-TS12-ENF-01
(QA cycle RUN_ID 20260929T1338): the gate answering the agent
"allowed" on calls it had actually blocked or never checked.
NullRunRuntime.execute(..., mode="inline")removed. It returned a synthesised localallowwithout contacting the gateway, so budget, rate limit and tool-block policies were all skipped — the SDK's ownexplanationstring said as much. The only guard was a sensitivity check, which meant the safety of a tool call depended on whether someone had remembered to mark it sensitive. It now raisesNullRunConfigError(error_code="NR-S001") at the top ofexecute, before any context resolution. There is no replacement: every call goes through/execute. If you were usingmode="inline"to avoid a round-trip, drop the argument —mode="auto"(the default) already always contacts the gateway.nullrun.runtime.register_strict_mode_forced,nullrun.runtime.is_strict_mode_forcedand the module-level_STRICT_MODE_FORCEDset removed. They existed only to force strict mode past the inline fast path.register_strict_mode_forcedalready had zero callers (the@sensitivedecorator its own docstring referenced no longer exists in the SDK); dead security machinery reads as a live mechanism and invites a bypass being wired back up.MCPAdapter(runtime=None)no longer means "do not gate".call_toolwas conditional onself._runtime is not None, andruntimedefaulted toNone— so a default-constructed adapter (which is what the module's own documented example builds) called the MCP server with no/executeround-trip at all. The operator got a contextvar that a later@protectwrapper might read on its next/check: post-hoc annotation, not enforcement. The umbrellamcp_destructive_policy/mcp_readonly_policytherefore applied to a locally-declared function but not to a remote MCP call, on the same agent, in the same loop.runtime=Nonenow means "resolve the global runtime", on the same terms@protectresolves it. Resolution is lazy — atcall_tool, not at construction — so the adapter stays constructible in fixtures and doc snippets withoutnullrun.init(). A missing API key raises rather than degrading to an ungated call, matching@protect's fail-loud invariant. Passingruntime=explicitly still works and still wins.
mode itself is unchanged on the wire — it is still sent, and the
backend still ignores it (transport.py: "Wire-present but unused
by backend"). Its only real function was deciding whether to skip
enforcement.
The per-runtime sensitivity registry (add_sensitive_tool,
register_sensitive_tools, remove_sensitive_tool,
is_sensitive_tool, get_sensitive_tools) is unchanged — it
is a separate documented surface, and no longer has a consumer
inside execute. See ADR-061.
- A real gate decision is no longer treated as a transport
fail-open. The LangChain/LangGraph callback swallowed
NullRunBlockedExceptionalongside transport errors; enforcement exceptions are now recorded and re-raised through a thread-local deferred handoff to the@protectboundary, which is the only place the framework lets an exception abort. - One definition of a synthetic decision.
decorators.pyandruntime.pyeach had their own test for "is this source synthetic", and the two disagreed onAUTH_ERROR; both now callis_fallback_decision_source. - A skipped pre-flight gate is countable.
check_control_planeandcheck_workflow_budgetstill no-op when no workflow can be resolved (correct — a never-bound key has no control-plane state and no per-workflow budget), but the no-op now incrementscontrol_plane_no_workflow_total/budget_preflight_no_workflow_totalat DEBUG instead of being indistinguishable from normal operation. Behaviour is unchanged: it still never raises.
Consolidated release that bundles three rounds of work landed on
cleanup/deprecation-removal (#111) and never shipped as separate
versions: the 0.18.3 init/init_or_die unification, the 0.18.4
handle → guard rename plus top-level status() removal, and
the follow-on deprecation sweep (drop redundant @guarded, drop
framework-extras / auto_instrument public surface, strip legacy
"pre-fix / post-fix" docstring framing, drop stale init_or_die
references in source docstrings). Also adds a mechanical lint/type
cleanup pass that brings ruff check src tests and mypy src/nullrun back to green after the 1882-insertion / 15621-deletion
sweep across the branch (71 unused imports, 9 unused locals,
BusinessImpact.impact narrowed from Any to NoImpactPayload,
_LAZY_EXPORTS annotation tightened).
nullrun.handlerenamed tonullrun.guard. Same@contextmanagerbody (catchesNullRunError, re-raisesWorkflowKilledInterrupt, renders the four-line developer report,sys.exit(1)on failure). The nameguardwas freed when@guardedwas dropped earlier in this release. Migrate by replacingfrom nullrun import handle/with nullrun.handle:withfrom nullrun import guard/with nullrun.guard():.- Top-level
nullrun.status()removed. Reach the snapshot vianullrun.get_runtime().status()(returns the same frozenNullRunStatusdataclass). The wrapper's only role was raisingNullRunConfigError(NR-C004)when no runtime was bound — that path is nowget_runtime()'s job.NullRunStatusitself remains importable as a type. nullrun.init_or_die()removed. CLI fail-fast behavior is now a parameter oninit():nullrun.init(fail_on_exit=True)prints the same four-line developer report andsys.exit(1)on configuration failure.nullrun.shutdown()is now auto-registered viaatexitinsideinit()— long-running scripts get a clean WS close on process exit without an explicit call. Callingshutdown()manually remains safe and idempotent.@nullrun.guardeddecorator removed. It was a 3-line syntactic shortcut forwith nullrun.guard():; withguardas the canonical error-translation path the decorator form was pure duplication.- Framework-extras install groups dropped from
pyproject.toml([agents],[langchain],[langgraph],[llama-index],[crewai],[autogen]). The silent auto-detect instrumentation patches remain in code, butnullrunno longer advertises per-framework install paths or example wiring. Public install is one line:pip install nullrun. nullrun.auto_instrument/nullrun.is_auto_instrumentedremoved from the top-level namespace (they were internal triggers, not user-facing API). Module-leveltrack_eventalias removed (duplicate oftrack; theruntime.track_eventmethod is still used internally).NullRunCallbackremoved from_LAZY_EXPORTS(advanced / manual path; reachable vianullrun.toolbox.langgraphif ever needed).
init()gains afail_on_exit: bool = Falsekeyword argument. When True, missingNULLRUN_API_KEY(NR-C001) prints the developer-facing report and exits 1 instead of raising. Default False preserves the embedder-friendly raise semantics.- The
_shutdown_atexit_registeredmodule-level flag prevents double registration wheninit()is called more than once.
- Strips "pre-fix / post-fix", "previously / now we", "was X / is
now Y", and "legacy" framing from public docstrings across
runtime.py,transport.py,decorators.py, and 14 other modules. External ticket identifiers (DEF-, ADR-, AUDIT P*, IDEM-01, CLOSE-ORPHAN) are preserved as anchors. - Mechanical lint cleanup: 71 unused imports (F401) auto-removed,
9 unused locals (F841) hand-removed across
tests/.ruff check src testsandmypy src/nullrunare clean on the merged branch.
__version__, init, protect, shutdown, on_error, guard,
NullRunError, NullRunAuthError, NullRunConfigError,
NullRunBackendError, NullRunBudgetError, NullRunToolBlockedError,
WorkflowKilledInterrupt, NullRunWorkflowKilledError,
NullRunMcpDestructiveBlockedError,
NullRunMcpReadonlyBypassBlockedError,
NullRunMcpApprovalRequiredError,
NullRunApprovalDbUnavailableError,
format_user_message, set_user_message
(Net vs 0.18.2: status, handle, guarded, init_or_die,
auto_instrument, is_auto_instrumented, track_event removed;
guard added. Net -6 symbols vs 0.18.2.)
Wire contract: unchanged from 0.18.0.
@protect is the single user-facing entry point. Every call routes
through /api/v1/execute unconditionally — no opt-outs, no per-tool
registry to manage, no alternate decorators.
Top-level dir(nullrun) exposes only:
- Lifecycle:
init,protect,shutdown,on_error,status,handle,guarded,init_or_die - Messages:
format_user_message,set_user_message - Exceptions:
NullRunErrorand the typed catalog (NullRunAuthError,NullRunBackendError,NullRunBudgetError,NullRunConfigError,NullRunMcpApprovalRequiredError,NullRunMcpDestructiveBlockedError,NullRunMcpReadonlyBypassBlockedError,NullRunToolBlockedError,NullRunWorkflowKilledError,WorkflowKilledInterrupt)
Wire contract: unchanged from 0.18.0.