needlepath 0.2.1__tar.gz → 0.2.2__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 (30) hide show
  1. {needlepath-0.2.1 → needlepath-0.2.2}/CHANGELOG.md +27 -0
  2. {needlepath-0.2.1 → needlepath-0.2.2}/PKG-INFO +45 -1
  3. {needlepath-0.2.1 → needlepath-0.2.2}/README.md +44 -0
  4. {needlepath-0.2.1 → needlepath-0.2.2}/pyproject.toml +2 -1
  5. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/__init__.py +2 -0
  6. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/client.py +37 -7
  7. needlepath-0.2.2/src/needlepath/integrations/__init__.py +30 -0
  8. needlepath-0.2.2/src/needlepath/integrations/system_one.py +210 -0
  9. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/models.py +24 -0
  10. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/result.py +7 -1
  11. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/version.py +1 -1
  12. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_async_parity.py +3 -1
  13. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_fail_open.py +68 -0
  14. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_r4_contract.py +20 -0
  15. needlepath-0.2.2/tests/test_system_one_adapter.py +337 -0
  16. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_wire_compatibility.py +12 -1
  17. {needlepath-0.2.1 → needlepath-0.2.2}/.gitignore +0 -0
  18. {needlepath-0.2.1 → needlepath-0.2.2}/LICENSE +0 -0
  19. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/errors.py +0 -0
  20. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/features.py +0 -0
  21. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/py.typed +0 -0
  22. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/testing.py +0 -0
  23. {needlepath-0.2.1 → needlepath-0.2.2}/src/needlepath/transport.py +0 -0
  24. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_config_and_guards.py +0 -0
  25. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_live_endpoint.py +0 -0
  26. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_no_secret_leak.py +0 -0
  27. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_retries.py +0 -0
  28. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_shadow_mode.py +0 -0
  29. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_simple_shape.py +0 -0
  30. {needlepath-0.2.1 → needlepath-0.2.2}/tests/test_urllib_transport.py +0 -0
@@ -1,3 +1,12 @@
1
+ ## 0.2.2
2
+
3
+ ### Changed
4
+
5
+ - First published build carrying the `sanitized_fallback` and `blocked`
6
+ security-screening outcomes, `security_mode` on requests and the
7
+ `security_action` receipt field (see the API release notes). 0.2.1 predates
8
+ them and classifies a screened fallback as `selection_unsafe`.
9
+
1
10
  # Changelog
2
11
 
3
12
  All notable changes to the `needlepath` Python package are documented here.
@@ -5,6 +14,24 @@ All notable changes to the `needlepath` Python package are documented here.
5
14
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
15
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
16
 
