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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,47 @@
## [0.18.0] - 2026-09-21

Minor release — **closes the structural orphan where the `approvals` row stayed at `status='APPROVED'` past `expires_at`** because the only path that flipped it to `CONSUMED` was the orchestrator's Step 6 inline at `backend/src/proxy/http/gate/orchestrator.rs:713`, which `mode="inline"` tools bypass entirely. The fix wires the SDK to call a new structurally-distinct endpoint (`POST /api/v1/approvals/{approval_id}/consume`) from both the success path (after WS approval resolves to `outcome=approved`) and the exception path (`_safe_cancel_active_execution`). Operator-initiated `/cancel` on an approval envelope ALSO consumes the row in spawned Step 4e. Audit emits distinguish operator-cancel from SDK-consume via distinct `matched_rule` strings. Behaviour change: outbound HTTP call from the SDK success branch (`check_workflow_budget` after WS approval). Wire-format unchanged (additive). SDK_MIN_VERSION unchanged.

### Added

- **`POST /api/v1/approvals/{approval_id}/consume`** (backend 2.8.0+). Sibling to `/api/v1/cancel`. Structurally distinct from `consume_approved`: the SQL omits `execution_id` binding per ADR-046 (no cached-replay arm race window), carries `organization_id` filter (C2 closure), and optionally filters by `api_key_id` (P1-A cross-key replay defense). Three response shapes: `consumed` (success), `already_consumed` (idempotent replay), `not_approved` (PENDING/DENIED/EXPIRED — idempotent no-op). All three return 200. See `backend/src/proxy/http/approvals.rs::consume_approval_handler` and ADR-047.

- **`Runtime.consume_approval(approval_id, execution_id=None)`** — best-effort POST to the new endpoint. Catches all exceptions and surfaces them at `logger.debug` so a network blip does NOT block the success path or the exception path. Returns `{"status": "error", "approval_id": ...}` on failure (so callers can branch if they care).

- **`Runtime.lookup_pending_approval_id_for_execution(execution_id)`** — read-only accessor for the reverse-index `execution_id → approval_id` populated by `check_workflow_budget` on the WS-approval success branch. RLock-guarded. Used by `_safe_cancel_active_execution` to close orphan grants on the exception path.

- **`Runtime._mark_approval_resolved_for_execution(execution_id, approval_id)`** — internal writer for the same reverse index. RLock-guarded. Invoked from `check_workflow_budget` after `outcome == "approved"`.

### Changed

- **`check_workflow_budget` auto-calls `consume_approval`** on the `outcome=approved` branch after WS approval resolves (`src/nullrun/runtime.py`). Best-effort: the helper catches all exceptions and surfaces them at `logger.debug`, so a network blip here does NOT block the success path. The `approval_expiry_sweeper` at `backend/src/workers/approval_expiry.rs` will close any rows that slip through within ~5 min, but for `mode="inline"` non-sensitive tools (the dominant class), the success path now closes the row before the operator sees it on the dashboard.

- **`_safe_cancel_active_execution` ALSO calls `consume_approval`** after `cancel_execution` on the exception path (`src/nullrun/decorators.py`). The reverse-index lookup is RLock-guarded; if the SDK crashed before reaching the WS-approval branch the lookup returns `None` and this is a no-op. Same best-effort posture as `cancel_execution` itself — never masks the original exception.

- **`/cancel` mode-gated Step 4e** consumes the approval row (backend, spawned task). Audit emit uses `matched_rule="lifecycle.consume_approved_via_cancel"` + `consume_reason="operator_cancel"`, distinct from the SDK-side `lifecycle.consume_approved_via_sdk` + `sdk_consume_endpoint`. The two `matched_rule` strings give operators forensic distinction between operator-cancel and SDK-side auto-consume on the audit table.

### Why this is needed

Production had 48 `approvals` rows in `status='APPROVED' + consumed_at IS NULL + expires_at < NOW()` — the dashboard's "abandoned grants" view (per ADR-045 §10.1 retraction). Root cause: `consume_approved` SQL at `backend/src/proxy/infra/db.rs:1715` was only reachable from `/execute` orchestrator Step 6, but `mode="inline"` tools bypass `/execute` entirely. The same gap fired on SDK crashes between WS approval push and body execution. Recent SDK releases (v0.16.5 cancel-on-exception, v0.16.8 NR-A015, v0.17.0/0.17.1) closed the budget reservation leak and the sensitive-tool wire-shape gap but NOT this structural orphan. `_safe_cancel_active_execution` (added in v0.16.5) calls `POST /api/v1/cancel`, which only releases the B-10 pending counter + DELs the Redis reservation key — it does NOT touch the `approvals` row. The `approval_expiry_sweeper` at `backend/src/workers/approval_expiry.rs` is PENDING-only by design (ADR-045 §10.1 retraction locked this in) and never transitions APPROVED rows.

