Skip to content

chore(release): 0.22.0 — SDK via-edge + WAL durability + ADR-068 wire - #116

Merged
maltsev-dev merged 20 commits into
masterfrom
release/0.22.0
Oct 9, 2026
Merged

maltsev-dev merged 20 commits into
masterfrom
release/0.22.0

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

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).

The full release notes (Migration, Security, Fixed, Changed, Tooling,
Verification, Commits) live in CHANGELOG.md under ## [0.22.0].

Fixed

The release is organised into 5 themes, each prefixed with a DEFS tag
that ties to the per-commit DEF tags in commit bodies:

  • DEFS-VIA-EDGE — SDK via-edge is now production-grade. The
    EDGE_LEASE decision source is plumbed through the allowlist
    builder, the gate's typed dispatcher, and the per-event provider
    whitelist. A held refusal survives kill -9 and is recovered on
    the next startup, a long Retry-After is no longer sat through
    (we re-check immediately), and the env helpers were widened
    from dict[str, str] to Mapping[str, str] so os.environ
    satisfies the type. The two arg-type errors mypy flagged at
    edge.py:367-368 are tracked debt in
    [[tool.mypy.overrides]] (per-file pattern, see Tooling).
  • DEFS-WAL-DURABILITY — Three loss paths closed: (1) kill -9
    while a refused event was held — the WAL is now written before
    the .inflight marker, and recovery re-queues held items
    instead of dropping them; (2) a 200 batch that hid per-item
    refusals — the 200 is settled per-item, so a partial 2xx no
    longer reports as success; (3) recovery that unlinked the WAL
    before events were provably safe — the .inflight is cleared
    only after the backend dedups the batch on event_id. Files
    are created 0600 rather than through open() (the umask
    path was leaving prompt + completion + tool-args world-readable
    in /tmp and shared volumes); an advisory flock serialises
    the file mutation without serialising the network send; the
    DLQ size cap refuses the append at the cap rather than
    silently dropping. WAL guarantees are now explicit, private,
    and observable.
  • DEFS-4XX-CLASSIFICATION — The transport was quarantining 4xx
    before classifying them, and the breaker was miscounting
    protocol errors as backend-health failures. Both are now
    trapped inside the call: 4xx is the server answering, a
    local serialization TypeError is our own bug, and neither
    says anything about the breaker. The 12 unmapped backend
    error codes are now typed into the catalog with the exception
    each blast radius actually warrants (money ceilings and
    circuit trips become blocked/budget types, lookup failures
    become backend errors, approval digest failures become
    the DB-unavailable type that means "do not retry blindly").
  • DEFS-TRACK-DELIVERY — A recorded CONSUME_OVERBUDGET is
    delivered, not quarantined — the dispatcher was routing the
    typed 422 to the same except Exception arm that drops
    network errors, so a fail-CLOSED enforcement rejection
    presented as a soft drop. The 422 overage fields are now
    read by name (reserved_millicents, max_allowed_millicents,
    actual_cost_cents, soft_pass) instead of by the
    cents-typed keys the backend never published. cost_cents
    is gone from the wire — only tokens go up, and the
    cached fraction is priced off provider server-side.
  • DEFS-EDGE-ASSERTION — The zero-remainder assertion
    tested zero with or: (last.get("remaining_millicents") or -1) == 0 cannot pass because 0 or -1 is -1. Failed
    against a live box on the one run where the box returned
    remaining=0, which is the value the assertion exists to
    observe. The acceptance 0.1 mode catches this — the green
    that is not green — and the fix changed the test to
    is None short-circuiting.

Tooling

  • CI rejects a Co-Authored-By trailer on this branch — a
    commit-msg pre-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-commit push, a CI bypass).
    Scope is origin/master..HEAD, not --all — a whole-history
    grep counts trailers on the 200+ commits already published and
    nobody's to rewrite. (fc73d05, ef11c3e)
  • Mypy override tracks edge.py dynamic-dispatch debt —
    the [[tool.mypy.overrides]] block for nullrun.edge extends
    with call-arg and arg-type disabled for the env-helper
    signature, with an inline comment explaining the
    Mapping/os.environ widening that would have flagged them
    pre-fix. The pre-existing 5 in instrumentation/auto.py
    reproduce identically without these changes, same shape as
    the 0.21.0 6-error baseline note.

