b24api 2.4.2__tar.gz → 2.5.1__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 (132) hide show
  1. {b24api-2.4.2/b24api.egg-info → b24api-2.5.1}/PKG-INFO +11 -1
  2. {b24api-2.4.2 → b24api-2.5.1}/README.md +10 -0
  3. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/closure.py +3 -0
  4. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/gate.py +30 -1
  5. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/recorder.py +24 -4
  6. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/reference_recorder.py +9 -1
  7. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/__init__.py +2 -0
  8. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/completion.py +2 -0
  9. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/reference.py +8 -1
  10. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/traversal.py +46 -3
  11. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/binding.py +65 -5
  12. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/dispatch.py +3 -0
  13. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/facade.py +14 -0
  14. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/scheduler.py +4 -0
  15. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/support.py +11 -1
  16. {b24api-2.4.2 → b24api-2.5.1}/b24api/testing/transport.py +9 -1
  17. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/control_preflight.py +3 -0
  18. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/driver.py +13 -0
  19. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/facade.py +5 -2
  20. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/offset_rules.py +42 -3
  21. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/plans.py +32 -1
  22. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/sequential.py +13 -1
  23. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/stream.py +9 -2
  24. {b24api-2.4.2 → b24api-2.5.1/b24api.egg-info}/PKG-INFO +11 -1
  25. {b24api-2.4.2 → b24api-2.5.1}/LICENSE +0 -0
  26. {b24api-2.4.2 → b24api-2.5.1}/MANIFEST.in +0 -0
  27. {b24api-2.4.2 → b24api-2.5.1}/b24api/__init__.py +0 -0
  28. {b24api-2.4.2 → b24api-2.5.1}/b24api/_client_traversal.py +0 -0
  29. {b24api-2.4.2 → b24api-2.5.1}/b24api/_diagnostics.py +0 -0
  30. {b24api-2.4.2 → b24api-2.5.1}/b24api/_error_types.py +0 -0
  31. {b24api-2.4.2 → b24api-2.5.1}/b24api/_sources.py +0 -0
  32. {b24api-2.4.2 → b24api-2.5.1}/b24api/batch/__init__.py +0 -0
  33. {b24api-2.4.2 → b24api-2.5.1}/b24api/batch/engine.py +0 -0
  34. {b24api-2.4.2 → b24api-2.5.1}/b24api/batch/facade.py +0 -0
  35. {b24api-2.4.2 → b24api-2.5.1}/b24api/batch/logical.py +0 -0
  36. {b24api-2.4.2 → b24api-2.5.1}/b24api/batch/outcome.py +0 -0
  37. {b24api-2.4.2 → b24api-2.5.1}/b24api/batch/stream.py +0 -0
  38. {b24api-2.4.2 → b24api-2.5.1}/b24api/cli.py +0 -0
  39. {b24api-2.4.2 → b24api-2.5.1}/b24api/cli_contract.py +0 -0
  40. {b24api-2.4.2 → b24api-2.5.1}/b24api/client.py +0 -0
  41. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/__init__.py +0 -0
  42. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/fast_recorder.py +0 -0
  43. {b24api-2.4.2 → b24api-2.5.1}/b24api/completion/operation_stream.py +0 -0
  44. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/bounded_range.py +0 -0
  45. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/command.py +0 -0
  46. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/dispatch.py +0 -0
  47. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/error_base.py +0 -0
  48. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/evidence.py +0 -0
  49. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/identity_store.py +0 -0
  50. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/json.py +0 -0
  51. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/keyset_capability.py +0 -0
  52. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/keyset_execution.py +0 -0
  53. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/page.py +0 -0
  54. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/page_stop.py +0 -0
  55. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/policy.py +0 -0
  56. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/positional.py +0 -0
  57. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/report.py +0 -0
  58. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/request.py +0 -0
  59. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/request_summary.py +0 -0
  60. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/response.py +0 -0
  61. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/stream.py +0 -0
  62. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/v3_codes.py +0 -0
  63. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/violation.py +0 -0
  64. {b24api-2.4.2 → b24api-2.5.1}/b24api/contracts/wire.py +0 -0
  65. {b24api-2.4.2 → b24api-2.5.1}/b24api/encoding.py +0 -0
  66. {b24api-2.4.2 → b24api-2.5.1}/b24api/errors.py +0 -0
  67. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/__init__.py +0 -0
  68. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/boundary.py +0 -0
  69. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/cleanup.py +0 -0
  70. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/context.py +0 -0
  71. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/executor.py +0 -0
  72. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/failure.py +0 -0
  73. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/lifecycle.py +0 -0
  74. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/rate.py +0 -0
  75. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/snapshot.py +0 -0
  76. {b24api-2.4.2 → b24api-2.5.1}/b24api/execution/throttle.py +0 -0
  77. {b24api-2.4.2 → b24api-2.5.1}/b24api/migration.py +0 -0
  78. {b24api-2.4.2 → b24api-2.5.1}/b24api/py.typed +0 -0
  79. {b24api-2.4.2 → b24api-2.5.1}/b24api/redaction.py +0 -0
  80. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/__init__.py +0 -0
  81. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/dispatch_plan.py +0 -0
  82. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/fanout.py +0 -0
  83. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/outcome.py +0 -0
  84. {b24api-2.4.2 → b24api-2.5.1}/b24api/references/stream.py +0 -0
  85. {b24api-2.4.2 → b24api-2.5.1}/b24api/settings.py +0 -0
  86. {b24api-2.4.2 → b24api-2.5.1}/b24api/testing/__init__.py +0 -0
  87. {b24api-2.4.2 → b24api-2.5.1}/b24api/testing/_isolation.py +0 -0
  88. {b24api-2.4.2 → b24api-2.5.1}/b24api/testing/scripted.py +0 -0
  89. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/__init__.py +0 -0
  90. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/base.py +0 -0
  91. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/decoding.py +0 -0
  92. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/httpx.py +0 -0
  93. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/log_records.py +0 -0
  94. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/logging_shield.py +0 -0
  95. {b24api-2.4.2 → b24api-2.5.1}/b24api/transport/protocol.py +0 -0
  96. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/__init__.py +0 -0
  97. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/counted.py +0 -0
  98. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/counted_batch.py +0 -0
  99. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/counted_rules.py +0 -0
  100. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/cursor.py +0 -0
  101. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/cursor_domain.py +0 -0
  102. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/facade_support.py +0 -0
  103. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/identity.py +0 -0
  104. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/identity_ledger.py +0 -0
  105. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset.py +0 -0
  106. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_auto.py +0 -0
  107. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_capability.py +0 -0
  108. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_eligibility.py +0 -0
  109. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_fast_plan.py +0 -0
  110. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_fast_stream.py +0 -0
  111. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_geometry.py +0 -0
  112. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_observation.py +0 -0
  113. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_ordered_admission.py +0 -0
  114. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_page_validation.py +0 -0
  115. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_plan.py +0 -0
  116. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_reporting.py +0 -0
  117. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_runtime.py +0 -0
  118. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_step.py +0 -0
  119. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_transaction_contract.py +0 -0
  120. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_transactions.py +0 -0
  121. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/keyset_verifier.py +0 -0
  122. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/page_adaptation.py +0 -0
  123. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/sparse.py +0 -0
  124. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/strategy_context.py +0 -0
  125. {b24api-2.4.2 → b24api-2.5.1}/b24api/traversal/values.py +0 -0
  126. {b24api-2.4.2 → b24api-2.5.1}/b24api.egg-info/SOURCES.txt +0 -0
  127. {b24api-2.4.2 → b24api-2.5.1}/b24api.egg-info/dependency_links.txt +0 -0
  128. {b24api-2.4.2 → b24api-2.5.1}/b24api.egg-info/entry_points.txt +0 -0
  129. {b24api-2.4.2 → b24api-2.5.1}/b24api.egg-info/requires.txt +0 -0
  130. {b24api-2.4.2 → b24api-2.5.1}/b24api.egg-info/top_level.txt +0 -0
  131. {b24api-2.4.2 → b24api-2.5.1}/pyproject.toml +0 -0
  132. {b24api-2.4.2 → b24api-2.5.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 2.4.2
3
+ Version: 2.5.1
4
4
  Summary: Bitrix24 API
5
5
  Author-email: Shkarupa Alex <shkarupa.alex@gmail.com>
6
6
  License-Expression: MIT
@@ -269,6 +269,12 @@ async with stream:
269
269
  Without `identity`, successful exhaustion is reported as `MECHANICS_ONLY`: pagination completed,
270
270
  but the client cannot prove that the portal did not duplicate or substitute rows.
271
271
 
272
+ An endpoint that honors `start` only at a fixed server window and signals its end only with a
273
+ shorter page (no usable `total`, no `next`) can declare that stop rule with
274
+ `OffsetSpec(short_page_termination=ShortPageTermination.DECLARED_TERMINAL)`; its successful
275
+ exhaustion is always `MECHANICS_ONLY`. See
276
+ [Declared short-page closure](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#declared-short-page-closure).
277
+
272
278
  Mapping-backed collections are explicit as well. `MAPPING_VALUES` yields values from a selected
273
279
  mapping in insertion order; `MAPPING_VALUES_OR_EMPTY` additionally accepts only an empty terminal
274
280
  sequence and records that degradation in the operation report.
@@ -469,6 +475,10 @@ async with stream:
469
475
  record_completion(event.correlation, event.row_count)
470
476
  ```
471
477
 
478
+ With `KeysetTraversal`, a binding may also set simple constant fields directly inside the keyset
479
+ filter, such as `filter[=ownerId]`, while each binding keeps its own cursor; see
480
+ [Keyset references with per-owner filters](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#keyset-references-with-per-owner-filters).
481
+
472
482
  For messages across chats, use the same `iter_references()` shape: each binding updates the chat
473
483
  parameter and carries the chat correlation; choose `CursorTraversal` when the message endpoint is
474
484
  cursor-based. Identity tracking and completion remain scoped to each binding, so equal child IDs
@@ -238,6 +238,12 @@ async with stream:
238
238
  Without `identity`, successful exhaustion is reported as `MECHANICS_ONLY`: pagination completed,
239
239
  but the client cannot prove that the portal did not duplicate or substitute rows.
240
240
 
241
+ An endpoint that honors `start` only at a fixed server window and signals its end only with a
242
+ shorter page (no usable `total`, no `next`) can declare that stop rule with
243
+ `OffsetSpec(short_page_termination=ShortPageTermination.DECLARED_TERMINAL)`; its successful
244
+ exhaustion is always `MECHANICS_ONLY`. See
245
+ [Declared short-page closure](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#declared-short-page-closure).
246
+
241
247
  Mapping-backed collections are explicit as well. `MAPPING_VALUES` yields values from a selected
242
248
  mapping in insertion order; `MAPPING_VALUES_OR_EMPTY` additionally accepts only an empty terminal
243
249
  sequence and records that degradation in the operation report.
@@ -438,6 +444,10 @@ async with stream:
438
444
  record_completion(event.correlation, event.row_count)
439
445
  ```
440
446
 
447
+ With `KeysetTraversal`, a binding may also set simple constant fields directly inside the keyset
448
+ filter, such as `filter[=ownerId]`, while each binding keeps its own cursor; see
449
+ [Keyset references with per-owner filters](https://github.com/shkarupa-alex/b24api/blob/master/docs/recipes.md#keyset-references-with-per-owner-filters).
450
+
441
451
  For messages across chats, use the same `iter_references()` shape: each binding updates the chat
442
452
  parameter and carries the chat correlation; choose `CursorTraversal` when the message endpoint is
443
453
  cursor-based. Identity tracking and completion remain scoped to each binding, so equal child IDs
@@ -14,6 +14,7 @@ SINGLE_RESPONSE_COMPLETE = "single response complete"
14
14
  QUALIFIED_TOTAL_REACHED = "qualified total reached"
15
15
  SPARSE_RAW_RANGE_COVERED = "qualified sparse raw range covered"
16
16
  ADMITTED_UPPER_BOUNDARY_REACHED = "exact admitted upper boundary reached"
17
+ DECLARED_SHORT_PAGE_REACHED = "declared short page reached"
17
18
 
18
19
 
19
20
  def qualified_closure(terminal_reason: str | None) -> BindingClosure | None:
@@ -27,5 +28,7 @@ def qualified_closure(terminal_reason: str | None) -> BindingClosure | None:
27
28
  return BindingClosure.RAW_RANGE_COVERED
28
29
  case "exact admitted upper boundary reached":
29
30
  return BindingClosure.BOUNDARY_SEEN
31
+ case "declared short page reached":
32
+ return BindingClosure.DECLARED_SHORT_PAGE
30
33
  case _:
31
34
  return None
@@ -31,6 +31,7 @@ from b24api.contracts.report import (
31
31
  Violation,
32
32
  ViolationSeverity,
33
33
  )
34
+ from b24api.contracts.traversal import DECLARED_SHORT_PAGE_MINIMUM_WIDTH
34
35
  from b24api.contracts.violation import retain_violations
35
36
 
36
37
  if TYPE_CHECKING:
@@ -114,8 +115,15 @@ def _binding_closure_violation(binding: _Binding, event: BindingTerminal) -> str
114
115
  "completion_unknown_binding_claimed_known",
115
116
  ),
116
117
  (
118
+ # A declared short-page closure reports a missing page as its own witness failure below.
117
119
  binding.last_page_id < 0
118
- and closure not in {BindingClosure.CALLER_STOP, BindingClosure.FAILURE, BindingClosure.UNKNOWN},
120
+ and closure
121
+ not in {
122
+ BindingClosure.CALLER_STOP,
123
+ BindingClosure.FAILURE,
124
+ BindingClosure.UNKNOWN,
125
+ BindingClosure.DECLARED_SHORT_PAGE,
126
+ },
119
127
  "completion_terminal_lacks_page_witness",
120
128
  ),
121
129
  (
@@ -150,10 +158,31 @@ def _binding_closure_violation(binding: _Binding, event: BindingTerminal) -> str
150
158
  ),
151
159
  "completion_missing_keyset_plan_witness",
152
160
  ),
161
+ (
162
+ closure is BindingClosure.DECLARED_SHORT_PAGE and not _declared_short_page_witnessed(binding, event),
163
+ "completion_invalid_declared_short_page_witness",
164
+ ),
165
+ (
166
+ closure is not BindingClosure.DECLARED_SHORT_PAGE and event.declared_short_page_width is not None,
167
+ "completion_unexpected_declared_short_page_width",
168
+ ),
153
169
  )
