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.
Files changed (31) hide show
  1. {needlepath-0.2.3 → needlepath-0.2.4}/CHANGELOG.md +29 -0
  2. {needlepath-0.2.3 → needlepath-0.2.4}/PKG-INFO +1 -1
  3. {needlepath-0.2.3 → needlepath-0.2.4}/pyproject.toml +1 -1
  4. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/client.py +9 -0
  5. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/integrations/system_one.py +13 -1
  6. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/result.py +55 -0
  7. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/version.py +1 -1
  8. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_guardrails.py +110 -0
  9. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_system_one_adapter.py +25 -0
  10. {needlepath-0.2.3 → needlepath-0.2.4}/.gitignore +0 -0
  11. {needlepath-0.2.3 → needlepath-0.2.4}/LICENSE +0 -0
  12. {needlepath-0.2.3 → needlepath-0.2.4}/README.md +0 -0
  13. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/__init__.py +0 -0
  14. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/errors.py +0 -0
  15. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/features.py +0 -0
  16. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/integrations/__init__.py +0 -0
  17. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/models.py +0 -0
  18. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/py.typed +0 -0
  19. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/testing.py +0 -0
  20. {needlepath-0.2.3 → needlepath-0.2.4}/src/needlepath/transport.py +0 -0
  21. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_async_parity.py +0 -0
  22. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_config_and_guards.py +0 -0
  23. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_fail_open.py +0 -0
  24. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_live_endpoint.py +0 -0
  25. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_no_secret_leak.py +0 -0
  26. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_r4_contract.py +0 -0
  27. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_retries.py +0 -0
  28. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_shadow_mode.py +0 -0
  29. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_simple_shape.py +0 -0
  30. {needlepath-0.2.3 → needlepath-0.2.4}/tests/test_urllib_transport.py +0 -0
  31. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: needlepath
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Framework-free client for the Needlepath hosted context-selection service.
5
5
  Project-URL: Homepage, https://nextmoca.com
6
6
  Author: Next Moca Global, Inc.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "Framework-free client for the Needlepath hosted context-selection service."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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.
@@ -5,6 +5,6 @@ is correct when the package is run from a source checkout, which is how every
5
5
  one of our own integration tests runs it.
6
6
  """
7
7
 
8
- __version__ = "0.2.3"
8
+ __version__ = "0.2.4"
9
9
 
10
10
  __all__ = ["__version__"]
@@ -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