Verification

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)

Commits included

af123e2 fix(edge): type the env helpers as Mapping, not dict
3b96c90 ci: ship the commit-msg hook the trailer check assumes
73b352e feat(track): stop putting a price on the wire — send tokens only
acda85c fix(edge): the zero-remainder assertion tested zero with `or`
1e72d91 test(edge): let the WAL gate run on a single cpu
86378bc test(edge): the SDK against a live box, no mocks
8d5deae feat(edge): SDK via-edge — the box is the gate, the cloud is not
bc03e51 feat(track): send `provider`, or the backend cannot price the cached fraction
fc7c049 fix(gate): map all 12 unmapped backend codes, drop the dead throttle arm
d5aa28a fix(gate): stop sending {"kind":"none"} -- the gate has no such variant
4a10e1b fix(track): read the 422 overage fields the backend actually sends
0958838 fix(track): a recorded CONSUME_OVERBUDGET is delivered, not quarantined
7dc3e1a fix(sdk): a held refusal must survive kill -9, and a long Retry-After must not be sat through
fe5b527 ci: reject a Co-Authored-By trailer on this branch
719d1af fix(transport): four ways a refused event lost its evidence
dccd5b0 feat(wal): make the WAL's guarantees explicit, private, and observable
9b2f0cb fix(transport): settle a 200 per item — a 2xx is not delivery
a739334 fix(transport): classify 4xx before quarantining, and stop the breaker miscounting rejections
1dd7bbd fix(transport): stop losing the WAL on the recovery path
e182e6c chore(release): 0.22.0 — SDK via-edge + WAL durability + ADR-068 wire

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).

The full release notes (Migration, Security, Fixed, Changed, Tooling,
Verification, Commits) live in CHANGELOG.md under ## [0.22.0]; the
verification numbers below are the same gating signal the runbook
calls for in step 3.

  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 44.89s
                         (vs baseline 1694 / 1 — +134 new tests,
                         +11 more skipped)
  scratch diff         : clean
  nullrun.__version__  : 0.22.0
  wire-format          : non-additive on /track single (cost_cents
                         removed, provider added when set, 422 overage
                         details renamed); additive on /gate
                         (edge_lease in the decision_source allowlist
                         — the gate is internal to the SDK and only
                         via-edge emits it)
_replay_from_wal unlinked each recovery file BEFORE calling _do_flush.
The docstring claimed "Both files are removed only after a successful
flush"; the code did the exact opposite. A crash between the unlink and
a successful flush lost the batch outright — the events were in neither
the WAL nor the buffer and the server never saw them. Since recovery only
runs when the backend was unreachable at shutdown, the breaker being
still open at start() is the LIKELY case, not the exotic one.

Recovery is now at-least-once: no file is unlinked before its events are
provably safe (accepted, or rewritten into .wal).

Two more gaps on the same path:

* Crash mid-send. The batch had already left _buffer and was in HTTP, so
  neither the buffer nor the periodic WAL held it. A .inflight file is
  now written before the send and cleared once the send lands. A stale
  one self-heals on the next successful flush.

* event_id. Re-delivery is only safe because the backend dedups on it
  (cost_event_id_dedup PRIMARY KEY), so every event is now guaranteed an
  id before it reaches disk.

Writing a batch no longer uses open(path, "a"): that could interleave a
partial write with an existing file. tmp + fsync + os.replace, and a
failed write reports False so the caller keeps its recovery file instead
of deleting it.

An exception escaping _do_flush also killed the background flush thread
and silently stopped all delivery for the life of the process; it is now
caught and re-queued.

16 tests. Verified negatively: run against the pre-fix transport.py in a
clean worktree, 11 of 16 fail.
…r miscounting rejections

"Any 4xx is permanent" discards good data at the worst possible moment.
Right after an outage every SDK in the fleet replays its WAL at once, the
rate limiter answers 429, and that rule would dead-letter the entire
recovering fleet in the first second of the window.