154
170
  return next((code for failed, code in checks if failed), None)
155
171
 
156
172
 
173
+ def _declared_short_page_witnessed(binding: _Binding, event: BindingTerminal) -> bool:
174
+ """Require the last acknowledged page to be non-empty and shorter than the declared window."""
175
+ width = event.declared_short_page_width
176
+ rows = binding.last_acknowledged_rows
177
+ return (
178
+ type(width) is int
179
+ and width >= DECLARED_SHORT_PAGE_MINIMUM_WIDTH
180
+ and binding.acknowledged_pages > 0
181
+ and rows is not None
182
+ and 0 < rows < width
183
+ )
184
+
185
+
157
186
  class CompletionGate:
158
187
  """Accept ordered evidence with O(active bindings + in-flight pages) memory."""
159
188
 
@@ -48,9 +48,14 @@ class CompletionSink(Protocol):
48
48
  class CompletionRecorder:
49
49
  """Emit bounded correlated events in the physical traversal lifecycle."""
50
50
 
51
- def __init__(self) -> None:
52
- """Admit the sole binding before its first possible page dispatch."""
51
+ def __init__(self, *, declared_short_page_width: int | None = None) -> None:
52
+ """Admit the sole binding before its first possible page dispatch.
53
+
54
+ ``declared_short_page_width`` is the window of a caller-declared short-page closure; it is
55
+ witnessed only when the binding actually closes on such a page.
56
+ """
53
57
  self.gate = CompletionGate(uuid4().hex)
