needlepath-langchain 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 (18) hide show
  1. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/CHANGELOG.md +69 -17
  2. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/PKG-INFO +6 -3
  3. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/README.md +4 -1
  4. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/pyproject.toml +2 -2
  5. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/_messages.py +86 -31
  6. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/langgraph.py +39 -8
  7. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/middleware.py +96 -35
  8. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/version.py +1 -1
  9. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_langgraph_recipe.py +185 -0
  10. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_middleware.py +557 -2
  11. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/.gitignore +0 -0
  12. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/LICENSE +0 -0
  13. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/__init__.py +0 -0
  14. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/py.typed +0 -0
  15. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/system_one.py +0 -0
  16. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/conftest.py +0 -0
  17. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_end_to_end.py +0 -0
  18. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_system_one_chain.py +0 -0
@@ -2,22 +2,60 @@
2
2
 
3
3
  All notable changes to the `needlepath-langchain` Python package are documented here.
4
4
 
5
- ## 0.2.2
5
+ ## 0.2.4 — 2026-11-05
6
6
 
7
7
  ### Fixed
8
8
 
9
- - **Tool-result seam applies a `sanitized_fallback`.** The service answers a
10
- screened tool result with one model-facing packet (`rendered_context`, the
11
- record with the offending span removed and marked in place) and no per-record
12
- excerpt. The seam declined that packet as `NO_SAVING` and delivered the
13
- unscreened original to the model. It now applies the packet
14
- (`rewrite_reason: "sanitized"`), and the length guard is skipped for screened
15
- packets, which are applied for what they removed, not for what they saved.
16
- - **Receipt on the message** (`response_metadata["needlepath"]`) now also carries
17
- `outcome`, `security_action` and `usage`, so an agent trace holds the same
18
- receipt the console shows.
9
+ - **A protected record the length guard would keep is withheld, never kept.**
10
+ Under workspace protection (the engine's `governance` forbids raw-context
11
+ fallback) and a verdict that is not blocked, `apply_selection` kept an
12
+ individual original whenever its excerpt or placeholder was empty or not
13
+ shorter, and the history seam and the LangGraph hook only declined when the
14
+ aggregate rewrite showed no saving, so a larger saving elsewhere masked the
15
+ per-record refusal and the protected original reached the model. Such a
16
+ record now collapses to the withheld notice (`rewrite_reason: "protected"`);
17
+ the aggregate no-saving decline does not apply under protection, since the
18
+ rewrite holds no original. On the tool-result seam the same case used to
19
+ decline, which under protection raised `NeedlepathGuardrailError` and
20
+ aborted the run; it now installs the notice and the run continues. The
21
+ engine's own stop conditions (an empty or unusable protected response)
22
+ still raise.
23
+ - **Shadow never restores a protected original.** The adapter's own shadow
24
+ guard exempted only a blocked verdict, so a protected engaged or sanitized
25
+ delivery (the core keeps it applied in a shadow run) was discarded and the
26
+ original forwarded. The guard now exempts every protected result
27
+ (`SelectionResult.protected`). Shadow still forwards the original for every
28
+ unprotected delivery.
29
+ - **A defect while installing a protected result stops the call.** Selection
30
+ and installation shared one fail-open handler, so a failure after a
31
+ protected result had been returned restored the original. The install step
32
+ now runs under `SelectionResult.applying()`: under protection it raises
33
+ `NeedlepathGuardrailError`; otherwise it fails open as before.
34
+
35
+ ## 0.2.3 — 2026-11-04
19
36
 