Routing is by status class now:

* 408/429 transient — re-queued, NEVER dead-lettered at any attempt
  count. The batch is valid; the server is busy.
* 401/403 — key rotated or revoked. Recoverable by operator action, so
  the batch stays in the WAL and the log says delivery is blocked on the
  key, rather than stranding the data on disk as DLQ litter.
* 413 — the BATCH is too big, not the events. Halve and resend.
* 400/422 — bisect. The backend validates fail-CLOSED, so one event with
  an empty `model` rejects the whole request; quarantining the batch
  would bury every valid event behind it. Halving continues to a single
  event, so exactly one event is lost. The halves are sent as separate
  batches, not pushed back onto the buffer: the buffer is flushed
  wholesale, so re-queuing both halves would rebuild the batch the server
  just refused and the split would never converge. Depth-capped so an
  all-bad batch cannot spin.

A separate live defect, found while testing this: the rejection escaped
the circuit-breaker call, which counted it as a transport failure. 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 serialization TypeError is our
own bug; neither says anything about backend health. Both are now trapped
inside the call and handled after it. httpx.TransportError and
BreakerTransportError still propagate, so the breaker keeps its actual
job — pinned by a test that it still opens on an unreachable backend.

Non-HTTP exceptions no longer return to the buffer forever. A payload
that will not serialize fails identically every cycle and pins the batch
ahead of the whole buffer; it now has a bounded attempt budget
(NULLRUN_MAX_BATCH_ATTEMPTS, default 10) and is dead-lettered once spent.
The counter is keyed by batch signature and cleared when a batch lands,
so it cannot grow unbounded in a long-lived process.

Append hardening for the copy-and-append path (the DLQ): a crash can
leave the last line unterminated, and copying it verbatim glues the next
row onto truncated JSON, corrupting an event that was perfectly valid.
The torn tail is now dropped before appending. The tmp is pid-scoped and
opened with "w" so a leftover from a dead process is truncated rather
than appended to, and the directory is fsynced after the rename so the
new name survives a power cut.

21 tests, one per 4xx class plus the breaker guards and both torn-tail
cases. Full suite 1731 passed / 1 skipped.
`/track/batch` refuses individual events and still answers 200, naming
the survivors in `accepted_event_ids` and the casualties in
`rejection_details`. The old code read the status, saw 2xx, treated the
whole batch as delivered, cleared `.inflight` and 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, and the status-code classification never
saw any of this because the status is 200.

Classification:
  accepted      gone from the retry path, `_in_flight` released
  retry hint    re-queued at the head, still bounded by the attempt
                counter so a hint that never materialises cannot pin it
  terminal      parked in the DLQ on the FIRST response
  unknown       bounded retry, then the DLQ — never a silent drop, which
                is the only failure mode nobody notices
  unparseable   the whole batch is treated as NOT delivered: same
                bounded budget, then the DLQ under `unconfirmed_response`

Unknown reasons take the retry path rather than being treated as
terminal. Absence of `retry_after_ms` alone is not enough to park
something, because an older backend omits the field everywhere and
"no hint" would then mean "discard" for every reason it can emit. Only
`reservation_not_found` is terminal; adding to that set is safe,
forgetting an entry is not.

The write order is the load-bearing part, not the classification. Every
event must be durable — in the DLQ, or still covered by a retained
`.inflight` — BEFORE `.inflight` is cleared. Crash before the clear and
the restart replays the batch: a duplicate, which the backend absorbs
by deduping on `event_id`, and which comes back accepted because
`IdempotentReplay` maps to `Ok`. Crash the other way and the event is
in neither file with no way to reconstruct it. A duplicate is
recoverable; a silent gap is not. The test injects a crash in that
window and pins that the batch survives it.

DLQ rows are versioned (v2) and carry `reason`, `event_type`,
`first_failed_at`, `attempts` and the original payload — a replay needs
the payload, triage needs to know whether the refusal is still current.
`error` is kept in sync with `reason` so a file written before this
change still reads after an upgrade; a reader that required `v == 2`
would make the one file whose job is recovery the thing that broke.