58
+ self._declared_short_page_width = declared_short_page_width
54
59
  self._sequence = 0
55
60
  self._next_page = 0
56
61
  self._current: int | None = None
@@ -153,7 +158,14 @@ class CompletionRecorder:
153
158
  )
154
159
  self._current = None
155
160
 
156
- def terminal(self, closure: BindingClosure, stream: StreamClosure, *, qualified_total: int | None = None) -> None:
161
+ def terminal(
162
+ self,
163
+ closure: BindingClosure,
164
+ stream: StreamClosure,
165
+ *,
166
+ qualified_total: int | None = None,
167
+ declared_short_page_width: int | None = None,
168
+ ) -> None:
157
169
  """Settle binding and producer before owned-resource cleanup."""
158
170
  self.gate.emit(
159
171
  BindingTerminal(
@@ -162,6 +174,7 @@ class CompletionRecorder:
162
174
  binding_id=0,
163
175
  closure=closure,
164
176
  qualified_total=qualified_total,
177
+ declared_short_page_width=declared_short_page_width,
165
178
  )
166
179
  )
167
180
  self.gate.emit(
@@ -201,7 +214,14 @@ class CompletionRecorder:
201
214
  if state is KernelState.CANCELLED
202
215
  else StreamClosure.EARLY_CLOSE
203
216
  )
