needlepath 0.2.3__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {needlepath-0.2.3 → needlepath-0.2.4}/CHANGELOG.md +29 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/PKG-INFO +1 -1
- {needlepath-0.2.3 → needlepath-0.2.4}/pyproject.toml +1 -1
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/client.py +9 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/integrations/system_one.py +13 -1
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/result.py +55 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/version.py +1 -1
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_guardrails.py +110 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_system_one_adapter.py +25 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/.gitignore +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/LICENSE +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/README.md +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/__init__.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/errors.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/features.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/integrations/__init__.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/models.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/py.typed +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/testing.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/transport.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_async_parity.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_config_and_guards.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_fail_open.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_live_endpoint.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_no_secret_leak.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_r4_contract.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_retries.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_shadow_mode.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_simple_shape.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_urllib_transport.py +0 -0
- {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_wire_compatibility.py +0 -0
|
@@ -5,6 +5,35 @@ All notable changes to the `needlepath` Python package are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.4] - 2026-11-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`SelectionResult.protected` and `SelectionResult.applying()`.** The two
|
|
13
|
+
rules an adapter must follow once workspace protection is in force, decided
|
|
14
|
+
in the core so no adapter re-derives them. `protected` is `True` when the
|
|
15
|
+
response's `governance` forbids raw-context fallback (a blocked verdict
|
|
16
|
+
included): an adapter's own shadow flag must exempt exactly these results
|
|
17
|
+
(`shadow and not protected`), mirroring `_finish`, which keeps a protected
|
|
18
|
+
delivery applied in a shadow run. `applying()` is a context for the install
|
|
19
|
+
step: an unexpected failure inside it is re-raised as a sanitized
|
|
20
|
+
`NeedlepathGuardrailError` when the result is protected and passes through
|
|
21
|
+
to the adapter's ordinary fail-open handler otherwise.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **A blocked result is canonicalized to an empty delivery.** `_finish` kept
|
|
26
|
+
the wire's `rendered_context` and `selected` on a `Reason.BLOCKED` result,
|
|
27
|
+
so a type-valid body that contradicted its own verdict (a render, retained
|
|
28
|
+
records with excerpts) could be forwarded by a caller reading those fields
|
|
29
|
+
or by the System One adapter. A blocked result now always exposes an empty
|
|
30
|
+
render and no selected records; the body is not rejected (the verdict is
|
|
31
|
+
the engine's word, the render is simply not delivered), and the receipt
|
|
32
|
+
keeps what the wire reported (`outcome`, `governance`, `usage`,
|
|
33
|
+
`security_action`, request id, token and record counts).
|
|
34
|
+
`to_system_one_state` mirrors the rule at the point of use: a blocked
|
|
35
|
+
result or response, however assembled, contributes no state.
|
|
36
|
+
|
|
8
37
|
## [0.2.3] - 2026-11-04
|
|
9
38
|
|
|
10
39
|
### Fixed
|
|
@@ -19,6 +19,7 @@ The three binding rules of §8.2 are implemented here and nowhere else:
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
|
+
import dataclasses
|
|
22
23
|
import json
|
|
23
24
|
import os
|
|
24
25
|
import random
|
|
@@ -1375,6 +1376,14 @@ def _finish(
|
|
|
1375
1376
|
and raw_context_fallback_allowed(response)
|
|
1376
1377
|
):
|
|
1377
1378
|
reason = Reason.SHADOW
|
|
1379
|
+
if reason == Reason.BLOCKED:
|
|
1380
|
+
# Canonical empty delivery. The verdict is the engine's word; a
|
|
1381
|
+
# type-valid body that contradicts it — a render, retained records
|
|
1382
|
+
# with excerpts — is not rejected, it is simply not delivered, so a
|
|
1383
|
+
# caller reading ``rendered_context`` or ``selected`` off a blocked
|
|
1384
|
+
# result cannot forward bytes the policy withheld. The receipt keeps
|
|
1385
|
+
# what the wire reported: outcome, governance, usage, the counts.
|
|
1386
|
+
response = dataclasses.replace(response, rendered_context="", selected=[])
|
|
1378
1387
|
return SelectionResult(
|
|
1379
1388
|
applied=(reason in _APPLIED_REASONS),
|
|
1380
1389
|
reason=reason,
|
|
@@ -43,7 +43,7 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
|
|
|
43
43
|
|
|
44
44
|
from ..errors import NeedlepathConfigError
|
|
45
45
|
from ..models import ContextRecord, ContextResponse, SelectedRecord
|
|
46
|
-
from ..result import SelectionResult
|
|
46
|
+
from ..result import Reason, SelectionResult
|
|
47
47
|
|
|
48
48
|
#: The keys of one state entry, in order. The receiving service reads them
|
|
49
49
|
#: positionally in its own documentation, so the order is part of the shape.
|
|
@@ -138,14 +138,26 @@ def _entries(selection: Selection) -> List[_StateEntry]:
|
|
|
138
138
|
"response of a result that did not apply anyway, pass `result.response`."
|
|
139
139
|
)
|
|
140
140
|
response = selection.response
|
|
141
|
+
if selection.reason == Reason.BLOCKED or _blocked(response):
|
|
142
|
+
# The client already canonicalizes a blocked result to an empty
|
|
143
|
+
# delivery; the same rule holds at the point of use, so a result
|
|
144
|
+
# assembled by hand with the verdict and contradictory records
|
|
145
|
+
# still delivers nothing. Not an error: the verdict applied.
|
|
146
|
+
return []
|
|
141
147
|
records: Sequence[Any] = response.selected if response is not None else []
|
|
142
148
|
elif isinstance(selection, ContextResponse):
|
|
149
|
+
if _blocked(selection):
|
|
150
|
+
return []
|
|
143
151
|
records = selection.selected
|
|
144
152
|
else:
|
|
145
153
|
records = list(selection)
|
|
146
154
|
return [_entry(record) for record in records]
|
|
147
155
|
|
|
148
156
|
|
|
157
|
+
def _blocked(response: Optional[ContextResponse]) -> bool:
|
|
158
|
+
return response is not None and response.outcome == "blocked"
|
|
159
|
+
|
|
160
|
+
|
|
149
161
|
def _entry(record: Union[SelectedRecord, ContextRecord]) -> _StateEntry:
|
|
150
162
|
"""One record, four fields, nothing computed.
|
|
151
163
|
|
|
@@ -193,6 +193,43 @@ class SelectionResult:
|
|
|
193
193
|
def raw_context_fallback_allowed(self) -> bool:
|
|
194
194
|
return self.response is None or raw_context_fallback_allowed(self.response)
|
|
195
195
|
|
|
196
|
+
@property
|
|
197
|
+
def protected(self) -> bool:
|
|
198
|
+
"""Workspace protection is in force on this result.
|
|
199
|
+
|
|
200
|
+
``True`` when the response is present and its ``governance`` forbids
|
|
201
|
+
raw-context fallback — a blocked verdict included. Two rules follow
|
|
202
|
+
for an adapter, and this property is where both are decided so no
|
|
203
|
+
adapter re-implements them:
|
|
204
|
+
|
|
205
|
+
* Its own shadow flag never restores an original under protection.
|
|
206
|
+
The core client already keeps such a delivery applied in a shadow
|
|
207
|
+
run (see ``_finish``); an adapter's belt-and-braces shadow guard
|
|
208
|
+
must exempt exactly the same results: ``shadow and not protected``.
|
|
209
|
+
* A failure to install the result must stop the call rather than
|
|
210
|
+
fail open — see :meth:`applying`.
|
|
211
|
+
"""
|
|
212
|
+
return not self.raw_context_fallback_allowed
|
|
213
|
+
|
|
214
|
+
def applying(self) -> _ProtectedApply:
|
|
215
|
+
"""A context in which an adapter installs this result.
|
|
216
|
+
|
|
217
|
+
::
|
|
218
|
+
|
|
219
|
+
with selection.applying():
|
|
220
|
+
return self._finish(result, plan, selection)
|
|
221
|
+
|
|
222
|
+
Inside it, an unexpected failure — a copy that raises, an annotation
|
|
223
|
+
that trips on a framework shape, an index slip — is re-raised as a
|
|
224
|
+
sanitized :class:`NeedlepathGuardrailError` when the result is
|
|
225
|
+
:attr:`protected`, and passes through untouched otherwise, so the
|
|
226
|
+
adapter's ordinary fail-open handler sees it. The fail-open rule
|
|
227
|
+
covers an adapter's own defects; once workspace protection is in
|
|
228
|
+
force the original may not come back, so a defect there is a stop.
|
|
229
|
+
A ``NeedlepathGuardrailError`` raised inside passes through as is.
|
|
230
|
+
"""
|
|
231
|
+
return _ProtectedApply(self)
|
|
232
|
+
|
|
196
233
|
@property
|
|
197
234
|
def passthrough(self) -> bool:
|
|
198
235
|
"""``True`` when the caller must use its original context unmodified.
|
|
@@ -360,6 +397,24 @@ class SelectionResult:
|
|
|
360
397
|
)
|
|
361
398
|
|
|
362
399
|
|
|
400
|
+
class _ProtectedApply:
|
|
401
|
+
"""See :meth:`SelectionResult.applying`."""
|
|
402
|
+
|
|
403
|
+
__slots__ = ("_result",)
|
|
404
|
+
|
|
405
|
+
def __init__(self, result: SelectionResult) -> None:
|
|
406
|
+
self._result = result
|
|
407
|
+
|
|
408
|
+
def __enter__(self) -> None:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
412
|
+
if exc is None or isinstance(exc, NeedlepathGuardrailError) or not self._result.protected:
|
|
413
|
+
return False
|
|
414
|
+
# Sanitized: the original exception's message may carry record text.
|
|
415
|
+
raise NeedlepathGuardrailError() from None
|
|
416
|
+
|
|
417
|
+
|
|
363
418
|
@dataclass
|
|
364
419
|
class SelectionStats:
|
|
365
420
|
"""Counters an adapter can expose for a shadow report or a health check.
|
|
@@ -324,3 +324,113 @@ async def test_protected_response_remains_fail_closed_if_result_assembly_fails(m
|
|
|
324
324
|
select(client([protected()]))
|
|
325
325
|
with pytest.raises(Exception, match="Workspace guardrails"):
|
|
326
326
|
await select(client([protected()], asynchronous=True))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# The two adapter rules a protected result carries, decided in one place.
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@pytest.mark.parametrize(
|
|
333
|
+
("script", "expected"),
|
|
334
|
+
[
|
|
335
|
+
([protected()], True),
|
|
336
|
+
(
|
|
337
|
+
[protected(outcome="blocked", selected=[], records_selected=0, rendered_context="")],
|
|
338
|
+
True,
|
|
339
|
+
),
|
|
340
|
+
(
|
|
341
|
+
[
|
|
342
|
+
protected(
|
|
343
|
+
governance={
|
|
344
|
+
"enabled": True,
|
|
345
|
+
"policies_applied": 0,
|
|
346
|
+
"raw_context_fallback_allowed": True,
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
],
|
|
350
|
+
False,
|
|
351
|
+
),
|
|
352
|
+
([NeedlepathTimeoutError("down")], False),
|
|
353
|
+
],
|
|
354
|
+
)
|
|
355
|
+
def test_protected_says_whether_workspace_protection_is_in_force(script, expected):
|
|
356
|
+
result = select(client(script))
|
|
357
|
+
assert result.protected is expected
|
|
358
|
+
assert result.protected is (not result.raw_context_fallback_allowed)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_a_protected_result_under_shadow_stays_applied_and_protected():
|
|
362
|
+
result = select(client([protected()], shadow=True))
|
|
363
|
+
assert result.applied is True and result.shadow is True
|
|
364
|
+
assert result.protected is True
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_applying_turns_a_failure_into_a_sanitized_stop_only_under_protection():
|
|
368
|
+
protected_result = select(client([protected()]))
|
|
369
|
+
with (
|
|
370
|
+
pytest.raises(Exception, match="Workspace guardrails") as caught,
|
|
371
|
+
protected_result.applying(),
|
|
372
|
+
):
|
|
373
|
+
raise RuntimeError("PRIVATE RAW leaked into an exception message")
|
|
374
|
+
assert type(caught.value).__name__ == "NeedlepathGuardrailError"
|
|
375
|
+
assert "PRIVATE RAW" not in str(caught.value)
|
|
376
|
+
assert caught.value.__cause__ is None
|
|
377
|
+
|
|
378
|
+
ordinary = select(client([NeedlepathTimeoutError("down")]))
|
|
379
|
+
with pytest.raises(RuntimeError), ordinary.applying():
|
|
380
|
+
raise RuntimeError("an adapter defect, fail open")
|
|
381
|
+
|
|
382
|
+
with protected_result.applying():
|
|
383
|
+
pass # no failure, nothing raised
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_applying_passes_a_guardrail_error_through_unchanged():
|
|
387
|
+
result = select(client([protected()]))
|
|
388
|
+
with pytest.raises(Exception, match="Workspace guardrails") as caught, result.applying():
|
|
389
|
+
result.declined("no_saving")
|
|
390
|
+
assert caught.value.__cause__ is None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# A blocked result is canonicalized to an empty delivery. The verdict is the
|
|
394
|
+
# engine's word; a type-valid body that contradicts it (a render, retained
|
|
395
|
+
# records) is not rejected, it is simply not delivered. The receipt keeps what
|
|
396
|
+
# the wire reported.
|
|
397
|
+
|
|
398
|
+
CONTRADICTORY_BLOCKED = dict(
|
|
399
|
+
outcome="blocked",
|
|
400
|
+
rendered_context="LEAKED RENDER of PRIVATE RAW",
|
|
401
|
+
records_selected=1,
|
|
402
|
+
selected=[{"record_id": "r1", "excerpt": "LEAKED EXCERPT of PRIVATE RAW"}],
|
|
403
|
+
tokens_before=1000,
|
|
404
|
+
tokens_after=250,
|
|
405
|
+
usage={"meter_version": "npm-2026-08-r1", "outcome": "pass_through"},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
410
|
+
@pytest.mark.asyncio
|
|
411
|
+
async def test_a_blocked_result_exposes_an_empty_render_and_no_records(asynchronous):
|
|
412
|
+
c = client([protected(**CONTRADICTORY_BLOCKED)], asynchronous=asynchronous)
|
|
413
|
+
result = await select(c) if asynchronous else select(c)
|
|
414
|
+
assert result.applied is True and result.reason == Reason.BLOCKED
|
|
415
|
+
assert result.rendered_context == ""
|
|
416
|
+
assert result.selected_ids == [] and result.excerpts == {}
|
|
417
|
+
assert result.response.rendered_context == ""
|
|
418
|
+
assert result.response.selected == []
|
|
419
|
+
assert "LEAKED" not in repr(result.response.selected) + result.rendered_context
|
|
420
|
+
# The receipt keeps what the wire reported.
|
|
421
|
+
meta = result.metadata(prefix="")
|
|
422
|
+
assert meta["outcome"] == "blocked"
|
|
423
|
+
assert meta["governance"]["raw_context_fallback_allowed"] is False
|
|
424
|
+
assert meta["usage"]["outcome"] == "pass_through"
|
|
425
|
+
assert meta["tokens_before"] == 1000 and meta["tokens_after"] == 250
|
|
426
|
+
assert meta["records_selected"] == 1
|
|
427
|
+
assert result.request_id == meta["request_id"]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_system_one_gets_no_state_from_a_blocked_result():
|
|
431
|
+
from needlepath.integrations.system_one import to_system_one_state
|
|
432
|
+
|
|
433
|
+
result = select(client([protected(**CONTRADICTORY_BLOCKED)]))
|
|
434
|
+
out = to_system_one_state(result, questions=["is this a refund?"])
|
|
435
|
+
assert out["state"] == []
|
|
436
|
+
assert "LEAKED" not in repr(out)
|
|
@@ -335,3 +335,28 @@ def test_a_bare_response_can_issue_its_own_receipt():
|
|
|
335
335
|
receipt = selection_receipt(resp, prefix="")
|
|
336
336
|
assert receipt["request_id"] == "np-0001"
|
|
337
337
|
assert receipt["selection_id"] == "sel-9"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --------------------------------------------------------------------------- #
|
|
341
|
+
# A blocked verdict delivers nothing, at the point of use too
|
|
342
|
+
# --------------------------------------------------------------------------- #
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_a_hand_built_blocked_result_contributes_no_state():
|
|
346
|
+
"""The client canonicalizes a blocked result to an empty delivery; the
|
|
347
|
+
adapter mirrors that rule at the point of use, so a result or response
|
|
348
|
+
assembled by hand with the verdict and contradictory records still
|
|
349
|
+
delivers nothing. Not an error: the verdict applied, and the state is
|
|
350
|
+
what it says — empty."""
|
|
351
|
+
resp = response(selected("r1", excerpt="LEAKED"), outcome="blocked")
|
|
352
|
+
result = SelectionResult(
|
|
353
|
+
applied=True,
|
|
354
|
+
reason=Reason.BLOCKED,
|
|
355
|
+
request_id=resp.request_id,
|
|
356
|
+
operating_point="np-2026-08-r4",
|
|
357
|
+
response=resp,
|
|
358
|
+
)
|
|
359
|
+
assert to_system_one_state(result, questions=QUESTIONS)["state"] == []
|
|
360
|
+
assert to_system_one_state(resp, questions=QUESTIONS)["state"] == []
|
|
361
|
+
assert to_system_one_state(result, questions=QUESTIONS, mode=MODE_RENDERED)["state"] == ""
|
|
362
|
+
assert selection_receipt(result)["needlepath.outcome"] == "blocked"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|