17
+ ## [Unreleased]
18
+
19
+ ### Added
20
+
21
+ - **System One state adapter**: `needlepath.integrations.system_one` reshapes a
22
+ selection into the `{state, questions}` request body that small classifier
23
+ models (TypeSafe's Jev, Laya) accept. `to_system_one_state()` carries every
24
+ record in `response.selected`, in the order returned, with the text
25
+ unmodified; `mode="rendered"` returns `state` as one string instead of an
26
+ array. `questions` travels in the shape it was passed, mapping or sequence,
27
+ so a `{id: question}` mapping never arrives as a list of its keys.
28
+ A `SelectionResult` is accepted only when it `applied`; a passthrough raises
29
+ `NeedlepathConfigError` naming the reason, since its response can carry a
30
+ selection the client declined to use. `selection_receipt()` returns
31
+ `request_id`, `selection_id`, `policy_version`, `tokens_saved` and `outcome`
32
+ as a sidecar dict, prefixed the way `SelectionResult.metadata()` prefixes its
33
+ keys.
34
+
8
35
  ## [0.2.1] - 2026-09-03
9
36
 
10
37
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: needlepath
3
- Version: 0.2.1
3
+ Version: 0.2.2
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.
@@ -258,6 +258,50 @@ carry fragments of your input in an exception message — the Lambda deliberatel
258
258
  declines to log it for that reason — so only its *type* appears here. The full
259
259
  string stays on `result.response.selection_error`.
260
260
 
261
+ ## System One models (TypeSafe Jev, Laya)
262
+
263
+ Small classifier models that take `{state, questions}` and answer the questions
264
+ against the state. `needlepath.integrations.system_one` reshapes a selection
265
+ into that request body:
266
+
267
+ ```python
268
+ from needlepath.integrations.system_one import selection_receipt, to_system_one_state
269
+
270
+ result = client.select(records=records, task=task, max_context_tokens=4000)
271
+ payload = to_system_one_state(
272
+ # Fail open with your own records: a result that did not apply is refused.
273
+ result if result.applied else records,
274
+ questions={"refund": Noul(instructions="Is this a refund request?")},
275
+ )
276
+ # Sidecar metadata, not part of the body.
277
+ receipt = selection_receipt(result)
278
+ ```
279
+
280
+ `payload["state"]` is a JSON array of `{record_id, title, source, text}`, one
281
+ entry per record in `response.selected`, **in the order the service returned
282
+ them, with the text unmodified**. The adapter is transport only: no threshold on
283
+ `score`, no drop for an empty excerpt, no re-sort, no deduplication, and no
284
+ question you did not pass. `mode="rendered"` returns `state` as a single string
285
+ instead, records joined by blank lines under a one-line `[record_id] title`
286
+ header, for a classifier whose state argument is text.
287
+
288
+ `questions` travels in the shape you pass it. `langchain-typesafe` reads a
289
+ mapping of id to typed question (`Noul`, `Choice`, `Score`); a different
290
+ `{state, questions}` service may read a list. Both are copied and sent
291
+ unchanged, so a mapping never arrives as a list of its keys. A bare string is
292
+ refused rather than iterated into single-character questions.
293
+
294
+ `to_system_one_state` accepts a `SelectionResult` only when it `applied`. A
295
+ result that did not apply is a passthrough — your own records are what has to
296
+ be sent — and its `response` can still carry a selection the client itself
297
+ declined to use, so passing one raises `NeedlepathConfigError` naming the
298
+ reason. To reshape that response anyway, for an audit or a shadow report, pass
299
+ `result.response`.
300
+
301
+ `selection_receipt()` returns `request_id`, `selection_id`, `policy_version`,
302
+ `tokens_saved` and `outcome`, prefixed the way `result.metadata()` prefixes its
303
+ keys. Attribution travels beside the request; record text never travels in it.
304
+
261
305
  ## Testing your integration
262
306
 
263
307
  ```python
@@ -229,6 +229,50 @@ carry fragments of your input in an exception message — the Lambda deliberatel
229
229
  declines to log it for that reason — so only its *type* appears here. The full
230
230
  string stays on `result.response.selection_error`.
231
231
 
232
+ ## System One models (TypeSafe Jev, Laya)
233
+
234
+ Small classifier models that take `{state, questions}` and answer the questions
235
+ against the state. `needlepath.integrations.system_one` reshapes a selection
236
+ into that request body:
237
+
238
+ ```python
239
+ from needlepath.integrations.system_one import selection_receipt, to_system_one_state
240
+
241
+ result = client.select(records=records, task=task, max_context_tokens=4000)
242
+ payload = to_system_one_state(
243
+ # Fail open with your own records: a result that did not apply is refused.
244
+ result if result.applied else records,
245
+ questions={"refund": Noul(instructions="Is this a refund request?")},
246
+ )
247
+ # Sidecar metadata, not part of the body.
248
+ receipt = selection_receipt(result)
249
+ ```
250
+
251
+ `payload["state"]` is a JSON array of `{record_id, title, source, text}`, one
252
+ entry per record in `response.selected`, **in the order the service returned
253
+ them, with the text unmodified**. The adapter is transport only: no threshold on
254
+ `score`, no drop for an empty excerpt, no re-sort, no deduplication, and no
255
+ question you did not pass. `mode="rendered"` returns `state` as a single string
256
+ instead, records joined by blank lines under a one-line `[record_id] title`
257
+ header, for a classifier whose state argument is text.
258
+
259
+ `questions` travels in the shape you pass it. `langchain-typesafe` reads a
260
+ mapping of id to typed question (`Noul`, `Choice`, `Score`); a different
261
+ `{state, questions}` service may read a list. Both are copied and sent
262
+ unchanged, so a mapping never arrives as a list of its keys. A bare string is
263
+ refused rather than iterated into single-character questions.
264
+
265
+ `to_system_one_state` accepts a `SelectionResult` only when it `applied`. A
266
+ result that did not apply is a passthrough — your own records are what has to
267
+ be sent — and its `response` can still carry a selection the client itself
268
+ declined to use, so passing one raises `NeedlepathConfigError` naming the
269
+ reason. To reshape that response anyway, for an audit or a shadow report, pass
270
+ `result.response`.
271
+
272
+ `selection_receipt()` returns `request_id`, `selection_id`, `policy_version`,
273
+ `tokens_saved` and `outcome`, prefixed the way `result.metadata()` prefixes its
274
+ keys. Attribution travels beside the request; record text never travels in it.
275
+
232
276
  ## Testing your integration
233
277
 
234
278
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Framework-free client for the Needlepath hosted context-selection service."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -52,6 +52,7 @@ packages = ["src/needlepath"]
52
52
  [tool.pytest.ini_options]
53
53
  testpaths = ["tests"]
54
54
  asyncio_mode = "auto"
55
+ asyncio_default_fixture_loop_scope = "function"
55
56
  filterwarnings = ["error"]
56
57
 
57
58
  [tool.ruff]
@@ -68,6 +68,7 @@ from .models import (
68
68
  OutcomeResponse,
69
69
  RecordKind,
70
70
  SafetySummary,
71
+ SecurityMode,
71
72
  SelectedRecord,
72
73
  SelectionTrace,
73
74
  SelectionTraceRecord,
@@ -105,6 +106,7 @@ __all__ = [
105
106
  "OutcomeReport",
106
107
  "OutcomeResponse",
107
108
  "RecordKind",
109
+ "SecurityMode",
108
110
  "REQUIRED_RESPONSE_FIELDS",
109
111
  # results
110
112
  "SelectionResult",
@@ -44,6 +44,7 @@ from .models import (
44
44
  ContextResponse,
45
45
  OutcomeReport,
46
46
  OutcomeResponse,
47
+ SecurityMode,
47
48
  TaskSpec,
48
49
  )
49
50
  from .result import Reason, SelectionResult
@@ -149,6 +150,18 @@ def _resolve_api_key(api_key: Optional[str]) -> Optional[str]:
149
150
  return env.strip() if env and env.strip() else None
150
151
 
151
152
 
153
+ def _resolve_security_mode(security_mode: Optional[str]) -> Optional[str]:
154
+ if security_mode is None:
155
+ return None
156
+ value = security_mode.strip().lower()
157
+ if value not in SecurityMode.VALUES:
158
+ allowed = ", ".join(sorted(SecurityMode.VALUES))
159
+ raise NeedlepathConfigError(
160
+ f"security_mode must be one of {allowed}, got {security_mode!r}"
161
+ )
162
+ return value
163
+
164
+
152
165
  def new_request_id() -> str:
153
166
  """A request id inside the published identifier charset.
154
167
 
@@ -192,6 +205,7 @@ def build_request(
192
205
  return_trace: bool = False,
193
206
  selection_strategy: Optional[str] = None,
194
207
  support_contract: Optional[str] = None,
208
+ security_mode: Optional[str] = None,
195
209
  ) -> ContextRequest:
196
210
  """Assemble a :class:`ContextRequest` with the operating point already pinned.
197
211
 
@@ -233,6 +247,7 @@ def build_request(
233
247
  return_trace=return_trace,
234
248
  selection_strategy=selection_strategy,
235
249
  support_contract=support_contract,
250
+ security_mode=_resolve_security_mode(security_mode),
236
251
  )
237
252
 
238
253
 
@@ -343,7 +358,7 @@ def classify(
343
358
  Note what this function does **not** do: it never reads ``gate.reason``.
344
359
  That value is an open enum, and a client that branches on today's prefixes
345
360
  is a client that breaks additively. Every decision here comes from a
346
- structural field, plus one recognised value of the top-level ``outcome``
361
+ structural field, plus recognised values of the top-level ``outcome``
347
362
  (see the note at that branch for why that is not the same mistake).
348
363
  """
349
364
  if response.selection_error:
@@ -352,6 +367,15 @@ def classify(
352
367
  # The engine stood down and is asking for the full context. Applying a
353
368
  # fallback render would at best be a no-op and at worst a lossy one.
354
369
  return Reason.ENGINE_FALLBACK
370
+ if response.outcome == "sanitized_fallback":
371
+ # R6 security finalization replaces a normal raw fail-open fallback
372
+ # with one model-facing packet. It necessarily has no selected records,
373
+ # so the ordinary zero-record guard must not turn it back into the
374
+ # original, unsafe context. The non-empty render is the proof that this
375
+ # is a delivery outcome rather than an unrecognized empty response.
376
+ if not response.rendered_context.strip():
377
+ return Reason.EMPTY_SELECTION
378
+ return Reason.SANITIZED_FALLBACK
355
379
  if response.outcome == "escalated":
356
380
  # The one service-supplied enum this function reads, and it reads
357
381
  # exactly one value from it. This is not the exhaustive matching the
@@ -434,6 +458,7 @@ class _ClientBase:
434
458
  max_retries: int = DEFAULT_MAX_RETRIES,
435
459
  max_request_bytes: int = MAX_REQUEST_BYTES,
436
460
  shadow: bool = False,
461
+ security_mode: Optional[str] = None,
437
462
  user_agent_suffix: Optional[str] = None,
438
463
  ) -> None:
439
464
  self.base_url = _resolve_base_url(base_url)
@@ -451,6 +476,7 @@ class _ClientBase:
451
476
  self.max_retries = int(max_retries)
452
477
  self.max_request_bytes = int(max_request_bytes)
453
478
  self.shadow = bool(shadow)
479
+ self.security_mode = _resolve_security_mode(security_mode)
454
480
  self._user_agent = f"needlepath-python/{__version__}"
455
481
  if user_agent_suffix:
456
482
  self._user_agent += f" {user_agent_suffix}"
@@ -465,7 +491,7 @@ class _ClientBase:
465
491
  f"{type(self).__name__}(base_url={self.base_url!r}, "
466
492
  f"operating_point={self.operating_point!r}, "
467
493
  f"api_key={'set' if self._api_key else 'unset'}, "
468
- f"shadow={self.shadow!r})"
494
+ f"shadow={self.shadow!r}, security_mode={self.security_mode!r})"
469
495
  )
470
496
 
471
497
  def _headers(self) -> Dict[str, str]:
@@ -591,6 +617,8 @@ class NeedlepathClient(_ClientBase):
591
617
  shadow=is_shadow,
592
618
  )
593
619
  try:
620
+ if "security_mode" not in budget_kwargs:
621
+ budget_kwargs["security_mode"] = self.security_mode
594
622
  request = build_request(
595
623
  records=records,
596
624
  text=text,
@@ -891,6 +919,8 @@ class AsyncNeedlepathClient(_ClientBase):
891
919
  shadow=is_shadow,
892
920
  )
893
921
  try:
922
+ if "security_mode" not in budget_kwargs:
923
+ budget_kwargs["security_mode"] = self.security_mode
894
924
  request = build_request(
895
925
  records=records,
896
926
  text=text,
@@ -1169,13 +1199,13 @@ def _finish(
1169
1199
  require_per_record=require_per_record and request.return_per_record,
1170
1200
  known_record_ids=known_ids,
1171
1201
  )
1172
- # Shadow wins over OK and *loses* to every decline: a shadow run must report
1173
- # the same reason a live run would have, or the shadow report predicts an
1174
- # invoice for selections that would never have been applied.
1175
- if reason == Reason.OK and shadow:
1202
+ # Shadow wins over every live delivery and *loses* to every decline: a
1203
+ # shadow run must report the same reason a live run would have, or the
1204
+ # shadow report predicts an invoice for packets that would never be sent.
1205
+ if reason in {Reason.OK, Reason.SANITIZED_FALLBACK} and shadow:
1176
1206
  reason = Reason.SHADOW
1177
1207
  return SelectionResult(
1178
- applied=(reason == Reason.OK),
1208
+ applied=(reason in {Reason.OK, Reason.SANITIZED_FALLBACK}),
1179
1209
  reason=reason,
1180
1210
  # When the request omitted its sent-side id (NEXPE-461), report the
1181
1211
  # server-assigned one so the caller can still correlate with logs.
@@ -0,0 +1,30 @@
1
+ """Request-shape adapters for services that consume a Needlepath selection.
2
+
3
+ Everything in here is transport only. An adapter reshapes what the service
4
+ returned into the request shape another service accepts; it never decides what
5
+ to send. The rule is checked by tests rather than trusted: every record in
6
+ ``response.selected`` travels, in the order it was returned, with its text
7
+ unmodified.
8
+ """
9
+
10
+ from .system_one import (
11
+ MODE_RECORDS,
12
+ MODE_RENDERED,
13
+ MODES,
14
+ STATE_FIELDS,
15
+ Questions,
16
+ Selection,
17
+ selection_receipt,
18
+ to_system_one_state,
19
+ )
20
+
21
+ __all__ = [
22
+ "to_system_one_state",
23
+ "selection_receipt",
24
+ "STATE_FIELDS",
25
+ "MODE_RECORDS",
26
+ "MODE_RENDERED",
27
+ "MODES",
28
+ "Questions",
29
+ "Selection",
30
+ ]
@@ -0,0 +1,210 @@
1
+ """The "System One" request shape: ``{state, questions}``.
2
+
3
+ A family of small classifier models (TypeSafe's Jev, Laya) takes a block of
4
+ state plus a list of questions and answers the questions against the state.
5
+ They are cheap and fast, and they get more accurate as the state they are given
6
+ gets more relevant, which is the whole of the fit with a selection service.
7
+
8
+ This module is the seam, and it is **transport only**. It carries every record
9
+ in ``response.selected``, in the order the service returned them, with the text
10
+ unmodified. It applies no threshold to ``score``, drops nothing for an empty
11
+ excerpt, re-sorts nothing, deduplicates nothing, and never supplies a question
12
+ the caller did not ask, and never converts the shape they asked it in. A
13
+ classifier handed a different set of records than the service selected is a
14
+ classifier whose answer cannot be attributed to the selection, and the tests in
15
+ ``tests/test_system_one_adapter.py`` are written to fail the moment that stops
16
+ being true.
17
+
18
+ A result that did not apply is refused rather than reshaped: it is a
19
+ passthrough, the caller's own records are what has to be sent, and its
20
+ ``response`` can still carry a selection the client declined to use.
21
+
22
+ Receipt fields do not travel inside ``state``. ``selection_receipt`` returns
23
+ them as a sidecar dict for the caller to attach wherever its framework keeps
24
+ metadata, mirroring :meth:`~needlepath.result.SelectionResult.metadata`::
25
+
26
+ from needlepath.integrations.system_one import selection_receipt, to_system_one_state
27
+
28
+ result = client.select(records=records, task=task, max_context_tokens=4000)
29
+ payload = to_system_one_state(result if result.applied else records, questions=questions)
30
+ receipt = selection_receipt(result)
31
+
32
+ ``questions`` is whatever the receiving service reads. ``langchain-typesafe``
33
+ asks for a mapping of id to typed question, ``{"refund": Noul(...)}``; another
34
+ service in this family may take a list of strings. Both travel unchanged.
35
+
36
+ Naming follows the request schema rather than any one vendor: a model that
37
+ accepts ``{state, questions}`` is served by this module whatever it is called.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
43
+
44
+ from ..errors import NeedlepathConfigError
45
+ from ..models import ContextRecord, ContextResponse, SelectedRecord
46
+ from ..result import SelectionResult
47
+
48
+ #: The keys of one state entry, in order. The receiving service reads them
49
+ #: positionally in its own documentation, so the order is part of the shape.
50
+ STATE_FIELDS = ("record_id", "title", "source", "text")
51
+
52
+ #: ``state`` is a JSON array of :data:`STATE_FIELDS` objects.
53
+ MODE_RECORDS = "records"
54
+ #: ``state`` is one string: records joined by blank lines, each under a
55
+ #: one-line header carrying its ``record_id`` and title. For a classifier whose
56
+ #: state argument is text rather than an array.
57
+ MODE_RENDERED = "rendered"
58
+ MODES = (MODE_RECORDS, MODE_RENDERED)
59
+
60
+ #: What can be reshaped: an **applied** result, the response inside one, or a
61
+ #: sequence of records. The sequence form is what a caller reaches for on the
62
+ #: fail-open path, where the original context is what has to be sent.
63
+ Selection = Union[SelectionResult, ContextResponse, Sequence[Any]]
64
+
65
+ #: Whatever the receiving service calls a question. A list for a service that
66
+ #: takes a list; a mapping of id to question object for one that takes a
67
+ #: mapping (``langchain-typesafe`` asks for ``{id: Noul(...)}``). The shape is
68
+ #: the caller's and is copied, never converted: a client that reshaped a
69
+ #: mapping into a list would be inventing the questions it asked.
70
+ Questions = Union[Mapping[str, Any], Sequence[Any]]
71
+
72
+ _StateEntry = Dict[str, Optional[str]]
73
+
74
+
75
+ def to_system_one_state(
76
+ selection: Selection,
77
+ *,
78
+ questions: Questions,
79
+ mode: str = MODE_RECORDS,
80
+ ) -> Dict[str, Any]:
81
+ """Reshape a selection into a ``{state, questions}`` request body.
82
+
83
+ ``questions`` is required and travels verbatim, in order, in the shape the
84
+ caller passed. There is no default: a question this client invented is an
85
+ answer the caller did not ask for and cannot audit.
86
+
87
+ A :class:`~needlepath.result.SelectionResult` is accepted only when it
88
+ ``applied``. A result that did not apply is a passthrough — the caller's
89
+ own records are what has to be sent — and its ``response`` can still carry
90
+ a ``selected`` list the client itself refused to use, so reshaping one
91
+ silently would defeat the fail-open contract rather than honour it.
92
+ """
93
+ if mode not in MODES:
94
+ raise NeedlepathConfigError(f"mode must be one of {MODES}, got {mode!r}")
95
+ entries = _entries(selection)
96
+ state: Union[List[_StateEntry], str] = entries if mode == MODE_RECORDS else _render(entries)
97
+ return {"state": state, "questions": _questions(questions)}
98
+
99
+
100
+ def selection_receipt(
101
+ selection: Union[SelectionResult, ContextResponse],
102
+ *,
103
+ prefix: str = "needlepath",
104
+ ) -> Dict[str, Any]:
105
+ """The attribution fields, as a sidecar dict. **Metadata only.**
106
+
107
+ Nothing derived from record text is in here, for the reason
108
+ :meth:`~needlepath.result.SelectionResult.metadata` gives at length: a
109
+ receipt routinely ends up in a log line, and record text does not belong in
110
+ one. The keys are a subset of that method's, prefixed the same way, so a
111
+ caller already carrying Needlepath metadata does not end up with two
112
+ spellings of the same field.
113
+ """
114
+ response = selection.response if isinstance(selection, SelectionResult) else selection
115
+ out: Dict[str, Any] = {
116
+ "request_id": selection.request_id,
117
+ "selection_id": response.selection_id if response is not None else None,
118
+ "policy_version": response.policy_version if response is not None else None,
119
+ "tokens_saved": response.tokens_saved if response is not None else 0,
120
+ "outcome": response.outcome if response is not None else None,
121
+ }
122
+ if not prefix:
123
+ return out
124
+ return {f"{prefix}.{k}": v for k, v in out.items()}
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Internals
129
+ # --------------------------------------------------------------------------- #
130
+
131
+
132
+ def _entries(selection: Selection) -> List[_StateEntry]:
133
+ if isinstance(selection, SelectionResult):
134
+ if not selection.applied:
135
+ raise NeedlepathConfigError(
136
+ f"the selection did not apply (reason={selection.reason!r}): send your own "
137
+ "records instead, as `result if result.applied else records`. To reshape the "
138
+ "response of a result that did not apply anyway, pass `result.response`."
139
+ )
140
+ response = selection.response
141
+ records: Sequence[Any] = response.selected if response is not None else []
142
+ elif isinstance(selection, ContextResponse):
143
+ records = selection.selected
144
+ else:
145
+ records = list(selection)
146
+ return [_entry(record) for record in records]
147
+
148
+
149
+ def _entry(record: Union[SelectedRecord, ContextRecord]) -> _StateEntry:
150
+ """One record, four fields, nothing computed.
151
+
152
+ A :class:`~needlepath.models.ContextRecord` carries its id on ``id`` and
153
+ its text on ``text``; a :class:`~needlepath.models.SelectedRecord` carries
154
+ them on ``record_id`` and ``excerpt``. That is the entire difference
155
+ between the applied path and the fail-open one.
156
+ """
157
+ if isinstance(record, ContextRecord):
158
+ return {
159
+ "record_id": record.id,
160
+ "title": record.title,
161
+ "source": record.source,
162
+ "text": record.text,
163
+ }
164
+ if isinstance(record, SelectedRecord):
165
+ return {
166
+ "record_id": record.record_id,
167
+ "title": record.title,
168
+ "source": record.source,
169
+ "text": record.excerpt,
170
+ }
171
+ raise NeedlepathConfigError(
172
+ f"each record must be a ContextRecord or a SelectedRecord, got {type(record).__name__}"
173
+ )
174
+
175
+
176
+ def _questions(questions: Questions) -> Union[Dict[str, Any], List[Any]]:
177
+ """The caller's questions, copied, in the shape they were given in.
178
+
179
+ A bare string is refused rather than iterated: ``list("is this a refund?")``
180
+ is seventeen single-character questions nobody asked.
181
+ """
182
+ if isinstance(questions, (str, bytes)):
183
+ raise NeedlepathConfigError(
184
+ "questions must be a sequence or a mapping of questions, not a single string"
185
+ )
186
+ if isinstance(questions, Mapping):
187
+ return dict(questions)
188
+ return list(questions)
189
+
190
+
191
+ def _render(entries: Sequence[_StateEntry]) -> str:
192
+ blocks = []
193
+ for entry in entries:
194
+ header = f"[{entry['record_id'] or ''}]"
195
+ if entry["title"]:
196
+ header = f"{header} {entry['title']}"
197
+ blocks.append(f"{header}\n{entry['text']}")
198
+ return "\n\n".join(blocks)
199
+
200
+
201
+ __all__ = [
202
+ "to_system_one_state",
203
+ "selection_receipt",
204
+ "STATE_FIELDS",
205
+ "MODE_RECORDS",
206
+ "MODE_RENDERED",
207
+ "MODES",
208
+ "Questions",
209
+ "Selection",
210
+ ]
@@ -56,6 +56,17 @@ class RecordKind:
56
56
  TOOL_SCHEMA = "tool_schema"
57
57
 
58
58
 
59
+ class SecurityMode:
60
+ """Named stateless context-security policies accepted by the hosted API."""
61
+
62
+ OFF = "off"
63
+ AUDIT = "audit"
64
+ QUARANTINE = "quarantine"
65
+ ENFORCE = "enforce"
66
+
67
+ VALUES = frozenset({OFF, AUDIT, QUARANTINE, ENFORCE})
68
+
69
+
59
70
  # --------------------------------------------------------------------------- #
60
71
  # Request
61
72
  # --------------------------------------------------------------------------- #
@@ -89,6 +100,10 @@ class ContextRecord:
89
100
  kind: str = RecordKind.EXTERNAL_DATA
90
101
  id: Optional[str] = None
91
102
  source: Optional[str] = None
103
+ #: Provenance is advisory and optional. When supplied, it lets the service
104
+ #: distinguish trusted application instructions from untrusted tool, web,
105
+ #: email, file, memory, or retrieval content during security scanning.
106
+ source_kind: Optional[str] = None
92
107
  title: Optional[str] = None
93
108
  step_id: Optional[str] = None
94
109
  # NEXPE-437: None means "the caller never annotated importance", and the
@@ -118,6 +133,8 @@ class ContextRecord:
118
133
  # an always-present key would erase the unset/explicit distinction.
119
134
  if self.importance is not None:
120
135
  wire["importance"] = self.importance
136
+ if self.source_kind is not None:
137
+ wire["source_kind"] = self.source_kind
121
138
  return wire
122
139
 
123
140
  @classmethod
@@ -127,6 +144,7 @@ class ContextRecord:
127
144
  kind=d.get("kind", RecordKind.EXTERNAL_DATA),
128
145
  id=d.get("id"),
129
146
  source=d.get("source"),
147
+ source_kind=d.get("source_kind"),
130
148
  title=d.get("title"),
131
149
  step_id=d.get("step_id"),
132
150
  importance=float(d["importance"]) if d.get("importance") is not None else None,
@@ -277,6 +295,9 @@ class ContextRequest:
277
295
  #: not change; the hosted R4 boundary resolves semantic support by default.
278
296
  selection_strategy: Optional[str] = None
279
297
  support_contract: Optional[str] = None
298
+ #: Named context-security policy. Omitted when unset for additive `/v1`
299
+ #: compatibility and to preserve legacy request bytes.
300
+ security_mode: Optional[str] = None
280
301
 
281
302
  def __post_init__(self) -> None:
282
303
  if self.text is not None and self.records:
@@ -311,6 +332,8 @@ class ContextRequest:
311
332
  wire["selection_strategy"] = self.selection_strategy
312
333
  if self.support_contract is not None:
313
334
  wire["support_contract"] = self.support_contract
335
+ if self.security_mode is not None:
336
+ wire["security_mode"] = self.security_mode
314
337
  return wire
315
338
 
316
339
  @classmethod
@@ -327,6 +350,7 @@ class ContextRequest:
327
350
  return_trace=bool(d.get("return_trace", False)),
328
351
  selection_strategy=d.get("selection_strategy"),
329
352
  support_contract=d.get("support_contract"),
353
+ security_mode=d.get("security_mode"),
330
354
  )
331
355
 
332
356
 
@@ -7,7 +7,9 @@ apply a selection that did not happen.
7
7
 
8
8
  ``applied`` is the only thing an adapter needs to read. When it is ``False`` the
9
9
  original context passes through unmodified — always, for every value of
10
- ``reason``, including the ones that are not errors at all.
10
+ ``reason``, including the ones that are not errors at all. A true
11
+ ``sanitized_fallback`` is deliberately ``applied``: the service has replaced a
12
+ normal raw fallback with an explicit, safe final-delivery packet.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -29,6 +31,9 @@ class Reason:
29
31
 
30
32
  #: The selection is safe to apply.
31
33
  OK = "ok"
34
+ #: A normal full-context fallback was quarantined and replaced with the
35
+ #: rendered safe packet. This is applied despite selecting no records.
36
+ SANITIZED_FALLBACK = "sanitized_fallback"
32
37
 
33
38
  # --- not errors: the service or the caller asked us not to apply --------
34
39
  #: Shadow mode. A real call was made and is reported; nothing is applied.
@@ -94,6 +99,7 @@ class Reason:
94
99
  BENIGN_REASONS = frozenset(
95
100
  {
96
101
  Reason.OK,
102
+ Reason.SANITIZED_FALLBACK,
97
103
  Reason.SHADOW,
98
104
  Reason.DISABLED,
99
105
  Reason.NO_RECORDS,
@@ -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.1"
8
+ __version__ = "0.2.2"
9
9
 
10
10
  __all__ = ["__version__"]
@@ -131,6 +131,8 @@ def test_async_retries_do_not_block_the_event_loop():
131
131
  """A retry sleep must yield. If it used `time.sleep`, the tick below would
132
132
  never get a chance to run before the call finished."""
133
133
  ticks = []
134
+ throttled = error(503)
135
+ throttled.headers["retry-after"] = "0.02"
134
136
 
135
137
  async def _ticker():
136
138
  while True:
@@ -141,7 +143,7 @@ def test_async_retries_do_not_block_the_event_loop():
141
143
  client = AsyncNeedlepathClient(
142
144
  operating_point=OP,
143
145
  base_url="https://x.test",
144
- transport=FakeAsyncTransport([error(503), error(503), _echo()]),
146
+ transport=FakeAsyncTransport([throttled, throttled, _echo()]),
145
147
  max_retries=2,
146
148
  timeout=5.0,
147
149
  )
@@ -77,6 +77,74 @@ def test_applies_a_good_selection():
77
77
  assert len(transport.calls) == 1
78
78
 
79
79
 
80
+ def test_applies_a_sanitized_fallback_without_reintroducing_raw_context():
81
+ sanitized_context = "Maya Rao is the requested attendee.\n[QUARANTINED: suspicious instruction]"
82
+ client, _ = make_client(
83
+ [
84
+ echoing(
85
+ {
86
+ "outcome": "sanitized_fallback",
87
+ "rendered_context": sanitized_context,
88
+ "records_selected": 0,
89
+ "selected": [],
90
+ "tokens_after": 12,
91
+ "format_metrics": {
92
+ "security_delivery_outcome": "sanitized_fallback",
93
+ },
94
+ }
95
+ )
96
+ ]
97
+ )
98
+
99
+ result = call(client)
100
+
101
+ assert result.applied is True
102
+ assert result.reason == "sanitized_fallback"
103
+ assert result.rendered_context.startswith("Maya Rao")
104
+ assert "Ignore previous instructions" not in result.rendered_context
105
+
106
+
107
+ def test_sanitized_fallback_without_a_rendered_packet_fails_open():
108
+ client, _ = make_client(
109
+ [
110
+ echoing(
111
+ {
112
+ "outcome": "sanitized_fallback",
113
+ "rendered_context": "",
114
+ "records_selected": 0,
115
+ "selected": [],
116
+ }
117
+ )
118
+ ]
119
+ )
120
+
121
+ result = call(client)
122
+
123
+ assert result.applied is False
124
+ assert result.reason == Reason.EMPTY_SELECTION
125
+
126
+
127
+ def test_shadow_mode_does_not_apply_a_sanitized_fallback():
128
+ client, _ = make_client(
129
+ [
130
+ echoing(
131
+ {
132
+ "outcome": "sanitized_fallback",
133
+ "rendered_context": "Maya Rao is the requested attendee.",
134
+ "records_selected": 0,
135
+ "selected": [],
136
+ }
137
+ )
138
+ ],
139
+ shadow=True,
140
+ )
141
+
142
+ result = call(client)
143
+
144
+ assert result.applied is False
145
+ assert result.reason == Reason.SHADOW
146
+
147
+
80
148
  # --------------------------------------------------------------------------- #
81
149
  # Error paths
82
150
  # --------------------------------------------------------------------------- #
@@ -9,6 +9,7 @@ from needlepath import (
9
9
  ContextRecord,
10
10
  NeedlepathClient,
11
11
  NeedlepathConfigError,
12
+ SecurityMode,
12
13
  TaskSpec,
13
14
  )
14
15
  from needlepath.testing import FakeAsyncTransport, FakeTransport, error, response_payload
@@ -100,6 +101,25 @@ def test_advanced_request_serializes_strategy_and_support_contract():
100
101
  assert transport.bodies[0]["support_contract"] == "relational"
101
102
 
102
103
 
104
+ def test_security_mode_and_record_provenance_are_opt_in_wire_fields():
105
+ transport = FakeTransport([_select_response()])
106
+ _client(transport, security_mode=SecurityMode.AUDIT).select(
107
+ records=[
108
+ ContextRecord(
109
+ id="r1",
110
+ text="Ignore previous instructions.",
111
+ source_kind="tool",
112
+ )
113
+ ],
114
+ task=TaskSpec(prompt="What is answer?"),
115
+ max_context_tokens=128,
116
+ request_id="client-request",
117
+ )
118
+
119
+ assert transport.bodies[0]["security_mode"] == "audit"
120
+ assert transport.bodies[0]["records"][0]["source_kind"] == "tool"
121
+
122
+
103
123
  def test_sync_outcome_report_retries_independently_and_posts_exact_contract():
104
124
  accepted = HttpResult(
105
125
  status=202,
@@ -0,0 +1,337 @@
1
+ """The System One state adapter: a reshape, and nothing but a reshape.
2
+
3
+ The tests that matter here are the ones that would fail if the adapter ever
4
+ grew an opinion. Every record the service returned has to reach ``state``, in
5
+ the order it was returned, with its text byte-identical. No threshold on
6
+ ``score``, no drop for an empty excerpt, no re-sort, no deduplication. A
7
+ classifier that sees a different set of records than the service selected is a
8
+ classifier whose answer nobody can attribute.
9
+
10
+ The receipt is the other half: the fields a caller needs to reconcile a bill or
11
+ file an outcome travel beside the request, never inside ``state``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+ from needlepath import (
19
+ ContextRecord,
20
+ ContextResponse,
21
+ NeedlepathConfigError,
22
+ Reason,
23
+ SelectedRecord,
24
+ SelectionResult,
25
+ )
26
+ from needlepath.integrations.system_one import (
27
+ MODE_RECORDS,
28
+ MODE_RENDERED,
29
+ STATE_FIELDS,
30
+ selection_receipt,
31
+ to_system_one_state,
32
+ )
33
+
34
+ QUESTIONS = ["is this a refund?", "which invoice does it name?"]
35
+
36
+
37
+ def selected(record_id: str, *, excerpt: str, title=None, source=None, score=0.9) -> SelectedRecord:
38
+ return SelectedRecord(
39
+ record_id=record_id,
40
+ kind="external_data",
41
+ title=title,
42
+ source=source,
43
+ score=score,
44
+ reason="engage:needle",
45
+ excerpt=excerpt,
46
+ excerpt_format="plain",
47
+ selected_tokens=10,
48
+ )
49
+
50
+
51
+ def response(*records: SelectedRecord, **kwargs) -> ContextResponse:
52
+ return ContextResponse(
53
+ request_id=kwargs.pop("request_id", "np-0001"),
54
+ rendered_context="Relevant State:\n- kept",
55
+ policy_version=kwargs.pop("policy_version", "np-2026-08-r4"),
56
+ selected=list(records),
57
+ tokens_before=1000,
58
+ tokens_after=100,
59
+ tokens_saved=900,
60
+ records_available=len(records),
61
+ records_selected=len(records),
62
+ **kwargs,
63
+ )
64
+
65
+
66
+ def applied(resp: ContextResponse) -> SelectionResult:
67
+ return SelectionResult(
68
+ applied=True,
69
+ reason=Reason.OK,
70
+ request_id=resp.request_id,
71
+ operating_point="np-2026-08-r4",
72
+ response=resp,
73
+ )
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # Transport only: everything the service returned, in order, verbatim
78
+ # --------------------------------------------------------------------------- #
79
+
80
+
81
+ def test_every_selected_record_reaches_state_in_the_order_returned():
82
+ resp = response(
83
+ selected("r3", excerpt="third"),
84
+ selected("r1", excerpt="first"),
85
+ selected("r2", excerpt="second"),
86
+ )
87
+ out = to_system_one_state(resp, questions=QUESTIONS)
88
+ assert [entry["record_id"] for entry in out["state"]] == ["r3", "r1", "r2"]
89
+
90
+
91
+ def test_a_low_score_record_is_carried_because_the_adapter_has_no_threshold():
92
+ resp = response(selected("r1", excerpt="kept", score=0.0), selected("r2", excerpt="also"))
93
+ out = to_system_one_state(resp, questions=QUESTIONS)
94
+ assert [entry["record_id"] for entry in out["state"]] == ["r1", "r2"]
95
+
96
+
97
+ def test_an_empty_excerpt_is_carried_not_dropped():
98
+ resp = response(selected("r1", excerpt=""), selected("r2", excerpt="text"))
99
+ out = to_system_one_state(resp, questions=QUESTIONS)
100
+ assert [entry["text"] for entry in out["state"]] == ["", "text"]
101
+
102
+
103
+ def test_duplicate_record_ids_are_carried_twice():
104
+ resp = response(selected("r1", excerpt="one"), selected("r1", excerpt="two"))
105
+ out = to_system_one_state(resp, questions=QUESTIONS)
106
+ assert [entry["text"] for entry in out["state"]] == ["one", "two"]
107
+
108
+
109
+ def test_excerpt_text_travels_byte_identical():
110
+ text = " line one\n\n\tline two  "
111
+ out = to_system_one_state(response(selected("r1", excerpt=text)), questions=QUESTIONS)
112
+ assert out["state"][0]["text"] == text
113
+
114
+
115
+ def test_state_entries_carry_the_four_contract_fields_and_nothing_else():
116
+ resp = response(selected("r1", excerpt="kept", title="Invoice A-1002", source="erp"))
117
+ entry = to_system_one_state(resp, questions=QUESTIONS)["state"][0]
118
+ assert tuple(entry) == STATE_FIELDS
119
+ assert entry == {
120
+ "record_id": "r1",
121
+ "title": "Invoice A-1002",
122
+ "source": "erp",
123
+ "text": "kept",
124
+ }
125
+
126
+
127
+ def test_questions_travel_verbatim_in_order():
128
+ out = to_system_one_state(response(selected("r1", excerpt="a")), questions=QUESTIONS)
129
+ assert out["questions"] == QUESTIONS
130
+
131
+
132
+ def test_questions_are_required_never_defaulted():
133
+ with pytest.raises(TypeError):
134
+ to_system_one_state(response(selected("r1", excerpt="a")))
135
+
136
+
137
+ def test_an_empty_question_list_is_the_callers_to_make():
138
+ out = to_system_one_state(response(selected("r1", excerpt="a")), questions=[])
139
+ assert out["questions"] == []
140
+
141
+
142
+ def test_a_question_mapping_stays_a_mapping():
143
+ """``langchain-typesafe`` reads ``questions`` as ``{id: question}``.
144
+ Converting it to a list here would send the ids as the questions."""
145
+ questions = {"refund": object(), "invoice": object()}
146
+ out = to_system_one_state(response(selected("r1", excerpt="a")), questions=questions)
147
+ assert out["questions"] == questions
148
+
149
+
150
+ def test_the_question_container_is_copied_not_aliased():
151
+ questions = ["is this a refund?"]
152
+ out = to_system_one_state(response(selected("r1", excerpt="a")), questions=questions)
153
+ questions.append("added later")
154
+ assert out["questions"] == ["is this a refund?"]
155
+
156
+
157
+ def test_a_bare_string_is_refused_rather_than_split_into_characters():
158
+ with pytest.raises(NeedlepathConfigError, match="not a single string"):
159
+ to_system_one_state(response(selected("r1", excerpt="a")), questions="is this a refund?")
160
+
161
+
162
+ def test_the_request_shape_is_exactly_state_and_questions():
163
+ out = to_system_one_state(response(selected("r1", excerpt="a")), questions=QUESTIONS)
164
+ assert set(out) == {"state", "questions"}
165
+
166
+
167
+ # --------------------------------------------------------------------------- #
168
+ # What it accepts
169
+ # --------------------------------------------------------------------------- #
170
+
171
+
172
+ def test_a_selection_result_reads_its_response():
173
+ resp = response(selected("r1", excerpt="kept"))
174
+ assert to_system_one_state(applied(resp), questions=QUESTIONS) == to_system_one_state(
175
+ resp, questions=QUESTIONS
176
+ )
177
+
178
+
179
+ def test_a_result_that_did_not_apply_is_refused_not_reshaped():
180
+ """The fail-open contract, enforced rather than documented. A passthrough
181
+ means the caller's own records are what has to be sent; reshaping one
182
+ silently would send a narrowed context under a selection that never
183
+ applied."""
184
+ result = SelectionResult.passthrough_result(Reason.TIMEOUT, operating_point="np-2026-08-r4")
185
+ with pytest.raises(NeedlepathConfigError, match="did not apply"):
186
+ to_system_one_state(result, questions=QUESTIONS)
187
+
188
+
189
+ def test_a_result_that_did_not_apply_is_refused_even_carrying_a_selection():
190
+ """The shape that makes the check worth having. ``selection_unsafe`` and
191
+ ``unknown_selection`` both arrive with a populated ``selected`` list the
192
+ client has already decided not to use."""
193
+ resp = response(selected("r1", excerpt="the unsafe selection"))
194
+ result = SelectionResult.passthrough_result(
195
+ Reason.SELECTION_UNSAFE, operating_point="np-2026-08-r4", response=resp
196
+ )
197
+ with pytest.raises(NeedlepathConfigError, match="selection_unsafe"):
198
+ to_system_one_state(result, questions=QUESTIONS)
199
+
200
+
201
+ def test_the_response_of_a_result_that_did_not_apply_can_still_be_reshaped():
202
+ """The escape hatch the refusal names: a caller auditing what the service
203
+ said passes the response itself, which carries no claim about applying."""
204
+ resp = response(selected("r1", excerpt="the unsafe selection"))
205
+ result = SelectionResult.passthrough_result(
206
+ Reason.SELECTION_UNSAFE, operating_point="np-2026-08-r4", response=resp
207
+ )
208
+ out = to_system_one_state(result.response, questions=QUESTIONS)
209
+ assert [entry["record_id"] for entry in out["state"]] == ["r1"]
210
+
211
+
212
+ def test_context_records_shape_the_same_way_so_fail_open_has_a_state():
213
+ records = [
214
+ ContextRecord(text="original one", id="r1", title="Invoice", source="erp"),
215
+ ContextRecord(text="original two", id="r2"),
216
+ ]
217
+ out = to_system_one_state(records, questions=QUESTIONS)
218
+ assert out["state"] == [
219
+ {"record_id": "r1", "title": "Invoice", "source": "erp", "text": "original one"},
220
+ {"record_id": "r2", "title": None, "source": None, "text": "original two"},
221
+ ]
222
+
223
+
224
+ def test_a_record_of_an_unknown_type_is_named_not_an_attribute_error():
225
+ with pytest.raises(NeedlepathConfigError, match="ContextRecord or a SelectedRecord"):
226
+ to_system_one_state([{"record_id": "r1", "text": "a"}], questions=QUESTIONS)
227
+
228
+
229
+ # --------------------------------------------------------------------------- #
230
+ # The rendered mode
231
+ # --------------------------------------------------------------------------- #
232
+
233
+
234
+ def test_rendered_mode_returns_one_string_with_a_header_per_record():
235
+ resp = response(
236
+ selected("r1", excerpt="first body", title="Invoice A-1002"),
237
+ selected("r2", excerpt="second body"),
238
+ )
239
+ state = to_system_one_state(resp, questions=QUESTIONS, mode=MODE_RENDERED)["state"]
240
+ assert state == "[r1] Invoice A-1002\nfirst body\n\n[r2]\nsecond body"
241
+
242
+
243
+ def test_rendered_mode_keeps_the_order_and_every_record():
244
+ resp = response(
245
+ selected("r2", excerpt="b"), selected("r1", excerpt=""), selected("r3", excerpt="c")
246
+ )
247
+ state = to_system_one_state(resp, questions=QUESTIONS, mode=MODE_RENDERED)["state"]
248
+ assert state == "[r2]\nb\n\n[r1]\n\n\n[r3]\nc"
249
+
250
+
251
+ def test_rendered_mode_over_nothing_is_the_empty_string():
252
+ state = to_system_one_state(response(), questions=QUESTIONS, mode=MODE_RENDERED)["state"]
253
+ assert state == ""
254
+
255
+
256
+ def test_records_mode_is_the_default():
257
+ resp = response(selected("r1", excerpt="a"))
258
+ assert to_system_one_state(resp, questions=QUESTIONS) == to_system_one_state(
259
+ resp, questions=QUESTIONS, mode=MODE_RECORDS
260
+ )
261
+
262
+
263
+ def test_an_unknown_mode_is_refused_at_wire_up():
264
+ with pytest.raises(NeedlepathConfigError, match="mode"):
265
+ to_system_one_state(response(), questions=QUESTIONS, mode="ranked")
266
+
267
+
268
+ # --------------------------------------------------------------------------- #
269
+ # The receipt travels beside the request, never inside it
270
+ # --------------------------------------------------------------------------- #
271
+
272
+
273
+ def test_the_receipt_carries_the_five_attribution_fields():
274
+ resp = response(selected("r1", excerpt="kept"), outcome="applied")
275
+ resp.selection_id = "sel-77"
276
+ assert selection_receipt(applied(resp), prefix="") == {
277
+ "request_id": "np-0001",
278
+ "selection_id": "sel-77",
279
+ "policy_version": "np-2026-08-r4",
280
+ "tokens_saved": 900,
281
+ "outcome": "applied",
282
+ }
283
+
284
+
285
+ def test_the_receipt_is_prefixed_the_way_metadata_is():
286
+ resp = response(selected("r1", excerpt="kept"))
287
+ receipt = selection_receipt(applied(resp))
288
+ assert set(receipt) == {
289
+ "needlepath.request_id",
290
+ "needlepath.selection_id",
291
+ "needlepath.policy_version",
292
+ "needlepath.tokens_saved",
293
+ "needlepath.outcome",
294
+ }
295
+ assert receipt["needlepath.request_id"] == "np-0001"
296
+
297
+
298
+ def test_the_receipt_agrees_with_the_core_metadata_it_mirrors():
299
+ """Same keys, same spelling, same values. ``outcome`` is the one receipt
300
+ field ``metadata()`` does not carry, and it is named here so that adding it
301
+ to the core method later cannot silently diverge from this."""
302
+ resp = response(selected("r1", excerpt="kept"), outcome="applied")
303
+ result = applied(resp)
304
+ metadata = result.metadata()
305
+ receipt = selection_receipt(result)
306
+ shared = {key: value for key, value in receipt.items() if key in metadata}
307
+ assert set(receipt) - set(shared) == {"needlepath.outcome"}
308
+ for key, value in shared.items():
309
+ assert metadata[key] == value
310
+
311
+
312
+ def test_no_record_text_reaches_the_receipt():
313
+ resp = response(selected("r1", excerpt="the secret invoice body", title="Invoice"))
314
+ blob = repr(selection_receipt(applied(resp)))
315
+ assert "secret" not in blob
316
+ assert "Invoice" not in blob
317
+
318
+
319
+ def test_a_receipt_answers_for_a_passthrough_that_never_got_a_response():
320
+ result = SelectionResult.passthrough_result(
321
+ Reason.TIMEOUT, request_id="np-0002", operating_point="np-2026-08-r4"
322
+ )
323
+ assert selection_receipt(result, prefix="") == {
324
+ "request_id": "np-0002",
325
+ "selection_id": None,
326
+ "policy_version": None,
327
+ "tokens_saved": 0,
328
+ "outcome": None,
329
+ }
330
+
331
+
332
+ def test_a_bare_response_can_issue_its_own_receipt():
333
+ resp = response(selected("r1", excerpt="kept"))
334
+ resp.selection_id = "sel-9"
335
+ receipt = selection_receipt(resp, prefix="")
336
+ assert receipt["request_id"] == "np-0001"
337
+ assert receipt["selection_id"] == "sel-9"
@@ -40,6 +40,9 @@ HOSTED_WIRE_FIELDS = {
40
40
  "keywords",
41
41
  "tags",
42
42
  "attributes",
43
+ # R6: provenance is omitted when unspecified to preserve the historical
44
+ # request body, and is otherwise accepted by hosted v1.
45
+ "source_kind",
43
46
  },
44
47
  "TaskSpec": {
45
48
  "prompt",
@@ -81,6 +84,8 @@ HOSTED_WIRE_FIELDS = {
81
84
  # R4 advanced form. Both are omitted when unset.
82
85
  "selection_strategy",
83
86
  "support_contract",
87
+ # R6: optional last-hop context-security mode.
88
+ "security_mode",
84
89
  },
85
90
  "SelectedRecord": {
86
91
  "record_id",
@@ -154,7 +159,9 @@ HOSTED_ADDITIONS = {
154
159
  "return_trace",
155
160
  "selection_strategy",
156
161
  "support_contract",
162
+ "security_mode",
157
163
  },
164
+ "ContextRecord": {"source_kind"},
158
165
  # NEXPE-495: the top-level outcome field is live on the hosted API
159
166
  # (verified 2026-08-11); csbench has not republished its contract yet.
160
167
  "ContextResponse": {
@@ -232,12 +239,16 @@ def test_to_wire_emits_the_full_shape_including_nones():
232
239
  "return_trace",
233
240
  "selection_strategy",
234
241
  "support_contract",
242
+ "security_mode",
235
243
  }
236
244
  # NEXPE-437: `importance` is the one deliberate exception to the
237
245
  # full-shape rule. Unset means OMITTED from the wire, so the server can
238
246
  # tell "never annotated" (neutral) apart from an explicit 0.0
239
247
  # (stand-down). Every other optional stays present-as-null.
240
- assert set(wire["records"][0]) == HOSTED_WIRE_FIELDS["ContextRecord"] - {"importance"}
248
+ assert set(wire["records"][0]) == HOSTED_WIRE_FIELDS["ContextRecord"] - {
249
+ "importance",
250
+ "source_kind",
251
+ }
241
252
  assert set(wire["task"]) == HOSTED_WIRE_FIELDS["TaskSpec"]
242
253
  assert set(wire["budget"]) == HOSTED_WIRE_FIELDS["BudgetSpec"]
243
254
  assert wire["records"][0]["source"] is None # present, not omitted
File without changes
File without changes