needlepath 0.2.2__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 (32) hide show
  1. needlepath-0.2.4/CHANGELOG.md +139 -0
  2. {needlepath-0.2.2 → needlepath-0.2.4}/PKG-INFO +70 -20
  3. {needlepath-0.2.2 → needlepath-0.2.4}/README.md +69 -19
  4. {needlepath-0.2.2 → needlepath-0.2.4}/pyproject.toml +1 -1
  5. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/__init__.py +6 -4
  6. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/client.py +209 -29
  7. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/errors.py +13 -4
  8. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/features.py +5 -3
  9. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/integrations/system_one.py +13 -1
  10. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/result.py +151 -11
  11. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/version.py +1 -1
  12. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_fail_open.py +151 -0
  13. needlepath-0.2.4/tests/test_guardrails.py +436 -0
  14. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_system_one_adapter.py +31 -6
  15. needlepath-0.2.2/CHANGELOG.md +0 -78
  16. {needlepath-0.2.2 → needlepath-0.2.4}/.gitignore +0 -0
  17. {needlepath-0.2.2 → needlepath-0.2.4}/LICENSE +0 -0
  18. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/integrations/__init__.py +0 -0
  19. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/models.py +0 -0
  20. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/py.typed +0 -0
  21. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/testing.py +0 -0
  22. {needlepath-0.2.2 → needlepath-0.2.4}/src/needlepath/transport.py +0 -0
  23. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_async_parity.py +0 -0
  24. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_config_and_guards.py +0 -0
  25. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_live_endpoint.py +0 -0
  26. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_no_secret_leak.py +0 -0
  27. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_r4_contract.py +0 -0
  28. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_retries.py +0 -0
  29. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_shadow_mode.py +0 -0
  30. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_simple_shape.py +0 -0
  31. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_urllib_transport.py +0 -0
  32. {needlepath-0.2.2 → needlepath-0.2.4}/tests/test_wire_compatibility.py +0 -0