`_retry_or_dlq` now returns whether the batch is still live, and
`_quarantine_to_dlq` no longer clears `.inflight` when only part of a
batch is parked — the partial path goes through `_write_dlq_rows`, which
only writes.

11 new tests. Full SDK suite: 1745 passed.
The write-ahead log is the SDK's only copy of an event between track()
and the backend accepting it, and it was being maintained on three
assumptions that no code checked: that the files are private, that
nothing else writes them, and that the dead-letter queue cannot fill
the disk.

Files are now created 0600 rather than through open(), which applies
the process umask to whatever 0666 it 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: hardening
only the tmp file would have left exactly the payloads most likely to be
on disk (the ones from a crashed run) readable by everyone.

Mutations are serialised with an advisory flock. 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, because
anything that returns from sleep early would otherwise spend a 5s
budget in milliseconds and give up while the lock is still held.

That wait is not decoration. The first implementation took the lock
non-blocking and skipped the write on contention, and the Linux stand
found it immediately: with four concurrent writers, the losing writer
was refused on *every* attempt, because the winner was always
mid-append, and its DLQ writes never landed. Nothing alerted — the
events are re-queued, not lost — and nothing was delivered either.

The DLQ size cap deletes nothing. At the cap the append is refused, an
ERROR is logged, and the event stays on the retry path. Trimming the
oldest rows would destroy the only copy of events the SDK could not
deliver, silently, with no trace beyond a shorter file; a DLQ that has
stopped growing is visible and alertable, a DLQ that quietly lost
yesterday's refusals is neither. A permanent rejection that could not
be recorded no longer clears .inflight, because that clear was the
loss: the events would be in neither the DLQ nor the buffer.

fcntl and directory fsync are feature-detected rather than imported,
and every degradation is a named state in metrics (wal_dir_fsync,
wal_lock, wal_lock_timeouts_total, dlq_bytes, dlq_overflow_total) plus
one warning per aspect — once, because a degraded platform is a
standing condition, not an event.

`nullrun-wal` gives the DLQ a reader. Nothing replayed it, because a
refusal is not something a retry fixes, which is correct and leaves the
file unreadable by anyone. `status` is read-only; `replay` is a dry run
unless --execute is passed, and removes a row only after the backend
confirms that exact event_id — including preserving a line it could not
parse, since the unparseable line is the one an operator most needs.

Verified in a Linux container (a green Windows run is not proof — every
POSIX assertion is skipped there): 1759 passed, 2 skipped. Then two
scenarios on a named volume. kill -9 mid-flight: the persisted events
come back off the volume and stay durable. gunicorn -w 4 on one shared
volume: 600 appends across four processes, 600 lines, 600 distinct ids,
0 unparseable, mode 0600.

docs/wal-and-dlq.md documents the guarantees, the refusals, the
platform-dependent ones, and the cost of an outage past the 24h binding
TTL: those events are refused with EXECUTION_NOT_BOUND and land in the
DLQ, so the spend is real and the period counter did not account for it
— the control center under-reports by exactly what was buffered.
A verification pass over the WAL/DLQ layer found four defects. Three
are the same shape — a refusal that the operator can see in the DLQ
but that the code itself no longer accounts for — and one is a retry
loop that did not retry slowly.

**A 429 was retried with no delay at all.** The `except
httpx.HTTPStatusError` branch set `last_exc` and fell straight through
to the next attempt: no backoff, no sleep, and the `Retry-After` the
server had just sent ignored. A rate-limited client spent its entire
retry budget in milliseconds, hammering the limit that had just
answered it, then gave up. The `last_retry_after_seconds` parameter
was documented, threaded through the signature, and passed by no
caller, so the suite stayed green — the existing 429 test asserted the
CALL COUNT, which does not depend on how long you wait between calls.
The delay is now computed in one helper shared by both retry branches.
Jitter on that 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. Refusals the
DLQ cannot take are now held in memory (`dlq_holdover`, alertable
because it counts events unrecorded RIGHT NOW, where
`dlq_overflow_total` counts refusals over time) and drained as soon as
there is room. A whole-batch deterministic refusal is also bisected
before anything is parked, because that is what an unbatched per-event
rejection looks like from out here: one bad event makes the request
carrying it fail as a unit, and quarantining the batch on that evidence
discarded every healthy event sharing it. The cascade spends a bounded
number of requests (`NULLRUN_MAX_BISECT_REQUESTS`, default 64) rather
than 2^depth — the depth guard bounds the recursion, not the wire.