20
- ## Unreleased
37
+ ### Fixed
38
+
39
+ - **LangGraph `pre_model_hook` installs a blocked verdict.** The hook applied a
40
+ blocked selection through the ordinary placeholder path, where the length
41
+ guard could keep the withheld record. It now collapses every considered tool
42
+ result to the withheld notice, the same as the middleware's history seam.
43
+ - **A blocked verdict is enforced in shadow mode too.** The middleware's and
44
+ the hook's own shadow flags no longer restore a withheld record: the notice
45
+ is installed in a shadow run and the receipt says it was one
46
+ (`shadow: true`, `reason: blocked`). Shadow still installs nothing for
47
+ every other reason.
48
+ - **A `blocked` tool result is withheld from the model.** When workspace
49
+ policy withholds the record (`outcome: "blocked"`), the tool-result seam
50
+ returned the original message, so the withheld record reached the model.
51
+ The engine's verdict is now installed as the tool result: a short notice
52
+ naming the engine's categories (`rewrite_reason: "blocked"`), no length
53
+ guard, and the agent continues without the record. On the history seam
54
+ every record that was sent collapses to the same notice.
55
+ - **Receipt carries `governance` verbatim.** `response_metadata["needlepath"]`
56
+ now includes the engine's `governance` block (records before/after/blocked,
57
+ category counts, policy revision, policies applied) when the response has
58
+ one. It is the audit evidence a governed workspace reads.
21
59
 
22
60
  ### Added
23
61
 
@@ -37,11 +75,25 @@ All notable changes to the `needlepath-langchain` Python package are documented
37
75
 
38
76
  ### Requires
39
77
 
40
- - **`needlepath` with `needlepath.integrations.system_one`**, which the
41
- unreleased core adds. The dependency floor here still reads
42
- `needlepath>=0.1.0,<1.0.0` and **must be raised to the core release that
43
- carries that module before this package is published**, or a resolver is free
44
- to pair this release with a core that cannot import it. Core releases first.
78
+ - **`needlepath>=0.2.3`.** The dependency floor is raised from `0.1.0`: this
79
+ release reads `Reason.BLOCKED`, which core 0.2.3 adds, and imports
80
+ `needlepath.integrations.system_one`, which core 0.2.2 already ships. A
81
+ resolver can no longer pair this package with a core that lacks either.
82
+
83
+ ## 0.2.2 — 2026-11-03
84
+
85
+ ### Fixed
86
+
87
+ - **Tool-result seam applies a `sanitized_fallback`.** The service answers a
88
+ screened tool result with one model-facing packet (`rendered_context`, the
89
+ record with the offending span removed and marked in place) and no per-record
90
+ excerpt. The seam declined that packet as `NO_SAVING` and delivered the
91
+ unscreened original to the model. It now applies the packet
92
+ (`rewrite_reason: "sanitized"`), and the length guard is skipped for screened
93
+ packets, which are applied for what they removed, not for what they saved.
94
+ - **Receipt on the message** (`response_metadata["needlepath"]`) now also carries
95
+ `outcome`, `security_action` and `usage`, so an agent trace holds the same
96
+ receipt the console shows.
45
97
 
46
98
  ## 0.2.1 — 2026-09-03
47
99
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: needlepath-langchain
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Needlepath context selection for LangChain agents (AgentMiddleware) and LangGraph.
5
5
  Project-URL: Homepage, https://nextmoca.com
6
6
  Author: Next Moca Global, Inc.
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Typing :: Typed
15
15
  Requires-Python: >=3.10
16
16
  Requires-Dist: langchain<2.0.0,>=1.2.9
17
- Requires-Dist: needlepath<1.0.0,>=0.1.0
17
+ Requires-Dist: needlepath<1.0.0,>=0.2.3
18
18
  Provides-Extra: dev
19
19
  Requires-Dist: mypy>=1.8; extra == 'dev'
20
20
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
@@ -110,7 +110,10 @@ the savings from deleting message envelopes.
110
110
 
111
111
  **A rewrite never grows a message and never empties one.** An excerpt longer than
112
112
  what it replaces, or an empty excerpt applied to a non-empty message, is refused