204
- self.terminal(closure, stream, qualified_total=qualified_total)
217
+ self.terminal(
218
+ closure,
219
+ stream,
220
+ qualified_total=qualified_total,
221
+ declared_short_page_width=(
222
+ self._declared_short_page_width if closure is BindingClosure.DECLARED_SHORT_PAGE else None
223
+ ),
224
+ )
205
225
 
206
226
  def cleanup(self, state: CleanupState) -> None:
207
227
  """Finish only after cleanup was observed."""
@@ -64,7 +64,14 @@ class ReferenceCompletionRecorder:
64
64
  """Return the active adapter for one admitted binding."""
65
65
  return self._bindings[binding_id]
66
66
 
67
- def terminal(self, binding_id: int, closure: BindingClosure, *, qualified_total: int | None = None) -> None:
67
+ def terminal(
68
+ self,
69
+ binding_id: int,
70
+ closure: BindingClosure,
71
+ *,
72
+ qualified_total: int | None = None,
73
+ declared_short_page_width: int | None = None,
74
+ ) -> None:
68
75
  """Retire one accounted binding after its final outcome is delivered."""
69
76
  self.gate.emit(
70
77
  BindingTerminal(
@@ -73,6 +80,7 @@ class ReferenceCompletionRecorder:
73
80
  binding_id=binding_id,
74
81
  closure=closure,
75
82
  qualified_total=qualified_total,
83
+ declared_short_page_width=declared_short_page_width,
76
84
  )
77
85
  )
78
86
  del self._bindings[binding_id]