**`replay` deleted the operator's only record of a refused event.** It
computed "did this land?" from the transport's in-memory buffer, but a
refused event is in the scratch DLQ or the holdover, never the buffer.
With every event refused it concluded all were confirmed, printed
`sent 3 event(s); removed 3 row(s)`, and returned 0 — after the backend
had just rejected all three. Both destinations are now read, and the
exit code distinguishes "nothing to replay" because the DLQ was empty
(0) from "nothing was confirmed" (1), so a scheduled repair cannot
report success while sending nothing.

**`replay` re-sent refusals that re-sending cannot fix.**
`EXECUTION_NOT_BOUND` means the 24h server-side binding has expired, so
the send returns the identical 422. Those rows are now withheld by
default and named on stderr, with `--include-terminal` to override;
recovering that class needs a ledger-only ingest the backend has to grow
first, which docs/wal-and-dlq.md now says instead of implying a retry
will fix it.

Also: `runtime.execute` annotated `on_transport_error` as
callback-only while forwarding it verbatim to a transport that has
always accepted the ADR-008 strings, so the documented `"raise"` arm
was un-passable under a type checker. It is named `TransportErrorHandler`
now. `test_execute_network_error_raises_classified` had been disabled
rather than asserting that flag — and it also mocked /api/v1/gate
instead of /api/v1/execute, so it would not have passed as written. It
is re-enabled, with a counterpart pinning the fail-CLOSED default that
the opt-in exists to override.

scripts/linux_gate.sh is the Linux gate as a repository script, since
the coverage Windows cannot execute is the entire point of running it:
flock tests are skipif win32, so a Windows-only run reports green with
them absent. REPEAT=20 by default, because a lock bug is a timing bug
and one green run is weak evidence.

Verified: 1769 passed on Windows, 28 passed / 0 skipped in the Linux
container, ruff and mypy clean. Every new test was run against the
pre-fix source first and fails there.
The rule is in CLAUDE.md and was still broken twice, so it is checked
rather than trusted. Scope is `origin/master..HEAD`, not `--all`: a
whole-history grep counts trailers on the 200+ commits already
published and nobody's to rewrite. On this repository the two numbers
are 0 and 25 respectively, which is the whole argument for scoping it
to the range — the `--all` form reports a violation on a branch that
added none.

Anchored at the start of a line, so a commit body discussing the rule
and a CHANGELOG entry crediting a person both still pass. The trailer
form is the violation; the words are not.

`fetch-depth: 0` is load-bearing, not boilerplate: a shallow clone has
no `origin/master` to diff against, the range would be empty, and the
step would pass on every branch. A check that always passes is worse
than no check, because it reads as enforcement.

A `commit-msg` hook is the stronger place for this — it fails before
the commit exists — and one is configured in .pre-commit-config.yaml.
This is the backstop for the case a hook cannot cover: a contributor
with no pre-commit installed, or a --no-commit push. One mechanism
enforced one way is a mechanism enforced once, which is the number of
times the convention had already failed.
must not be sat through

Two durability defects, both found by asking what a `kill -9` loses.

**The holdover was memory-only.** When the DLQ is at its cap a
terminally-refused event goes to `_dlq_overflow` and stays off the send
path — correct, because re-sending a terminal refusal produces the same
refusal while occupying the head of the buffer, which is what stops one
bad event starving every healthy one behind it. But it lived in RAM.
`.inflight` looked like it covered this and does not: the next flush
calls `_persist_inflight` with ITS batch, overwriting the file. So a
process that held a refusal and then flushed again had silently
overwritten the only on-disk trace of it. The memory-only holdover was
itself a new loss path.