113
- here rather than trusted upstream.
113
+ here rather than trusted upstream. Refusing keeps the original, except under
114
+ workspace protection (the engine's `governance` forbids raw-context fallback):
115
+ there the record is withheld with the notice instead, because the original is
116
+ the one thing that may not be installed.
114
117
 
115
118
  By default only `ToolMessage`s are rewritten. `SystemMessage` never is, on any
116
119
  setting. An `AIMessage` carrying `tool_calls` never is: its content is usually
@@ -86,7 +86,10 @@ the savings from deleting message envelopes.
86
86
 
87
87
  **A rewrite never grows a message and never empties one.** An excerpt longer than
88
88
  what it replaces, or an empty excerpt applied to a non-empty message, is refused
89
- here rather than trusted upstream.
89
+ here rather than trusted upstream. Refusing keeps the original, except under
90
+ workspace protection (the engine's `governance` forbids raw-context fallback):
91
+ there the record is withheld with the notice instead, because the original is
92
+ the one thing that may not be installed.
90
93
 
91
94
  By default only `ToolMessage`s are rewritten. `SystemMessage` never is, on any
92
95
  setting. An `AIMessage` carrying `tool_calls` never is: its content is usually
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath-langchain"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Needlepath context selection for LangChain agents (AgentMiddleware) and LangGraph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10" # langchain 1.x floor
@@ -29,7 +29,7 @@ classifiers = [
29
29
  # `langchain.agents.middleware` package and ModelRequest.system_message is
30
30
  # stable. Capped at the major, per §8.2.
31
31
  dependencies = [
32
- "needlepath>=0.1.0,<1.0.0",
32
+ "needlepath>=0.2.3,<1.0.0",
33
33
  "langchain>=1.2.9,<2.0.0",
34
34
  ]
35
35
 
@@ -22,7 +22,12 @@ to get it.
22
22
  **Invariant 2 — a rewrite never grows the context and never empties a message.**
23
23
  An excerpt longer than what it replaces, or an empty excerpt applied to a
24
24
  non-empty message, is a regression however the service arrived at it. Both are
25
- refused here rather than trusted upstream.
25
+ refused here rather than trusted upstream. Refusing a rewrite keeps the
26
+ original, which is the right degradation for an ordinary selection and the
27
+ wrong one under workspace protection: when the engine's ``governance`` forbids
28
+ raw-context fallback, a refused rewrite collapses to the withheld notice
29
+ instead (``protected_notice``), because the original is the one thing that
30
+ may not be installed.
26
31
 
27
32
  **No local "preserve the last N messages" policy (NEXPE-517 review).** An
28
33
  earlier version excluded a trailing window of messages from ``index_by_id``
@@ -45,7 +50,7 @@ from __future__ import annotations
45
50
  from collections.abc import Sequence
46
51
  from typing import Any
47
52
 
48
- from needlepath import ContextRecord, RecordKind
53
+ from needlepath import ContextRecord, RecordKind, SelectionResult
49
54
 
50
55
  try: # pragma: no cover - exercised by the installed-langchain test matrix
51
56
  from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
@@ -298,6 +303,8 @@ def apply_selection(
298
303
  index_by_id: dict[str, int],
299
304
  placeholder: str,
300
305
  request_id: str,
306
+ withhold: bool = False,
307
+ protected_notice: str | None = None,
301
308
  ) -> tuple[list[BaseMessage], int, int]:
302
309
  """Rewrite the selected and unselected messages. Returns (messages, before, after).
303
310
 
@@ -305,6 +312,18 @@ def apply_selection(
305
312
  would make a message *longer*, is refused and the original kept. Both are
306
313
  regressions however the service arrived at them, and refusing one message's
307
314
  rewrite is cheaper than failing the whole selection open.
315
+
316
+ Two exceptions, both for a response under workspace protection, where the
317
+ original is the thing that must not be kept:
318
+
319
+ * ``withhold``, for a ``blocked`` verdict: every record collapses to
320
+ ``placeholder`` (the withheld notice) whatever its length. ``excerpts``
321
+ is ignored; a blocked response carries none.
322
+ * ``protected_notice``, for any other protected delivery: a record whose
323
+ rewrite invariant 2 refuses collapses to this notice instead of keeping
324
+ the original. The refusal is per record, and a caller that only checks
325
+ the aggregate saving cannot see it — a large saving elsewhere would mask
326
+ one kept original and send it to the model.
308
327
  """
309
328
  out: list[BaseMessage] = list(messages)
310
329
  before = 0
@@ -313,41 +332,56 @@ def apply_selection(
313
332
  original = out[index]
314
333
  original_text = message_text(original)
315
334
  before += approx_tokens(original_text)
316
- excerpt = excerpts.get(record_id)
317
- if excerpt is None and not placeholder.strip():
318
- # Invariant 2, at the mutation boundary. A blank custom placeholder
319
- # would rewrite a non-empty message to nothing — the pairing
320
- # survives, the content does not. Refused rather than trusted.
321
- after += approx_tokens(original_text)
322
- continue
323
- if excerpt is None:
324
- # Not selected: collapse to the placeholder. This is the only place
325
- # content is discarded, and it is the difference between selection
326
- # and a no-op.
327
- new_text = placeholder
328
- reason = "not_selected"
329
- elif not excerpt.strip():
330
- # Selected with nothing in it. Applying this would delete content
331
- # the service said to keep — refuse it and keep the original.
332
- after += approx_tokens(original_text)
333
- continue
334
- elif len(excerpt) >= len(original_text):
335
- # An "excerpt" that is not shorter buys nothing and may cost.
336
- after += approx_tokens(original_text)
337
- continue
335
+ if withhold:
336
+ new_text, reason = placeholder, "blocked"
338
337
  else:
339
- new_text = excerpt
340
- reason = "excerpt"
341
- if len(new_text) >= len(original_text) and reason == "not_selected":
342
- # A placeholder longer than the message it replaces. Rare, but a
343
- # short tool result plus a long custom placeholder gets here.
344
- after += approx_tokens(original_text)
345
- continue
338
+ rewrite = _rewrite_for(excerpts.get(record_id), original_text, placeholder)
339
+ if rewrite is None and protected_notice is None:
340
+ # Invariant 2 refused the rewrite: the original stays.
341
+ after += approx_tokens(original_text)
342
+ continue
343
+ if rewrite is None:
344
+ # Under workspace protection the original is the one thing
345
+ # that may not be installed. Withheld, with the notice.
346
+ new_text, reason = protected_notice, "protected"
347
+ else:
348
+ new_text, reason = rewrite
346
349
  out[index] = rewrite_message(original, new_text, reason=reason, request_id=request_id)
347
350
  after += approx_tokens(new_text)
348
351
  return out, before, after
349
352
 
350
353
 
354
+ def _rewrite_for(
355
+ excerpt: str | None, original_text: str, placeholder: str
356
+ ) -> tuple[str, str] | None:
357
+ """What replaces a message, as ``(text, reason)``, or ``None`` to refuse.
358
+
359
+ Invariant 2, at the mutation boundary. Every ``None`` here is a rewrite
360
+ that would grow the context or empty a message.
361
+ """
362
+ if excerpt is None:
363
+ if not placeholder.strip():
364
+ # A blank custom placeholder would rewrite a non-empty message to
365
+ # nothing — the pairing survives, the content does not.
366
+ return None
367
+ if len(placeholder) >= len(original_text):
368
+ # A placeholder longer than the message it replaces. Rare, but a
369
+ # short tool result plus a long custom placeholder gets here.
370
+ return None
371
+ # Not selected: collapse to the placeholder. This is the only place
372
+ # content is discarded, and it is the difference between selection
373
+ # and a no-op.
374
+ return placeholder, "not_selected"
375
+ if not excerpt.strip():
376
+ # Selected with nothing in it. Applying this would delete content the
377
+ # service said to keep.
378
+ return None
379
+ if len(excerpt) >= len(original_text):
380
+ # An "excerpt" that is not shorter buys nothing and may cost.
381
+ return None
382
+ return excerpt, "excerpt"
383
+
384
+
351
385
  def tool_call_keywords(args: Any, limit: int = 6, max_len: int = 120) -> list[str]:
352
386
  """Short string values from a tool call's arguments, as selection keywords.
353
387
 
@@ -399,6 +433,26 @@ def reject_removed_options(kwargs: dict[str, Any]) -> None:
399
433
  raise TypeError(message)
400
434
 
401
435
 
436
+ def withheld_notice(selection: SelectionResult, *, tool_name: str | None = None) -> str:
437
+ """The model-facing text installed for a record workspace policy withheld.
438
+
439
+ Built from the receipt only — the engine's category labels and the request
440
+ id — never from the record. The categories are the engine's own names for
441
+ what the policy matched (``scope:source:hr``, ``classifier:pii.us_ssn``);
442
+ they are wire fields, read here as labels, not evaluated.
443
+ """
444
+ response = selection.response
445
+ governance = response.extra.get("governance") if response is not None else None
446
+ counts = governance.get("category_counts") if isinstance(governance, dict) else None
447
+ categories = ", ".join(sorted(str(k) for k in counts)) if isinstance(counts, dict) else ""
448
+ what = f"the {tool_name} result" if tool_name else "this tool result"
449
+ why = f" ({categories})" if categories else ""
450
+ return (
451
+ f"[WITHHELD by workspace policy: {what} was not delivered to the model{why}. "
452
+ f"Request {selection.request_id}.]"
453
+ )
454
+
455
+
402
456
  __all__ = [
403
457
  "METADATA_KEY",
404
458
  "DEFAULT_PLACEHOLDER",
@@ -414,6 +468,7 @@ __all__ = [
414
468
  "prior_user_prompts",
415
469
  "rewrite_message",
416
470
  "apply_selection",
471
+ "withheld_notice",
417
472
  "tool_call_keywords",
418
473
  "REMOVED_OPTIONS",
419
474
  "reject_removed_options",
@@ -88,6 +88,7 @@ from typing import Any
88
88
  from needlepath import (
89
89
  AsyncNeedlepathClient,
90
90
  NeedlepathClient,
91
+ NeedlepathGuardrailError,
91
92
  Reason,
92
93
  SelectionResult,
93
94
  SelectionStats,
@@ -102,6 +103,7 @@ from ._messages import (
102
103
  latest_user_prompt,
103
104
  prior_user_prompts,
104
105
  reject_removed_options,
106
+ withheld_notice,
105
107
  )
106
108
  from .version import __version__
107
109
 
@@ -121,8 +123,8 @@ LLM_INPUT_MESSAGES = "llm_input_messages"
121
123
  class NeedlepathPreModelHook:
122
124
  """A callable ``pre_model_hook`` that selects context non-destructively.
123
125
 
124
- Returns ``{"llm_input_messages": [...]}`` on every pass without exception,
125
- and the list is never empty — see edges 2 and 3 in the module docstring.
126
+ Returns ``{"llm_input_messages": [...]}`` unless mandatory guardrail
127
+ enforcement fails. Returned lists are never empty; see edges 2 and 3.
126
128
 
127
129
  Instances are callable both synchronously and (via :meth:`ainvoke`)
128
130
  asynchronously; ``create_react_agent`` accepts any ``RunnableLike``, and a
@@ -194,7 +196,11 @@ class NeedlepathPreModelHook:
194
196
  selection_strategy=self.selection_strategy,
195
197
  support_contract=self.support_contract,
196
198
  )
197
- return self._apply(messages, plan, selection)
199
+ with selection.applying():
200
+ return self._apply(messages, plan, selection)
201
+ except NeedlepathGuardrailError:
202
+ # Mandatory protection failures must stop the framework call.
203
+ raise
198
204
  except Exception: # noqa: BLE001 - a defect here must not break the graph
199
205
  self._note(Reason.UNEXPECTED_ERROR)
200
206
  return self._always_write(messages)
@@ -204,6 +210,9 @@ class NeedlepathPreModelHook:
204
210
  messages = _messages_of(state)
205
211
  try:
206
212
  return await self._ainvoke(messages)
213
+ except NeedlepathGuardrailError:
214
+ # Mandatory protection failures must stop the framework call.
215
+ raise
207
216
  except Exception: # noqa: BLE001 - a defect here must not break the graph
208
217
  self._note(Reason.UNEXPECTED_ERROR)
209
218
  return self._always_write(messages)
@@ -237,7 +246,8 @@ class NeedlepathPreModelHook:
237
246
  support_contract=self.support_contract,
238
247
  ),
239
248
  )
240
- return self._apply(messages, plan, selection)
249
+ with selection.applying():
250
+ return self._apply(messages, plan, selection)
241
251
 
242
252
  # ------------------------------------------------------------------ #
243
253
 
@@ -299,8 +309,12 @@ class NeedlepathPreModelHook:
299
309
  self.engine_stats.record(selection)
300
310
  # `self.shadow` is belt and braces — the core client already declines a
301
311
  # shadow result — but "measuring never changes behaviour" is enforced at
302
- # the point of mutation rather than inherited.
303
- if not selection.applied or self.shadow:
312
+ # the point of mutation rather than inherited. What shadow does not
313
+ # suppress is a protected delivery (a blocked verdict, or a selection
314
+ # under workspace protection): shadow never restores a withheld record
315
+ # or a protected original, so it is installed below, shadow or not.
316
+ blocked = selection.reason == Reason.BLOCKED
317
+ if not selection.applied or (self.shadow and not selection.protected):
304
318
  # Nothing is installed on this path, so the ACTUAL before/after is
305
319
  # the size of what was considered, unchanged.
306
320
  candidate_tokens = sum(approx_tokens(r.text) for r in plan["records"])
@@ -308,14 +322,31 @@ class NeedlepathPreModelHook:
308
322
  _with_actual_tokens(selection, before=candidate_tokens, after=candidate_tokens)
309
323
  )