@@ -136,6 +136,7 @@ from b24api.contracts.traversal import (
136
136
  PageStride,
137
137
  RawTotalSource,
138
138
  SequentialTraversal,
139
+ ShortPageTermination,
139
140
  SparseRawBound,
140
141
  SplitOrderSpec,
141
142
  TotalTermination,
@@ -265,6 +266,7 @@ __all__ = [
265
266
  "RouteKind",
266
267
  "SequentialKeysetExecution",
267
268
  "SequentialTraversal",
269
+ "ShortPageTermination",
268
270
  "SlotContract",
269
271
  "SlotShape",
270
272
  "SparseRawBound",
@@ -26,6 +26,7 @@ class BindingClosure(StrEnum):
26
26
  SINGLE_RESPONSE = "single_response"
27
27
  BOUNDARY_SEEN = "boundary_seen"
28
28
  KEYSET_PLAN_COVERED = "keyset_plan_covered"
29
+ DECLARED_SHORT_PAGE = "declared_short_page"
29
30
  CALLER_STOP = "caller_stop"
30
31
  FAILURE = "failure"
31
32
  UNKNOWN = "unknown"
@@ -142,6 +143,7 @@ class BindingTerminal(CompletionEvent):
142
143
  closure: BindingClosure
143
144
  qualified_total: int | None = None
144
145
  qualified_witnesses: int | None = None
146
+ declared_short_page_width: int | None = None
145
147
 
146
148
 
147
149
  @dataclass(frozen=True, slots=True, kw_only=True)
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
13
13
  from collections.abc import Iterable
14
14
 
15
15
  from b24api.contracts.command import NotExecutedReason
16
+ from b24api.contracts.completion import BindingClosure
16
17
  from b24api.contracts.error_base import B24ApiError
17
18
 
18
19
  _SUMMARY_MAXIMUM = 256
@@ -83,13 +84,19 @@ class ReferenceItem[C]:
83
84
 
84
85
  @dataclass(frozen=True, slots=True)
85
86
  class ReferenceComplete[C]:
86
- """Successful binding terminal, with source-exhaustion evidence distinguished."""
87
+ """Successful binding terminal, with source-exhaustion evidence distinguished.
88
+
89
+ ``closure`` names the witness that ended this binding (for example ``SOURCE_EMPTY`` or
90
+ ``DECLARED_SHORT_PAGE``); it is excluded from equality and hashing, so comparisons by the
91
+ other fields keep their meaning, and it is ``None`` only on a value built without it.
92
+ """
87
93
 
88
94
  binding_index: int
89
95
  correlation: C = field(repr=False)
90
96
  row_count: int
91
97
  exhausted: bool = True
92
98
  stop_reason: str | None = None
99
+ closure: BindingClosure | None = field(default=None, compare=False)
93
100
 
94
101
 
95
102
  @dataclass(frozen=True, slots=True)
@@ -23,6 +23,9 @@ _ORDER = ParameterPath(("order",))
23
23
  _ROOT_SELECTOR = ResultSelector.root()
24
24
  _SEQUENTIAL_KEYSET_EXECUTION = SequentialKeysetExecution()
25
25
  _IDENTITY_PAGE_ADAPTER = IdentityPageAdapter()
26
+ # A one-row window has no short page: every non-empty page would be full. The contract, the offset plan and the
27
+ # completion gate all enforce this one bound, so they share this single definition.
28
+ DECLARED_SHORT_PAGE_MINIMUM_WIDTH = 2
26
29
 
27
30
 
28
31
  class OffsetContinuation(StrEnum):
@@ -41,6 +44,22 @@ class TotalTermination(StrEnum):
41
44
  EXACT_QUALIFIED = "exact_qualified"
42
45
 
43
46
 
47
+ class ShortPageTermination(StrEnum):
48
+ """Whether a short page may end a fixed-step offset traversal.
49
+
50
+ Some endpoints honor ``start`` only at a fixed server window, report no usable total and send no
51
+ ``next``, so the only end signal is a page shorter than that window. The client cannot prove
52
+ that from the wire: a short page may also mean a truncated or misbehaving response. ``DISABLED``
53
+ therefore keeps refusing an unexplained short page. ``DECLARED_TERMINAL`` is the caller's own
54
+ qualification of the endpoint: a non-empty page shorter than the declared window closes the
55
+ traversal as exhausted. That result proves the declared stop rule was met, not that the
56
+ snapshot is complete, so its assurance stays ``MECHANICS_ONLY``.
57
+ """
58
+
59
+ DISABLED = "disabled"
60
+ DECLARED_TERMINAL = "declared_terminal"
61
+
62
+
44
63
  class CursorDomain(StrEnum):
45
64
  """How a cursor control is interpreted by its qualified endpoint."""
46
65
 
@@ -161,6 +180,27 @@ def _validate_offset_extensions(spec: OffsetSpec) -> None: # noqa: C901 - close
161
180
  raise ValueError("sparse raw bound requires its declared page_stride")
162
181
  if spec.total_termination is not TotalTermination.DISABLED:
163
182
  raise ValueError("sparse raw bound owns closure independently of selected count")
183
+ if spec.short_page_termination is ShortPageTermination.DECLARED_TERMINAL:
184
+ _validate_declared_short_page(spec)
185
+
186
+
187
+ def _validate_declared_short_page(spec: OffsetSpec) -> None:
188
+ """Admit a declared short-page closure only over one exact fixed wire window."""
189
+ if (
190
+ spec.continuation is not OffsetContinuation.FIXED_STEP
191
+ or spec.total_termination is not TotalTermination.DISABLED
192
+ ):
193
+ raise ValueError("declared short-page termination requires fixed-step continuation without a total")
194
+ if spec.page_index is not None or spec.sparse_raw_bound is not None:
195
+ raise ValueError("declared short-page termination cannot combine with page_index or a sparse raw bound")
196
+ stride = spec.page_stride
197
+ if stride is None:
198
+ raise ValueError("declared short-page termination requires a qualified page_stride")
199
+ width = stride.max_decoded_rows
200
+ if not spec.step == stride.wire_increment == width or width < DECLARED_SHORT_PAGE_MINIMUM_WIDTH:
201
+ raise ValueError("declared short-page termination requires step equal to one decoded window of at least 2")
202
+ if stride.requested_wire_limit is not None and stride.requested_wire_limit != width:
203
+ raise ValueError("declared short-page termination requires a requested wire limit equal to the window")
164
204
 
165
205
 
166
206
  @dataclass(frozen=True, slots=True)
@@ -176,12 +216,14 @@ class OffsetSpec:
176
216
  page_index: PageIndex | None = None
177
217
  page_stride: PageStride | None = None
178
218
  sparse_raw_bound: SparseRawBound | None = None
219
+ short_page_termination: ShortPageTermination = ShortPageTermination.DISABLED
179
220
 
180
221
  def __post_init__(self) -> None:
181
222
  """Validate offset mechanics and completion semantics."""
182
- if not isinstance(self.continuation, OffsetContinuation) or not isinstance(
183
- self.total_termination,
184
- TotalTermination,
223
+ if (
224
+ not isinstance(self.continuation, OffsetContinuation)
225
+ or not isinstance(self.total_termination, TotalTermination)
226
+ or not isinstance(self.short_page_termination, ShortPageTermination)
185
227
  ):
186
228
  raise TypeError("offset controls must use their declared enum types")
187
229
  _validate_offset_extensions(self)
@@ -402,6 +444,7 @@ __all__ = [
402
444
  "OffsetContinuation",
403
445
  "OffsetSpec",
404
446
  "SequentialTraversal",
447
+ "ShortPageTermination",
405
448
  "SplitOrderSpec",
406
449
  "TotalTermination",
407
450
  "TraversalSpec",
@@ -9,7 +9,7 @@ from b24api._sources import OwnedSource
9
9
  from b24api.contracts.command import NotExecutedReason
10
10
  from b24api.contracts.json import _freeze_json
11
11
  from b24api.contracts.reference import Binding
12
- from b24api.contracts.traversal import CursorTraversal, TraversalSpec, traversal_control_paths
12
+ from b24api.contracts.traversal import CursorTraversal, KeysetTraversal, TraversalSpec, traversal_control_paths
13
13
  from b24api.errors import CapabilityError, InputSourceError, PaginationError
14
14
  from b24api.references.outcome import ReferenceRequest
15
15
  from b24api.traversal.cursor_domain import validate_cursor_value
@@ -18,6 +18,7 @@ from b24api.traversal.values import _coerce_identity
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from b24api.contracts.json import JsonValue
21
+ from b24api.contracts.reference import ParameterUpdate
21
22
  from b24api.contracts.report import Violation
22
23
  from b24api.contracts.request import ParameterPath, Request
23
24
 
@@ -51,12 +52,71 @@ def _overlaps(left: tuple[str | int, ...], right: tuple[str | int, ...]) -> bool
51
52
  return left[:shared] == right[:shared]
52
53
 
53
54
 
55
+ _CONTROL_COLLISION = "binding update collides with a traversal control path"
56
+ _FILTER_COMBINATORS = frozenset({"logic", "and", "or"})
57
+
58
+
59
+ def binding_update_conflict(traversal: TraversalSpec, update: ParameterUpdate) -> str | None:
60
+ """Return a stable local-validation reason, or None when this update may compose.
61
+
62
+ Every traversal control path stays exclusive, except that a keyset binding may set one simple
63
+ sibling field directly inside ``KeysetSpec.filter_path`` (for example ``=ownerId``): the
64
+ traversal writes only its own cursor key there, so a constant beside it composes with the
65
+ binding's own cursor. ``traversal_control_paths`` names the containers a traversal writes and
66
+ does not decide admission.
67
+ """
68
+ update_path = _normalized(update.path)
69
+ filter_path = _normalized(traversal.keyset.filter_path) if isinstance(traversal, KeysetTraversal) else None
70
+ for control in traversal_control_paths(traversal):
71
+ normalized = _normalized(control)
72
+ if normalized != filter_path and _overlaps(update_path, normalized):
73
+ return _CONTROL_COLLISION
74
+ if not isinstance(traversal, KeysetTraversal) or filter_path is None or not _overlaps(update_path, filter_path):
75
+ return None
76
+ return _keyset_filter_conflict(traversal, update, len(filter_path))
77
+
78
+
79
+ def _keyset_filter_conflict(traversal: KeysetTraversal, update: ParameterUpdate, depth: int) -> str | None:
80
+ """Admit one scalar sibling field of the keyset filter whose name differs from the cursor field."""
81
+ parts = update.path.path
82
+ if len(parts) != depth + 1:
83
+ return "binding may set only a direct field inside the keyset filter"
84
+ cursor_field = traversal.identity.filter_key
85
+ if not cursor_field.isidentifier():
86
+ # An operator-bearing cursor key cannot be compared by field name, so nothing may sit beside it.
87
+ return "keyset filter key is not a simple field name"
88
+ field = _filter_field(parts[-1])
89
+ if field is None or field.casefold() in _FILTER_COMBINATORS:
90
+ return "binding keyset filter key is not a simple field"
91
+ if field.casefold() == cursor_field.casefold():
92
+ return "binding keyset filter key names the cursor field"
93
+ if not _flat_scalar(update.value):
94
+ return "binding keyset filter value is not a scalar or a flat scalar list"
95
+ return None
96
+
97
+
98
+ def _filter_field(key: str | int) -> str | None:
99
+ """Strip a leading filter operator and return the remaining simple field name, if any."""
100
+ if not isinstance(key, str):
101
+ return None
102
+ index = 0
103
+ while index < len(key) and not (key[index].isalnum() or key[index] == "_"):
104
+ index += 1
105
+ field = key[index:]
106
+ return field if field.isidentifier() else None
107
+
108
+
109
+ def _flat_scalar(value: JsonValue) -> bool:
110
+ if isinstance(value, list):
111
+ return all(not isinstance(item, dict | list) for item in value)
112
+ return not isinstance(value, dict)
113
+
114
+
54
115
  def _validate_binding_controls(binding: Binding[object], traversal: TraversalSpec) -> None:
55
- controls = tuple(_normalized(path) for path in traversal_control_paths(traversal))
56
116
  for update in binding.updates:
57
- update_path = _normalized(update.path)
58
- if any(_overlaps(update_path, control) for control in controls):
59
- raise ValueError("binding update collides with a traversal control path")
117
+ reason = binding_update_conflict(traversal, update)
118
+ if reason is not None:
119
+ raise ValueError(reason)
60
120
 
61
121
 
62
122
  def _matching_key(mapping: dict[str, JsonValue], requested: str) -> str | None:
@@ -25,6 +25,7 @@ from b24api.references.outcome import (
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from b24api.contracts.command import NotExecutedReason
28
+ from b24api.contracts.completion import BindingClosure
28
29
  from b24api.contracts.json import FrozenJson, JsonValue
29
30
  from b24api.contracts.report import PageRecord, Violation
30
31
  from b24api.contracts.request import Request
@@ -83,6 +84,7 @@ class _DoneEvent:
83
84
  stopped_reason: str | None = None
84
85
  terminal_reason: str | None = None
85
86
  qualified_total: int | None = None
87
+ declared_short_page_width: int | None = None
86
88
 
87
89
 
88
90
  @dataclass(frozen=True, slots=True)
@@ -106,6 +108,7 @@ class _KernelReferenceComplete:
106
108
  reference: ReferenceRequest
107
109
  row_count: int
108
110
  stopped_reason: str | None = None
111
+ closure: BindingClosure | None = None
109
112
 
110
113
 
111
114
  @dataclass(frozen=True, slots=True)
@@ -23,6 +23,7 @@ from b24api.contracts.reference import (
23
23
  ReferenceOutcome,
24
24
  ReferenceOutcomeUnknown,
25
25
  )
26
+ from b24api.contracts.report import TraversalAssurance
26
27
  from b24api.contracts.request import (
27
28
  IdentitySpec,
28
29
  Request,
@@ -35,6 +36,7 @@ from b24api.contracts.traversal import (
35
36
  CountedTraversal,
36
37
  KeysetTraversal,
37
38
  SequentialTraversal,
39
+ ShortPageTermination,
38
40
  TraversalSpec,
39
41
  )
40
42
  from b24api.errors import (
@@ -221,6 +223,7 @@ class _ReferenceEventMapper:
221
223
  event.row_count,
222
224
  exhausted=event.stopped_reason is None,
223
225
  stop_reason=event.stopped_reason,
226
+ closure=event.closure,
224
227
  )
225
228
  context = cast("_BindingContext", event.correlation)
226
229
  self._item_indexes.pop(context.index, None)
@@ -256,6 +259,16 @@ class _ReferenceEventMapper:
256
259
  )
257
260
 
258
261
 
262
+ def _reference_assurance(traversal: TraversalSpec) -> TraversalAssurance | None:
263
+ """Declare mechanics only for a declared short-page closure: it proves the stop rule, not empty later windows."""
264
+ if (
265
+ isinstance(traversal, SequentialTraversal)
266
+ and traversal.offset.short_page_termination is ShortPageTermination.DECLARED_TERMINAL
267
+ ):
268
+ return TraversalAssurance.MECHANICS_ONLY
269
+ return None
270
+
271
+
259
272
  def _reference_variant(outcome: ReferenceOutcome[object]) -> str:
260
273
  if isinstance(outcome, ReferenceItem):
261
274
  return "item"
@@ -377,6 +390,7 @@ def reference_stream[C](
377
390
  source,
378
391
  mapper,
379
392
  operation="iter_reference_outcomes" if tolerant else "iter_references",
393
+ assurance=_reference_assurance(traversal),
380
394
  classify=_reference_variant,
381
395
  error_mapper=lambda error, report: _reference_error(error, report, mapper),
382
396
  error_items=lambda error: _reference_error_items(error, mapper),
@@ -47,6 +47,8 @@ from b24api.references.outcome import (
47
47
  )
48
48
  from b24api.references.support import (
49
49
  _active_limit,
50
+ _declared_short_page_width,
51
+ _done_closure,
50
52
  _finish_done_completion,
51
53
  _finish_task,
52
54
  _new_page_records,
@@ -392,6 +394,7 @@ class ReferenceScheduler:
392
394
  run.stopped_reason,
393
395
  driver.terminal_reason,
394
396
  driver.expected_total,
397
+ _declared_short_page_width(self.plan, driver.terminal_reason),
395
398
  ),
396
399
  )
397
400
  except asyncio.CancelledError:
@@ -652,6 +655,7 @@ class ReferenceScheduler:
652
655
  event.work.reference,
653
656
  event.row_count,
654
657
  event.stopped_reason,
658
+ _done_closure(event),
655
659
  )
656
660
  return
657
661
  request = event.work.reference.request
@@ -5,7 +5,7 @@ import asyncio
5
5
  import contextlib
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- from b24api.completion.closure import qualified_closure
8
+ from b24api.completion.closure import DECLARED_SHORT_PAGE_REACHED, qualified_closure
9
9
  from b24api.contracts.completion import BindingClosure
10
10
  from b24api.contracts.report import PageRecord, Violation, ViolationSeverity
11
11
  from b24api.traversal.plans import (
@@ -33,6 +33,13 @@ def _done_closure(event: _DoneEvent) -> BindingClosure:
33
33
  return qualified_closure(event.terminal_reason) or BindingClosure.SOURCE_EMPTY
34
34
 
35
35
 
36
+ def _declared_short_page_width(plan: ListPlan, terminal_reason: str | None) -> int | None:
37
+ """Return the declared window a reference's short-page closure is witnessed against, else None."""
38
+ if isinstance(plan, OffsetSequentialPlan) and terminal_reason == DECLARED_SHORT_PAGE_REACHED:
39
+ return plan.short_page_width
40
+ return None
41
+
42
+
36
43
  def _finish_done_completion(completion: ReferenceCompletionRecorder, event: _DoneEvent) -> None:
37
44
  """Retire an acknowledged reference with its qualified closure evidence."""
38
45
  completion.binding(event.work.index).complete_omitted_empty()
@@ -41,6 +48,9 @@ def _finish_done_completion(completion: ReferenceCompletionRecorder, event: _Don
41
48
  event.work.index,
42
49
  closure,
43
50
  qualified_total=event.qualified_total if closure is BindingClosure.QUALIFIED_TOTAL else None,
51
+ declared_short_page_width=(
52
+ event.declared_short_page_width if closure is BindingClosure.DECLARED_SHORT_PAGE else None
53
+ ),
44
54
  )
45
55
 
46
56
 
@@ -242,7 +242,7 @@ def _failure_detail(error: BaseException | None) -> str | None:
242
242
 
243
243
  async def _await_bounded[T](awaitable: Awaitable[T], *, seconds: float, detail: str) -> T:
244
244
  """Await extension code without waiting indefinitely for cancellation cooperation."""
245
- task = asyncio.ensure_future(awaitable)
245
+ task = asyncio.ensure_future(_contain_process_aborts(awaitable))
246
246
  try:
247
247
  done, _ = await asyncio.wait({task}, timeout=seconds)
248
248
  except asyncio.CancelledError:
@@ -256,6 +256,14 @@ async def _await_bounded[T](awaitable: Awaitable[T], *, seconds: float, detail:
256
256
  raise IsolationDeadlineError(detail)
257
257
 
258
258
 
259
+ async def _contain_process_aborts[T](awaitable: Awaitable[T]) -> T:
260
+ """Convert process aborts before Task re-raises them into and stops the isolated loop."""
261
+ try:
262
+ return await awaitable
263
+ except (KeyboardInterrupt, SystemExit) as error:
264
+ raise IsolationAbortError(type(error).__name__) from None
265
+
266
+
259
267
  def _consume_detached_task[T](task: asyncio.Future[T]) -> None:
260
268
  """Retrieve any eventual exception from cancellation-resistant extension code."""
261
269
  if not task.done():
@@ -27,6 +27,9 @@ def _aligned_initial_offset(driver: StrategyContext, plan: OffsetSequentialPlan)
27
27
  if stride is not None and initial_offset % stride.server_granularity:
28
28
  # The server would silently serve the floor window, repeating or skipping raw rows.
29
29
  raise CapabilityError("initial offset must align with the qualified server page granularity")
30
+ if initial_offset != 0 and OffsetTerminalRule.DECLARED_SHORT_PAGE in plan.terminal:
31
+ # A declared short page closes only the collection read from its first window.
32
+ raise CapabilityError("declared short-page traversal must start at offset zero")
30
33
  if initial_offset != plan.initial_control and OffsetTerminalRule.QUALIFIED_TOTAL in plan.terminal:
31
34
  # The exact total counts the whole collection; a suffix never reaches it and would end incomplete.
32
35
  raise CapabilityError(
@@ -14,6 +14,7 @@ from b24api.contracts.policy import (
14
14
  ExecutionPolicy,
15
15
  IdentityCoercion,
16
16
  IdentityRequirement,
17
+ SnapshotRequirement,
17
18
  TotalSemantics,
18
19
  )
19
20
  from b24api.contracts.report import (
@@ -68,6 +69,7 @@ from b24api.traversal.plans import (
68
69
  KeysetTerminalRule,
69
70
  ListPlan,
70
71
  OffsetSequentialPlan,
72
+ OffsetTerminalRule,
71
73
  SingleResponsePlan,
72
74
  )
73
75
  from b24api.traversal.sequential import CountedStrategy, OffsetStrategy, SingleResponseStrategy
@@ -391,6 +393,17 @@ class PaginationDriver:
391
393
  )
392
394
  if isinstance(plan, KeysetPlan) and plan.terminal is KeysetTerminalRule.BOUNDARY_ID_SEEN:
393
395
  raise CapabilityError("boundary-id keyset requires an externally reviewed boundary contract")
396
+ if (
397
+ isinstance(plan, OffsetSequentialPlan)
398
+ and OffsetTerminalRule.DECLARED_SHORT_PAGE in plan.terminal
399
+ and (
400
+ consistency.total_semantics is not TotalSemantics.IGNORE
401
+ or consistency.confirmation_policy is not ConfirmationPolicy.NONE
402
+ or consistency.snapshot_requirement is not SnapshotRequirement.TRAVERSAL_ONLY
403
+ )
404
+ ):
405
+ # A declared short page proves only the endpoint's stop rule, never a total, boundary or snapshot.
406
+ raise CapabilityError("declared short-page traversal supports only the traversal-only consistency policy")
394
407
  return _EffectiveConsistency(
395
408
  duplicate_policy,
396
409
  total_semantics,