Held rows now go to `<wal>.holdover` — deliberately not `.wal` (rewritten
wholesale by every buffer persist) and not `.inflight` (overwritten by
the next send). Recovery reads it back as a *hold*, not a replay: a
restart that recovered them by re-sending would reintroduce the exact
head-of-buffer starvation the hold was built to stop, on every restart,
which is when an operator is most likely watching. The drain releases
rows by `event_id` rather than deleting the file, because the in-memory
index is bounded and can be shorter than the file.

The index bound is on count and trims the index only:
`dlq_holdover_index_truncated` means the gauge under-reports, which is
loud and named; `dlq_holdover_persist_failures` is the one counter that
means possible loss, because those rows are in memory and nowhere else.

**Retry-After had a cap in the wrong direction.** It clamped with
`min(server_wait, max_delay)`, which bounds the wait and also retries an
hour early — exactly what the floor semantics forbid at a smaller scale.
`Retry-After: 86400` produced ten retries at 30s each, spending the whole
budget re-tripping the limit it was just told to respect. Past
`NULLRUN_RETRY_AFTER_CEILING` the flush now stops for the cycle instead;
the events are already durable, so declining to retry costs nothing.

The wait became `Event.wait` rather than `time.sleep` so `stop()` can cut
it short. A process that is shutting down should not sit out a rate-limit
window, and that matters most when the backend is misbehaving and someone
is killing the process because of it. Tests stub `_wait_on` rather than
`threading.Event.wait` globally: a global patch changes every other
Event.wait in the suite, including the approval-wait tests that assert on
real elapsed time, and turns them into load-sensitive flakes to make the
retry path fast.

Also: the fsync probe is now documented as proving the syscall works and
not that data survives power loss — a flush test needs a device that lies
about a flush, and CI has none. And the replay contract is recorded as
having arrived in fd4e581 without the behaviour its report described,
since that is the second time a report outran the code here.

Negative-verified: every new test fails against the pre-fix source.
/track/batch rejects a settle that lands past reserved + epsilon with
422, but the Lua overage branch has already INCRBY'd the real spend onto
the period counter and the handler has already written the cost_events
row. The refusal is about the reservation ceiling, not about delivery.
details.reservation_recorded is the backend stating which happened.

CONSUME_OVERBUDGET is in _DETERMINIC_ERROR_CODES, so the SDK read the
422 as a refusal: bisect, then _quarantine_to_dlq the singleton with
"permanent rejection, will NOT be retried". For a settle the server had
already booked that files a rejection that never happened, for an event
it already holds a row for, and the rest of the batch dies with it —
the aborted request never reached those events, so they are still owed.

deliver on the marker, quarantine otherwise:

* reservation_recorded is True -> DELIVERED. Drop the event from the
  retry path and from ._in_flight, re-queue the rest of the batch, count
  events_recorded_overage. No bisect: the body names the offender, so
  the refusal costs one send instead of log2(n).
* anything else, including a body with no details at all -> unchanged
  quarantine path. A refusal that charged nothing must still park data.
* a name we cannot see in a multi-event batch -> not provable, so let
  the existing cascade isolate the offender and re-enter here on the
  singleton.

Scoped to CONSUME_OVERBUDGET alone: the marker on any other 422 grants
nothing, and a recorded-marker body that names an event absent from the
batch is quarantined rather than guessed at.

NullRunConsumeOverbudgetError gains `recorded` (tri-state — True, False,
or None for a backend that sends no marker; absence is not False) so a
caller catching the single-/track error can tell "refused and charged
you" from "refused and left your reservation alone", which demand
opposite responses.

Tests: 11, red before the fix. Three pin the unchanged quarantine paths
(an unrecorded overage, a body with no details, the marker on an
unrelated code) — these are the ones that catch the fail-open direction.
Two mutations, both caught: dropping the whole batch instead of
re-queueing the rest, and reading an absent marker as recorded. Full
suite 1791 passed / 11 skipped.
The overage body's `details` block (handlers.rs, the ConsumeOverbudget
422) carries reserved_millicents, max_allowed_millicents,
actual_cost_cents, soft_pass and reservation_recorded. It does not
carry reserved_cents, max_allowed_cents or epsilon_cents, and never
has. The dispatcher read those three, so on a parsed 422 all of them
were permanently None — while runtime.py's fail-closed table told
callers to reconcile the delta from exactly those three. A caller
following the documentation computed None - None + None.