The orphan class shrinks from "every inline-mode approval" to "transport blip during success path". The 48 historical rows will close on the sweeper's next pass; no data migration needed. New inline-mode rows close on the SDK success path; new cancel-path rows close on the operator-cancel path. The remaining failure surface is "SDK crashes between WS approval resolve and `consume_approval` HTTP landing", which the sweeper still handles within ~5 min.

### Verification

- `ruff check src tests` — all checks passed.
- `mypy src/nullrun` — success: no issues found.
- `pytest -q` — full SDK suite green; 6 new tests added in `tests/test_v3_wire_contract.py::TestConsumeApprovalEndpoint`, `TestCheckWorkflowBudgetConsumeOnApproved`, `TestSafeCancelCallsConsumeApproval`.
- Wire-format smoke: `pytest -q tests/test_v3_wire_contract.py -k "consume_approval or safe_cancel or protocol_header"`.

### Why two `matched_rule` strings

Operators investigating the `audit_events` table for an "approved → consumed" transition need to know whether the consume came from the SDK auto-consume (meaning the agent successfully executed the approved tool) or from the operator's `/cancel` (meaning the operator cancelled the approval envelope). Conflating them in a single `matched_rule` string would lose this distinction, so we emit two:

- `lifecycle.consume_approved_via_sdk` + `consume_reason="sdk_consume_endpoint"` — SDK side, success path
- `lifecycle.consume_approved_via_cancel` + `consume_reason="operator_cancel"` — operator-initiated cancel

Both share the same `audit_kind` (`approval.consumed`) and `status` (`success`), so the `approvals` lifecycle view aggregates them correctly while the audit deep-dive filters on `matched_rule` + `consume_reason`.

## [0.17.1] - 2026-09-15

Patch release — two correctness themes on the 0.17.0 baseline: (1) **`/check` mints a fresh `operation_id` per call** (the previous behaviour — reuse the first call's op_id within the same scope — collided with the backend's `IDEM-01` 11-field semantic-hash dedup whenever a second `/check` had a different `tools` / `model` / `input`, surfacing as a 409 `IDEMPOTENCY_KEY_MISMATCH` and the misleading SDK error `NR-B004` "You've reached the usage limit for this conversation"), and (2) **`_V3_ERROR_CODE_MAP` closes the wire-code gap from backend `fix-wave-2`** (the two NEW wire codes — `INVALID_JSON` (400, `invalid_json` slug, `JsonSyntaxError`) and `INVALID_FIELD` (422, `validation_error` slug, `JsonDataError`) — now round-trip through `NullRunBackendError` instead of falling through to the generic transport fallback at `transport.py:2961`). No behaviour change for code that already handles `NullRunBackendError`; cookbook recipes that branch on `error_code` now retain diagnostic class for parse-level vs schema-level rejections. Wire-format unchanged. SDK_MIN_VERSION unchanged.
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,35 @@ silently dropping the query.

---

## Closing orphan grants (v0.18+)

An approval row that lands at `status='APPROVED'` but never flips to
`CONSUMED` is an "orphan grant" — the operator sees it on the dashboard
forever (or until the sweeper runs). Two paths close the orphan:

1. **Success path** — when the WebSocket approval push resolves
`outcome=approved`, the SDK auto-calls
`POST /api/v1/approvals/{approval_id}/consume` so the row flips to
`CONSUMED` *before* the function body runs. Best-effort: a network
blip is logged at `DEBUG` and the success path is **not** blocked.
2. **Exception path** — `@protect`'s
`_safe_cancel_active_execution` calls
`cancel_execution` *and* `consume_approval` (in that order) when an
exception fires after `/gate` succeeded. The reverse-index lookup
`execution_id → approval_id` is populated by the WS push handler, so
if the SDK never reached the WS-approval branch the lookup returns
`None` and `consume_approval` is a no-op.