@@ -0,0 +1,139 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `needlepath` Python package are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
37
+ ## [0.2.3] - 2026-11-04
38
+
39
+ ### Fixed
40
+
41
+ - **`outcome: blocked` is applied, never passed through.** A governed
42
+ workspace answers a record its policy withholds with top-level
43
+ `outcome: "blocked"`, zero records, an empty render and a `governance`
44
+ block. By shape that is an empty selection, and `classify` returned
45
+ `EMPTY_SELECTION`, a passthrough, so every adapter delivered the withheld
46
+ record. `classify` now reads `blocked` first and returns `Reason.BLOCKED`,
47
+ an applied result with nothing to deliver: adapters install the verdict (a
48
+ notice, a placeholder, or no context) and never the original. Benign, not a
49
+ failure. A blocked verdict is enforced in shadow mode too: shadow never
50
+ restores a withheld record, so the result stays `reason: blocked`,
51
+ `applied: true` with `shadow: true`, `outcome` and `governance` on the
52
+ receipt.
53
+
54
+ ### Added
55
+
56
+ - **Workspace guardrails.** `NeedlepathGuardrailError`, raised when the
57
+ service answers `guardrails_unavailable`, when a `governance` block is
58
+ malformed, or when a protected delivery (workspace policies applied,
59
+ raw-context fallback forbidden) cannot be applied. `guardrails_required=True`
60
+ on either client additionally stops on transport errors, timeouts and
61
+ missing enforcement evidence; it is never sent on the wire. A protected
62
+ delivery is applied even in shadow mode and cannot be `declined()` back to
63
+ the original. A blocked verdict is the exception on purpose: it is applied
64
+ as an empty delivery and never raises.
65
+ - **`SelectionResult.metadata()` carries the whole receipt.** `outcome`,
66
+ `security_action`, `usage` and `governance` are now part of the
67
+ metadata-only receipt, verbatim and only when the response has them, so an
68
+ adapter that writes nothing but `metadata()` still shows what the engine
69
+ decided, what it billed and what its policies withheld.
70
+
71
+ ## [0.2.2] - 2026-11-03
72
+
73
+ ### Changed
74
+
75
+ - First published build carrying the `sanitized_fallback` and `blocked`
76
+ security-screening outcomes, `security_mode` on requests and the
77
+ `security_action` receipt field (see the API release notes). 0.2.1 predates
78
+ them and classifies a screened fallback as `selection_unsafe`.
79
+
80
+ ### Added
81
+
82
+ - **System One state adapter**: `needlepath.integrations.system_one` reshapes a
83
+ selection into the `{state, questions}` request body that small classifier
84
+ models (TypeSafe's Jev, Laya) accept. `to_system_one_state()` carries every
85
+ record in `response.selected`, in the order returned, with the text
86
+ unmodified; `mode="rendered"` returns `state` as one string instead of an
87
+ array. `questions` travels in the shape it was passed, mapping or sequence,
88
+ so a `{id: question}` mapping never arrives as a list of its keys.
89
+ A `SelectionResult` is accepted only when it `applied`; a passthrough raises
90
+ `NeedlepathConfigError` naming the reason, since its response can carry a
91
+ selection the client declined to use. `selection_receipt()` returns
92
+ `request_id`, `selection_id`, `policy_version`, `tokens_saved` and `outcome`
93
+ as a sidecar dict, prefixed the way `SelectionResult.metadata()` prefixes its
94
+ keys.
95
+
96
+ ## [0.2.1] - 2026-09-03
97
+
98
+ ### Changed
99
+
100
+ - Docs, docstrings and examples pin np-2026-08-r4. No behaviour change.
101
+
102
+ ## [0.2.0] - 2026-08-07
103
+
104
+ ### Added
105
+
106
+ - **Simple text shape** (NEXPE-461): `select(text=...)` accepts a raw string as
107
+ the alternative to `records=`. Exactly one of the two may be passed; passing
108
+ both fails open client-side with a `NeedlepathConfigError` whose message
109
+ mirrors the server's `400 text_and_records_both_set`. The text goes on the
110
+ wire verbatim; the server chunks it, and each chunk comes back in
111
+ `selected[].record_id` as a content-hash id of the form `sha256:<16 hex>`.
112
+ `ContextRequest` gains an optional `text` field, omitted from the wire when
113
+ unset.
114
+
115
+ ### Changed
116
+
117
+ - **`request_id` is now optional** (NEXPE-461): it is a sent-side correlation
118
+ id. `ContextRequest.request_id` may be `None`, in which case the key is
119
+ omitted from the payload and the server assigns its own id (reported on
120
+ `SelectionResult.request_id`). In `select()` / `build_request()`, leaving the
121
+ argument off still auto-generates an id as before; pass `request_id=None`
122
+ explicitly to omit it. The response echo check is skipped when no id was
123
+ sent.
124
+
125
+ ### Note
126
+
127
+ - No breaking changes for existing callers: records requests serialize
128
+ byte-identically to 0.1.x, omitting `request_id` still auto-generates one,
129
+ and `operating_point` remains the required, immutable-label constructor
130
+ argument.
131
+
132
+ ## [0.1.1] - 2026-08-06
133
+
134
+ - Default base URL moved to `https://api.nextmoca.com`; no wire-shape changes.
135
+
136
+ ## [0.1.0] - 2026-08-01
137
+
138
+ - Initial release: fail-open `select()`, pinned operating point, client-side
139
+ 6 MB payload guard, shadow mode, sync and async clients.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: needlepath
3
- Version: 0.2.2
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.
@@ -72,8 +72,51 @@ result = client.select(
72
72
  context = result.rendered_context if result.applied else my_original_context
73
73
  ```
74
74
 
75
- `select()` **never raises.** Read `result.applied`; when it is `False`, use your
76
- original context. That is the whole integration contract.
75
+ Ordinary unprotected failures return `result.applied=False`, allowing your
76
+ original context. A record your workspace policy withholds comes back as
77
+ `result.reason == "blocked"` with `result.applied=True` and an empty
78
+ `rendered_context`: the verdict is "deliver nothing from these records", and
79
+ that is what you install. Workspace guardrail failures (the service could not
80
+ enforce, or returned a protected response that cannot be applied) raise
81
+ `NeedlepathGuardrailError`; stop the model request instead of substituting the
82
+ original context.
83
+
84
+ ### Workspace guardrails
85
+
86
+ The framework integrations (LangChain/LangGraph, Strands, LiteLLM and
87
+ LlamaIndex) install a blocked verdict as a withheld notice in place of the
88
+ record, or as no context, and let the agent continue; they propagate
89
+ `NeedlepathGuardrailError` rather than falling back to the original.
90
+ `guardrails_required` is a core-client option: pass a client you constructed
91
+ with it to the integration.
92
+
93
+ For a workspace with guardrails, update the SDK and construct either the sync
94
+ or async client with `guardrails_required=True`:
95
+
96
+ ```python
97
+ client = NeedlepathClient(
98
+ operating_point="np-2026-08-r4",
99
+ guardrails_required=True,
100
+ )
101
+ ```
102
+
103
+ This local fallback setting is never sent to the server and cannot disable
104
+ workspace policies. It stops on transport errors, timeouts, invalid requests,
105
+ and missing or malformed enforcement evidence. An authoritative admin pause
106
+ (`enabled=False`) or active configuration with zero policies is honored.
107
+
108
+ Regardless of this setting, invalid governance metadata or
109
+ `guardrails_unavailable` raises a sanitized `NeedlepathGuardrailError`. A
110
+ blocked response never raises: it is applied as an empty delivery
111
+ (`reason: "blocked"`), in shadow mode too, and the receipt's `governance`
112
+ block says what was withheld and under which policy revision.
113
+ When the server forbids raw-context fallback, only its permitted selected
114
+ context is applied, including in shadow mode and after engine recovery.
115
+ `result.declined(...)` cannot restore the original context for a protected
116
+ response. Do not catch a guardrail exception or a response rewrite error and
117
+ continue with raw records. Existing clients
118
+ must update and enable this setting to stop safely when an outage prevents
119
+ the server from returning any enforcement evidence.
77
120
 
78
121
  ### Async
79
122
 
@@ -134,6 +177,7 @@ Do not send prompts, context, model output, answers, or free-form notes.
134
177
  | `max_retries` | — | `2` |
135
178
  | `max_request_bytes` | — | `6 * 1024 * 1024` |
136
179
  | `shadow` | — | `False` |
180
+ | `guardrails_required` | — | `False` |
137
181
 
138
182
  The base URL is a **documented default, not a hard-coded one**: a durable custom
139
183
  domain replaces it, and swapping it is a config change rather than a release.
@@ -143,12 +187,13 @@ constraint; loopback is exempt so the localhost benchmark adapter still works.
143
187
 
144
188
  ## The four binding rules, and where they live
145
189
 
146
- ### 1. Fail open, always
190
+ ### 1. Fail open for ordinary unprotected requests
147
191
 
148
- Timeout, 5xx, a violated contract, an empty selection, a stood-down gate — every
149
- one of them returns a `SelectionResult` with `applied=False`, the exception on
150
- `.error`, and a machine-readable `.reason`. Nothing is raised on the selection
151
- path.
192
+ With the default configuration and no workspace protection evidence, a timeout,
193
+ 5xx, violated contract, empty selection, or stood-down gate returns a
194
+ `SelectionResult` with `applied=False`, the exception on `.error`, and a
195
+ machine-readable `.reason`. The workspace guardrail contract above takes
196
+ precedence over these optimization fallbacks.
152
197
 
153
198
  ```python
154
199
  if not result.applied:
@@ -178,18 +223,19 @@ Treat an unrecognized reason as "do not apply". `result.failed` separates the
178
223
  error paths from the benign declines, which is the distinction that decides
179
224
  whether a passthrough deserves a `WARNING` or a `DEBUG`.
180
225
 
181
- The empty-selection rule is not a nicety. NEXPE-413 is the precedent: the engine
182
- returned an empty selection as a success, and at a framework boundary that has
183
- to degrade to passthrough, never to an empty context.
226
+ An ordinary empty selection degrades to passthrough. A blocked verdict is
227
+ applied as an empty delivery (`reason: "blocked"`, `applied=True`), never a
228
+ passthrough and never an exception. An empty selection under workspace
229
+ protection that is not a blocked verdict raises and must stop the model request.
184
230
 
185
- **`select_or_raise()` exists** for the two cases where fail-open is wrong —
186
- diagnostics, and a caller that is itself a benchmark and must not silently
187
- substitute a passthrough for a measurement. Do not use it in an adapter.
231
+ **`select_or_raise()` exists** for diagnostics and benchmarks that must not
232
+ silently substitute a passthrough for a measurement. Framework adapters use
233
+ `select()` with its explicit guardrail exception contract.
188
234
 
189
235
  ### 2. Pin the operating point
190
236
 
191
- `operating_point` is required at construction, and this is the one failure that
192
- raises rather than fails open. Failing open on a bad request is degradation a
237
+ `operating_point` is required at construction; an invalid configuration raises.
238
+ Failing open on an ordinary unprotected bad request is degradation a
193
239
  customer absorbs; failing open on "no operating point was pinned" accepts
194
240
  exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
195
241
  never under load.
@@ -215,10 +261,14 @@ that passes at 5.99 MB can still be rejected upstream. Lower
215
261
  client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
216
262
  ```
217
263
 
218
- The call is made exactly as a live call would be — same body, same billing, same
219
- operating point — the full response comes back and is reported, and **the
220
- selection is never applied**. Measure what Needlepath would have saved you;
221
- change nothing.
264
+ The call is made exactly as a live call would be, with the same body, billing,
265
+ and operating point. Ordinary unprotected selections are reported without
266
+ being applied (`reason: "shadow"`). A blocked verdict is enforced in shadow
267
+ mode too: shadow never restores a withheld record, so the result stays
268
+ `reason: "blocked"`, `applied=True`, with `shadow: true`, `outcome` and
269
+ `governance` on the receipt. A protected delivery likewise takes precedence:
270
+ only its permitted selected context may be used, or the request stops with
271
+ `NeedlepathGuardrailError`.
222
272
 
223
273
  `budget.mode = "shadow"` is **not on the wire yet**. `BudgetSpec.mode` on the
224
274
  published contract accepts `fixed` and `adaptive` only; adding `shadow` is a
@@ -43,8 +43,51 @@ result = client.select(
43
43
  context = result.rendered_context if result.applied else my_original_context
44
44
  ```
45
45
 
46
- `select()` **never raises.** Read `result.applied`; when it is `False`, use your
47
- original context. That is the whole integration contract.
46
+ Ordinary unprotected failures return `result.applied=False`, allowing your
47
+ original context. A record your workspace policy withholds comes back as
48
+ `result.reason == "blocked"` with `result.applied=True` and an empty
49
+ `rendered_context`: the verdict is "deliver nothing from these records", and
50
+ that is what you install. Workspace guardrail failures (the service could not
51
+ enforce, or returned a protected response that cannot be applied) raise
52
+ `NeedlepathGuardrailError`; stop the model request instead of substituting the
53
+ original context.
54
+
55
+ ### Workspace guardrails
56
+
57
+ The framework integrations (LangChain/LangGraph, Strands, LiteLLM and
58
+ LlamaIndex) install a blocked verdict as a withheld notice in place of the
59
+ record, or as no context, and let the agent continue; they propagate
60
+ `NeedlepathGuardrailError` rather than falling back to the original.
61
+ `guardrails_required` is a core-client option: pass a client you constructed
62
+ with it to the integration.
63
+
64
+ For a workspace with guardrails, update the SDK and construct either the sync
65
+ or async client with `guardrails_required=True`:
66
+
67
+ ```python
68
+ client = NeedlepathClient(
69
+ operating_point="np-2026-08-r4",
70
+ guardrails_required=True,
71
+ )
72
+ ```
73
+
74
+ This local fallback setting is never sent to the server and cannot disable
75
+ workspace policies. It stops on transport errors, timeouts, invalid requests,
76
+ and missing or malformed enforcement evidence. An authoritative admin pause
77
+ (`enabled=False`) or active configuration with zero policies is honored.
78
+
79
+ Regardless of this setting, invalid governance metadata or
80
+ `guardrails_unavailable` raises a sanitized `NeedlepathGuardrailError`. A
81
+ blocked response never raises: it is applied as an empty delivery
82
+ (`reason: "blocked"`), in shadow mode too, and the receipt's `governance`
83
+ block says what was withheld and under which policy revision.
84
+ When the server forbids raw-context fallback, only its permitted selected
85
+ context is applied, including in shadow mode and after engine recovery.
86
+ `result.declined(...)` cannot restore the original context for a protected
87
+ response. Do not catch a guardrail exception or a response rewrite error and
88
+ continue with raw records. Existing clients
89
+ must update and enable this setting to stop safely when an outage prevents
90
+ the server from returning any enforcement evidence.
48
91
 
49
92
  ### Async
50
93
 
@@ -105,6 +148,7 @@ Do not send prompts, context, model output, answers, or free-form notes.
105
148
  | `max_retries` | — | `2` |
106
149
  | `max_request_bytes` | — | `6 * 1024 * 1024` |
107
150
  | `shadow` | — | `False` |
151
+ | `guardrails_required` | — | `False` |
108
152
 
109
153
  The base URL is a **documented default, not a hard-coded one**: a durable custom
110
154
  domain replaces it, and swapping it is a config change rather than a release.
@@ -114,12 +158,13 @@ constraint; loopback is exempt so the localhost benchmark adapter still works.
114
158
 
115
159
  ## The four binding rules, and where they live
116
160
 
117
- ### 1. Fail open, always
161
+ ### 1. Fail open for ordinary unprotected requests
118
162
 
119
- Timeout, 5xx, a violated contract, an empty selection, a stood-down gate — every
120
- one of them returns a `SelectionResult` with `applied=False`, the exception on
121
- `.error`, and a machine-readable `.reason`. Nothing is raised on the selection
122
- path.
163
+ With the default configuration and no workspace protection evidence, a timeout,
164
+ 5xx, violated contract, empty selection, or stood-down gate returns a
165
+ `SelectionResult` with `applied=False`, the exception on `.error`, and a
166
+ machine-readable `.reason`. The workspace guardrail contract above takes
167
+ precedence over these optimization fallbacks.
123
168
 
124
169
  ```python
125
170
  if not result.applied:
@@ -149,18 +194,19 @@ Treat an unrecognized reason as "do not apply". `result.failed` separates the
149
194
  error paths from the benign declines, which is the distinction that decides
150
195
  whether a passthrough deserves a `WARNING` or a `DEBUG`.
151
196
 
152
- The empty-selection rule is not a nicety. NEXPE-413 is the precedent: the engine
153
- returned an empty selection as a success, and at a framework boundary that has
154
- to degrade to passthrough, never to an empty context.
197
+ An ordinary empty selection degrades to passthrough. A blocked verdict is
198
+ applied as an empty delivery (`reason: "blocked"`, `applied=True`), never a
199
+ passthrough and never an exception. An empty selection under workspace
200
+ protection that is not a blocked verdict raises and must stop the model request.
155
201
 
156
- **`select_or_raise()` exists** for the two cases where fail-open is wrong —
157
- diagnostics, and a caller that is itself a benchmark and must not silently
158
- substitute a passthrough for a measurement. Do not use it in an adapter.
202
+ **`select_or_raise()` exists** for diagnostics and benchmarks that must not
203
+ silently substitute a passthrough for a measurement. Framework adapters use
204
+ `select()` with its explicit guardrail exception contract.
159
205
 
160
206
  ### 2. Pin the operating point
161
207
 
162
- `operating_point` is required at construction, and this is the one failure that
163
- raises rather than fails open. Failing open on a bad request is degradation a
208
+ `operating_point` is required at construction; an invalid configuration raises.
209
+ Failing open on an ordinary unprotected bad request is degradation a
164
210
  customer absorbs; failing open on "no operating point was pinned" accepts
165
211
  exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
166
212
  never under load.
@@ -186,10 +232,14 @@ that passes at 5.99 MB can still be rejected upstream. Lower
186
232
  client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
187
233
  ```
188
234
 
189
- The call is made exactly as a live call would be — same body, same billing, same
190
- operating point — the full response comes back and is reported, and **the
191
- selection is never applied**. Measure what Needlepath would have saved you;
192
- change nothing.
235
+ The call is made exactly as a live call would be, with the same body, billing,
236
+ and operating point. Ordinary unprotected selections are reported without
237
+ being applied (`reason: "shadow"`). A blocked verdict is enforced in shadow
238
+ mode too: shadow never restores a withheld record, so the result stays
239
+ `reason: "blocked"`, `applied=True`, with `shadow: true`, `outcome` and
240
+ `governance` on the receipt. A protected delivery likewise takes precedence:
241
+ only its permitted selected context may be used, or the request stops with
242
+ `NeedlepathGuardrailError`.
193
243
 
194
244
  `budget.mode = "shadow"` is **not on the wire yet**. `BudgetSpec.mode` on the
195
245
  published contract accepts `fixed` and `adaptive` only; adding `shadow` is a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath"
7
- version = "0.2.2"
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"
@@ -13,10 +13,10 @@
13
13
  Three properties this package guarantees, because the framework adapters built
14
14
  on it are only as safe as it is:
15
15
 
16
- 1. **``select`` never raises.** Timeout, 5xx, a violated contract, an empty
17
- selection, a stood-down gate — all of them come back as a result with
18
- ``applied=False``, the error on ``.error``, and a metadata-only summary on
19
- ``.metadata()``.
16
+ 1. Ordinary unprotected failures return ``applied=False`` with recorded errors.
17
+ A blocked verdict is applied (``reason: blocked``, nothing delivered) and
18
+ never raises. Workspace protection or ``guardrails_required`` failures
19
+ instead raise ``NeedlepathGuardrailError``; raw-context fallback is forbidden.
20
20
  2. **The operating point is pinned in the constructor**, or construction fails.
21
21
  3. **The 6 MB payload cap is measured client-side**, before the request leaves.
22
22
 
@@ -50,6 +50,7 @@ from .errors import (
50
50
  NeedlepathConfigError,
51
51
  NeedlepathContractError,
52
52
  NeedlepathError,
53
+ NeedlepathGuardrailError,
53
54
  NeedlepathHTTPError,
54
55
  NeedlepathPayloadTooLargeError,
55
56
  NeedlepathTimeoutError,
@@ -87,6 +88,7 @@ from .transport import (
87
88
  from .version import __version__
88
89
 
89
90
  __all__ = [
91
+ "NeedlepathGuardrailError",
90
92
  "__version__",
91
93
  # clients
92
94
  "NeedlepathClient",