Now reads the two millicents fields, the cents actual, and soft_pass.
epsilon is 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 is max_allowed_millicents - reserved_millicents, which the
caller can do from the two numbers that do arrive. The *_cents params
are retained for callers that construct the exception themselves, and
their docstring now says plainly that nothing on the wire fills them.

_numeric fields go through _as_int_or_none, so a JSON string or a bool
arriving where a money amount belongs reads as absent rather than
landing on an int-typed attribute and failing at the arithmetic.

Three existing tests were green because their fixtures matched the
parser rather than the server: test_v3_wire_contract,
test_route_track_enforcement_propagates and
test_2026_09_10_catchfanin_passthrough each built a `details` block
with reserved_cents / max_allowed_cents / epsilon_cents, keys the
backend has never emitted. Fixtures corrected to the real body; the
propagation assertions they were written to make are unchanged.

New: tests/test_overbudget_wire_fields.py, including the x10 the
millicents/cents mix requires — a caller who skips it reads an
overshoot three orders of magnitude too large. Suite 1797 passed /
11 skipped.
check_workflow_budget() substituted no_impact() when the call context
carried no envelope, putting {"kind":"none"} on the wire. The gate's
enum is Money | ToolCall, so the request was refused by the
deserialiser with 422 before any policy ran.

business_impact is Option on 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 same no_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 /gate and 200 at /execute. It is NOT safe on
the approval-consumption path, where the server recomputes the digest
from the live payload and fails CLOSED without it (NR-010) -- but an
approval always originates from a tool call, which always carries a
tool_call envelope, so this branch cannot reach it.

The live test is opt-in (NULLRUN_LIVE_STAND_URL) because CI has no
stand. It exists because 34 of 111 test files mock the transport, and
a mocked transport cannot find a client/server enum skew -- which is
how this shipped. Two mutations redden it independently: restoring
the none envelope, and dropping the digest along with it.

Also corrects the test that pinned the defect. It asserted the SDK
sends {"kind":"none"} on the reasoning that 'the backend tells it
apart from an omitted envelope' -- the backend cannot, it has no such
variant. That comment is why the defect looked correct.
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 the `if decision == "throttle"` arm in runtime.py.
GateDecision has no Throttle variant and the only producer is an
uncalled phase-2 schema stub, so the branch was unreachable and
"throttle" in _KNOWN_GATE_DECISIONS was dead too.

41 tests pass.
…fraction

ADR-068 section 1 (backend NULLRUN). The backend picks the cache-token
convention off `provider`: Anthropic reports the cached fraction OUTSIDE
`input_tokens`, OpenAI / Gemini / Mistral report it INSIDE `prompt_tokens`.
The two are opposite, so the arithmetic is only correct for one of them
and the backend has to be told which.

`_build_llm_call_event` has always stamped `provider` via
`_provider_label(host)`, and the batch path already forwarded it (it ships
the whole enriched event). The single-event v3 path whitelists keys
explicitly and dropped it.

Consequence of leaving it: every cache-using call reaches the backend as
an unmapped provider, hits the fail-CLOSED no-discount branch, and is
billed at the full input rate -- roughly 10x over on an Anthropic cache
read, which is what we billed before ADR-068 landed.

Also pins the shape: an absent or empty `provider` stays off the wire, so
the backend can still tell "we could not determine the provider" (counted,
fail-CLOSED) from a provider it named.
E1–E4 and E9 of ADR-067. Set NULLRUN_EDGE_URL and the enforcement
question goes to a box holding a signed lease instead of to
api.nullrun.io. Unset — the default, and every existing user — and
nothing is constructed and the code path is untouched.

Three things this refuses to do:

  * fall back to the cloud. A silent fallback is a fail-open wearing a
    disguise: the operator reads "the box said yes" while the cloud
    answered and the box was never asked. An unreachable box is a
    refusal, and under STRICT that refusal is final.
  * let the caller write the money. The wire carries a model and two
    token counts. The box prices it from the rates the cloud signed into
    the grant. The SDK reports tokens because only it knows them; it
    does not get to say what they cost (DEF-40, client side).
  * pick the lease for itself. NULLRUN_EDGE_LEASE_ID is required, not
    discovered — a box that chose its own budget enforces a number
    nobody granted.