The new endpoint is **structurally distinct** from the orchestrator's
`consume_approved` SQL (no `execution_id` binding per ADR-046, so it
does not participate in the cached-replay arm race window) and carries
`organization_id` for C2 closure. Idempotent: replay returns
`already_consumed`; PENDING/DENIED/EXPIRED rows return `not_approved`,
both with HTTP 200. See `src/nullrun/runtime.py::consume_approval`
and `src/nullrun/transport.py::consume_approval`.

---

## Examples

Runnable, copy-pastable examples live in a separate repo so you can adapt without cloning the SDK source:
Expand All @@ -295,8 +324,8 @@ Runnable, copy-pastable examples live in a separate repo so you can adapt withou
| **v0.14.x** | ✅ alpha | Wire protocol v3.31, server-minted execution IDs, MCP, anti-OOM streaming cap |
| **v0.15.x** | ✅ alpha | ADR-009 governance audit surface, typed `runtime.audit.*`, capability probes for `/audit-log/verify`, fail-OPEN observability closure |
| **v0.16.x** | ✅ alpha | Phase-1+ `action_digest` on `/gate`, `/execute` `tools` propagation, transient-5xx retry on gate (NR-006), error-code parity (NR-007, 41→56 entries) |
| **v0.17.x** (current) | ✅ alpha | Chain-setter Token discipline, `_GATE_CACHE` staleness closure, lazy-export repair, circuit-breaker lock unification (sync+async), op_id mint-fresh (DEF-OPID-REUSE-HASH-MISMATCH), error-code map closure (DEF-SDKT-004) |
| **v0.18** | 📋 planned | OpenTelemetry exporter, Redis-backed offline queue, hardened init contract |
| **v0.17.x** | ✅ alpha | Chain-setter Token discipline, `_GATE_CACHE` staleness closure, lazy-export repair, circuit-breaker lock unification (sync+async), op_id mint-fresh (DEF-OPID-REUSE-HASH-MISMATCH), error-code map closure (DEF-SDKT-004) |
| **v0.18.x** (current) | ✅ alpha | Close-orphan fix (ADR-047): SDK auto-calls `POST /api/v1/approvals/{id}/consume` after WS approval resolves to `outcome=approved` and on the `@protect` exception path. Closes the structural orphan where `mode="inline"` tools left approval rows at `status=APPROVED` past `expires_at`. |
| **v1.0** | 🎯 beta target | Stable wire contract, full async support, type-safe decisions |

[Full roadmap & RFCs →](https://nullrun.io/roadmap)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "nullrun"
# Full release history lives in CHANGELOG.md; only the current version
# is pinned here.
version = "0.17.1"
version = "0.18.0"
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. The headline is the canonical §1 statement
# from positioning.md — "runtime decision layer for tool-using AI agents"
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.17.1"
__version__ = "0.18.0"
__platform_version__ = "1.0.0"
18 changes: 18 additions & 0 deletions src/nullrun/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ def _safe_cancel_active_execution(reason: str | None = None) -> None:
- Synchronous, blocking HTTP. Caller is the @protect context
manager; HTTP I/O is the same channel used by
check_workflow_budget, so it does not change timeout posture.
- CLOSE-ORPHAN (ADR-047, 2026-09-21): after cancel_execution,
if a pending approval_id was captured for this execution_id,
ALSO call consume_approval so the row flips to CONSUMED.
Best-effort: a network blip here does NOT mask the cancel.
"""
try:
execution_id = get_server_minted_execution_id()
Expand All @@ -417,6 +421,20 @@ def _safe_cancel_active_execution(reason: str | None = None) -> None:
except Exception:
# An orphan from cancellation failure is preferred over
# masking the original exception with a transport error.
pass
# CLOSE-ORPHAN: also consume the approval row if one was captured.
# The reverse index (execution_id → approval_id) is RLock-guarded
# in Runtime and populated by check_workflow_budget at the
# outcome=approved branch. If the SDK crashed before reaching that
# branch, the lookup returns None and this is a no-op.
try:
approval_id = runtime.lookup_pending_approval_id_for_execution(
execution_id
)
if approval_id:
runtime.consume_approval(approval_id, execution_id=execution_id)
except Exception:
# Same posture as the cancel: best-effort, never mask.
return


Expand Down
Loading
Loading