310
324
  return self._always_write(messages)
325
+ # Workspace protection: the engine's `governance` forbids raw-context
326
+ # fallback (a blocked verdict is one case of it). Nothing of the
327
+ # caller's original may be written, per record or in aggregate.
328
+ protected = not selection.raw_context_fallback_allowed
329
+ notice = withheld_notice(selection) if protected else None
311
330
  rewritten, before, after = apply_selection(
312
331
  messages,
313
332
  excerpts=selection.excerpts,
314
333
  index_by_id=plan["index_by_id"],
315
- placeholder=self.placeholder,
334
+ # A blocked verdict covers every record that was sent: each one
335
+ # collapses to the withheld notice, which names the policy's
336
+ # categories, rather than to the selection placeholder, which
337
+ # would claim a record was merely not selected.
338
+ placeholder=withheld_notice(selection) if blocked else self.placeholder,
316
339
  request_id=selection.request_id,
340
+ withhold=blocked,
341
+ # Any other protected delivery: a record whose rewrite the length
342
+ # guards refuse is withheld with the notice, never kept.
343
+ protected_notice=notice,
317
344
  )
318
- if after >= before:
345
+ if after >= before and not protected:
346
+ # The guard refuses a rewrite that saves nothing. Under protection
347
+ # it does not apply: the rewrite holds no original, a withheld
348
+ # record is not a saving, and declining would raise and stop a run
349
+ # the engine did not stop.
319
350
  declined = selection.declined(Reason.NO_SAVING)
320
351
  # Nothing is installed here either: `rewritten` is computed but
321
352
  # never written, so actual after == actual before.