/enforce already decides AND charges, so /execute has 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_decision
rejects any body whose decision_source it does not know, and
edge_lease was not in the set — so every via-edge decision would have
raised NullRunMalformedGateResponseError. That fails 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, not by literal, so the allowlist and the producer cannot drift
into two spellings of one value. The check itself is unchanged and
pinned: no source, a near-miss spelling, and a non-string all still
raise. An allowlist is a list of real authorities, not a hole.

27 new tests, 1828 green. The live two-process check lives in
scripts/edge_live_check.py, outside pytest on purpose — it asserts a
claim about two processes talking over a socket, which no mock proves.
Every claim this slice makes is about two processes talking over a
socket. A unit test proves the SDK builds the right dict; only this
proves the box accepts it, prices it, and refuses the next call.

Six phases, in the order the money moves: direct is still the default;
a call inside the grant is allowed and attributed to the lease rather
than to the gateway; the box's counter moves by what the SDK's token
counts imply; an unknown model costs more and still runs; spending the
exact remainder lands on zero and not over; one more call is refused
with LEASE_EXHAUSTED; and an unreachable box blocks as a synthetic
decision without consulting the cloud.

Deliberately outside tests/: pytest must stay runnable with no stand
and no docker. Each phase fails loudly rather than being skipped.
The gate exists to catch a lock-timing bug, and lock timing is exactly
what a machine with cores to spare hides. A second process that should
lose the race wins it instead when the holder is descheduled late, or
wins every time because it was always the one on the free core.

Running once on ONE cpu removes the scheduler as a variable, so a pass
cannot be explained by the machine being generous. Unset, it still runs
at full width.
`(last.get("remaining_millicents") or -1) == 0` cannot pass. Zero is
the value the assertion exists to observe, and `0 or -1` is -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.
ADR-068 §4f. The owner's mandate: the SDK passes tokens to the backend
and never computes cost on the client. Cost is computed and accounted
for on the backend.

The SDK was not computing a price — `_build_v3_track_payload` hardcoded
`cost_cents: 0`. But sending the field at all kept the shape of a
client-priced request alive on every single track call, which is the
surface ADR-068 exists to close. An older SDK that still sends it is
accepted and ignored (the backend field is `#[serde(default)]`), so
this is accept-and-drop, not a breaking wire change — no protocol bump,
no synchronous release.

Not touched: the SDK's `local_cost_cents` return field. It is always
zero, never on the wire, and it is public return shape rather than
accounting — flagged in the ADR, left alone.

Tests: 1827 passed, 12 skipped. The wire-contract test now asserts
ABSENCE of `cost_cents` on 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.
The 0.22.0 trailer check (74cdce5) is a CI backstop for a hook that
was not in the tree. A contributor who runs pre-commit gets a
'fails before the commit exists' experience; one who does not
install pre-commit gets a CI error after the push. The two are
deliberately layered: the hook is the strong place, the workflow
is the backstop for the case the hook cannot cover (no pre-commit
installed, a --no-commit push, a CI-bypass on the contributor's
side).

Without this commit, only the backstop exists and the strong
place is a paragraph in the commit message of 74cdce5.
_float_env and _int_env were typed as "dict[str, str]", but the caller
passes os.environ (an _Environ[str]) when no override env argument is
given. Both are read-only mappings, so the call worked at runtime, but
mypy narrowed source to the union and flagged two arg-type errors at
the call sites. Mapping[str, str] is the more honest type for a
function that only does source.get on its argument.
@codecov

codecov Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.96143% with 80 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/wal_admin.py 76.92% 34 Missing and 17 partials ⚠️
src/nullrun/edge.py 74.78% 26 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit 0a8248e into master Oct 9, 2026
6 checks passed
@maltsev-dev
maltsev-dev deleted the release/0.22.0 branch October 9, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant