b24api 2.1.0__tar.gz → 2.3.0__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 (122) hide show
  1. {b24api-2.1.0/b24api.egg-info → b24api-2.3.0}/PKG-INFO +122 -46
  2. {b24api-2.1.0 → b24api-2.3.0}/README.md +120 -44
  3. {b24api-2.1.0 → b24api-2.3.0}/b24api/__init__.py +114 -0
  4. {b24api-2.1.0 → b24api-2.3.0}/b24api/_client_traversal.py +29 -4
  5. {b24api-2.1.0 → b24api-2.3.0}/b24api/_stream.py +28 -77
  6. {b24api-2.1.0 → b24api-2.3.0}/b24api/batch/engine.py +69 -12
  7. {b24api-2.1.0 → b24api-2.3.0}/b24api/batch/logical.py +90 -5
  8. {b24api-2.1.0 → b24api-2.3.0}/b24api/batch/stream.py +2 -1
  9. {b24api-2.1.0 → b24api-2.3.0}/b24api/cli.py +42 -5
  10. {b24api-2.1.0 → b24api-2.3.0}/b24api/cli_contract.py +52 -6
  11. {b24api-2.1.0 → b24api-2.3.0}/b24api/client.py +77 -3
  12. b24api-2.3.0/b24api/completion/__init__.py +5 -0
  13. b24api-2.3.0/b24api/completion/closure.py +31 -0
  14. b24api-2.3.0/b24api/completion/fast_recorder.py +195 -0
  15. b24api-2.3.0/b24api/completion/gate.py +479 -0
  16. b24api-2.3.0/b24api/completion/recorder.py +284 -0
  17. b24api-2.3.0/b24api/completion/reference_recorder.py +180 -0
  18. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/__init__.py +102 -1
  19. b24api-2.3.0/b24api/contracts/bounded_range.py +81 -0
  20. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/command.py +13 -0
  21. b24api-2.3.0/b24api/contracts/completion.py +158 -0
  22. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/dispatch.py +7 -0
  23. b24api-2.3.0/b24api/contracts/identity_store.py +45 -0
  24. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/json.py +1 -1
  25. b24api-2.3.0/b24api/contracts/keyset_capability.py +246 -0
  26. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/keyset_execution.py +1 -0
  27. b24api-2.3.0/b24api/contracts/page.py +70 -0
  28. b24api-2.3.0/b24api/contracts/page_stop.py +68 -0
  29. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/policy.py +2 -0
  30. b24api-2.3.0/b24api/contracts/positional.py +236 -0
  31. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/reference.py +19 -1
  32. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/report.py +24 -41
  33. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/request.py +62 -10
  34. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/response.py +4 -0
  35. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/traversal.py +148 -1
  36. b24api-2.3.0/b24api/contracts/violation.py +102 -0
  37. {b24api-2.1.0 → b24api-2.3.0}/b24api/errors.py +152 -4
  38. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/__init__.py +14 -1
  39. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/context.py +32 -8
  40. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/executor.py +57 -69
  41. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/failure.py +26 -9
  42. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/rate.py +134 -18
  43. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/snapshot.py +5 -0
  44. b24api-2.3.0/b24api/execution/throttle.py +53 -0
  45. {b24api-2.1.0 → b24api-2.3.0}/b24api/redaction.py +1 -1
  46. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/binding.py +25 -5
  47. b24api-2.3.0/b24api/references/dispatch.py +695 -0
  48. b24api-2.3.0/b24api/references/dispatch_plan.py +27 -0
  49. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/facade.py +59 -68
  50. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/fanout.py +4 -4
  51. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/outcome.py +27 -0
  52. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/scheduler.py +184 -44
  53. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/stream.py +49 -26
  54. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/support.py +67 -0
  55. {b24api-2.1.0 → b24api-2.3.0}/b24api/testing/__init__.py +3 -0
  56. b24api-2.3.0/b24api/testing/scripted.py +147 -0
  57. {b24api-2.1.0 → b24api-2.3.0}/b24api/testing/transport.py +39 -8
  58. {b24api-2.1.0 → b24api-2.3.0}/b24api/transport/base.py +58 -4
  59. {b24api-2.1.0 → b24api-2.3.0}/b24api/transport/httpx.py +102 -13
  60. b24api-2.3.0/b24api/transport/logging_shield.py +244 -0
  61. b24api-2.3.0/b24api/transport/protocol.py +279 -0
  62. b24api-2.3.0/b24api/traversal/control_preflight.py +81 -0
  63. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/counted.py +65 -5
  64. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/counted_batch.py +51 -23
  65. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/cursor.py +26 -7
  66. b24api-2.3.0/b24api/traversal/cursor_domain.py +61 -0
  67. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/driver.py +124 -98
  68. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/facade.py +63 -44
  69. b24api-2.3.0/b24api/traversal/facade_support.py +36 -0
  70. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/identity.py +74 -28
  71. b24api-2.3.0/b24api/traversal/identity_ledger.py +59 -0
  72. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset.py +11 -6
  73. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_auto.py +1 -6
  74. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_capability.py +3 -3
  75. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_eligibility.py +50 -13
  76. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_fast_plan.py +4 -4
  77. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_fast_stream.py +46 -9
  78. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_observation.py +5 -23
  79. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_reporting.py +2 -3
  80. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_scheduler.py +52 -55
  81. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_step.py +46 -3
  82. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_transaction_contract.py +4 -4
  83. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_transactions.py +56 -31
  84. b24api-2.3.0/b24api/traversal/keyset_verifier.py +528 -0
  85. b24api-2.3.0/b24api/traversal/offset_rules.py +76 -0
  86. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/ordered_admission.py +5 -6
  87. b24api-2.3.0/b24api/traversal/page_adaptation.py +90 -0
  88. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/page_validation.py +46 -9
  89. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/plans.py +43 -3
  90. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/sequential.py +76 -22
  91. b24api-2.3.0/b24api/traversal/sparse.py +55 -0
  92. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/stream.py +99 -5
  93. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/values.py +59 -36
  94. {b24api-2.1.0 → b24api-2.3.0/b24api.egg-info}/PKG-INFO +122 -46
  95. {b24api-2.1.0 → b24api-2.3.0}/b24api.egg-info/SOURCES.txt +26 -0
  96. {b24api-2.1.0 → b24api-2.3.0}/b24api.egg-info/requires.txt +1 -1
  97. {b24api-2.1.0 → b24api-2.3.0}/pyproject.toml +3 -2
  98. b24api-2.1.0/b24api/references/dispatch.py +0 -435
  99. b24api-2.1.0/b24api/transport/protocol.py +0 -157
  100. {b24api-2.1.0 → b24api-2.3.0}/LICENSE +0 -0
  101. {b24api-2.1.0 → b24api-2.3.0}/MANIFEST.in +0 -0
  102. {b24api-2.1.0 → b24api-2.3.0}/b24api/_audit.py +0 -0
  103. {b24api-2.1.0 → b24api-2.3.0}/b24api/_error_types.py +0 -0
  104. {b24api-2.1.0 → b24api-2.3.0}/b24api/batch/__init__.py +0 -0
  105. {b24api-2.1.0 → b24api-2.3.0}/b24api/batch/facade.py +0 -0
  106. {b24api-2.1.0 → b24api-2.3.0}/b24api/batch/outcome.py +0 -0
  107. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/stream.py +0 -0
  108. {b24api-2.1.0 → b24api-2.3.0}/b24api/contracts/wire.py +0 -0
  109. {b24api-2.1.0 → b24api-2.3.0}/b24api/encoding.py +0 -0
  110. {b24api-2.1.0 → b24api-2.3.0}/b24api/execution/cleanup.py +0 -0
  111. {b24api-2.1.0 → b24api-2.3.0}/b24api/references/__init__.py +0 -0
  112. {b24api-2.1.0 → b24api-2.3.0}/b24api/settings.py +0 -0
  113. {b24api-2.1.0 → b24api-2.3.0}/b24api/testing/_isolation.py +0 -0
  114. {b24api-2.1.0 → b24api-2.3.0}/b24api/transport/__init__.py +0 -0
  115. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/__init__.py +0 -0
  116. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_costs.py +0 -0
  117. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_partition.py +0 -0
  118. {b24api-2.1.0 → b24api-2.3.0}/b24api/traversal/keyset_range.py +0 -0
  119. {b24api-2.1.0 → b24api-2.3.0}/b24api.egg-info/dependency_links.txt +0 -0
  120. {b24api-2.1.0 → b24api-2.3.0}/b24api.egg-info/entry_points.txt +0 -0
  121. {b24api-2.1.0 → b24api-2.3.0}/b24api.egg-info/top_level.txt +0 -0
  122. {b24api-2.1.0 → b24api-2.3.0}/setup.cfg +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Bitrix24 API
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
- Requires-Dist: httpx[http2]>=0.28.1
8
+ Requires-Dist: httpx[http2]<0.29,>=0.28.1
9
9
  Requires-Dist: pydantic>=2.11.7
10
10
  Requires-Dist: pydantic-settings>=2.10.1
11
11
  Dynamic: license-file
@@ -29,10 +29,10 @@ work so its HTTP/2 connection pool and rate state are reused.
29
29
 
30
30
  <!-- tested: tests/client_v2_test.py::test_call_and_call_response_have_stable_detached_types -->
31
31
  ```python
32
- from b24api import Bitrix24, Request
32
+ from b24api import Bitrix24, Request, RouteKind
33
33
 
34
34
  async with Bitrix24() as client:
35
- profile = await client.call(Request("profile"))
35
+ profile = await client.call(Request("profile", route=RouteKind.BARE))
36
36
  ```
37
37
 
38
38
  The client owns its default transport. An injected transport remains caller-owned. `aclose()` is
@@ -48,15 +48,17 @@ The operation is explicit and never hides malformed JSON by falling back to byte
48
48
 
49
49
  <!-- tested: tests/client_findings_3_test.py::test_binary_call_returns_every_success_byte_without_json_sniffing -->
50
50
  ```python
51
- archive = await client.call_bytes(Request("example.export.download", replay_safety=ReplaySafety.SAFE))
51
+ from b24api import RouteKind
52
+ archive = await client.call_bytes(Request("example.export.download", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE))
52
53
  payload = archive.body
53
54
  ```
54
55
 
55
56
  <!-- tested: tests/client_v2_test.py::test_call_and_call_response_have_stable_detached_types -->
56
57
  ```python
58
+ from b24api import RouteKind
57
59
  from b24api import ReplaySafety
58
60
 
59
- request = Request("example.item.get", {"id": 7}, ReplaySafety.SAFE)
61
+ request = Request("example.item.get", {"id": 7}, ReplaySafety.SAFE, route=RouteKind.BARE)
60
62
  decoded = await client.call(request)
61
63
  response = await client.call_response(request)
62
64
  ```
@@ -97,11 +99,12 @@ matching a result to the object, file, chat or database row that produced its re
97
99
 
98
100
  <!-- tested: tests/client_v2_test.py::test_logical_batch_is_unbounded_ordered_and_correlation_is_strictly_off_wire -->
99
101
  ```python
102
+ from b24api import RouteKind
100
103
  from b24api import Command, CommandSuccess
101
104
 
102
105
  commands = (
103
106
  Command(
104
- Request("example.item.get", {"id": item_id}, ReplaySafety.SAFE),
107
+ Request("example.item.get", {"id": item_id}, ReplaySafety.SAFE, route=RouteKind.BARE),
105
108
  correlation=item_id,
106
109
  )
107
110
  for item_id in source_ids
@@ -139,23 +142,34 @@ more specialized mechanics have explicit names and explicit preconditions.
139
142
 
140
143
  | Operation | Use it when | Network mechanics | Completion proof |
141
144
  |---|---|---|---|
142
- | `iter_list` | The method supports ordinary offset pagination. | Pages are requested sequentially using server `next`; no separate count request is made. | Continuation and empty terminal page; add identity for duplicate detection. |
145
+ | `iter_list` | The method supports ordinary offset pagination. | Sequential requests follow server `next` (the next offset). Ordinary counted Bitrix list endpoints do server-side COUNT for `total` plus LIMIT/OFFSET page retrieval. | Continuation and empty terminal page; add identity for duplicate detection. |
143
146
  | `iter_list_counted` | The first response provides an exact filtered `total` and stable offset pages. | Head page is direct; all known tail offsets are grouped into physical Bitrix batches. | Exact total, ranges and identities. |
144
- | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Sequential by default; explicit range, partitioned, or auto execution may batch bounded work after a planning barrier. | Caller-asserted keyset contract, strict monotonic identity, bounded-plan canaries, and terminal empty confirmation. |
147
+ | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Auto by default: it plans first, then selects boundary-only, sequential, range, or partitioned execution; runtime sends no diagnostic canaries. | Caller-asserted keyset contract, strict monotonic identity, active bound validation, and terminal empty confirmation. |
145
148
  | `iter_list_cursor` | Each next request depends on a cursor from the previous response. | Sequential dependent cursor requests. | Strict unique monotonic cursor and empty terminal page. |
149
+ | `iter_cursors` | One or many parent-bound cursor traversals need correlation and shared batching. | Lazy per-parent drivers share the reference batch queue; each binding may have `start_cursor`. | Isolated strict cursor progress and terminal event per binding. |
146
150
  | `iter_references` | The same list method must run for many parent parameter sets, such as comments per owner or messages per chat. | Bindings are scheduled with direct or physical-batch dispatch; each binding has its own traversal state. | Per-binding rows, completion/failure and caller correlation. |
147
151
 
148
152
  `page_size` is a local decoded-page cap. It is sent to Bitrix only when you provide the endpoint's
149
153
  exact `limit_path`; the client never guesses method-specific parameter names.
150
154
 
155
+ ### List traversal comparison
156
+
157
+ ![List traversal comparison](list-traversal-comparison.svg)
158
+
151
159
  ### Sequential offset
152
160
 
153
161
  This is the canonical default. It follows the `next` returned by the server and confirms the end
154
- with an empty page. A `total` present in the response is observational; this strategy does not add
155
- a separate count request.
162
+ with an empty page. For ordinary counted Bitrix list endpoints, `next` is an offset for the next
163
+ LIMIT/OFFSET page, not a keyset cursor. Producing `total` involves a separate server-side count
164
+ query in addition to retrieving the page. These database operations are performed inside the same
165
+ REST request: the client does not issue an additional HTTP call just for the count. This distinction
166
+ matters for performance: not making a separate HTTP count call does **not** mean avoiding server-side
167
+ COUNT work. `iter_list` does not suppress that work; a returned `total` is observational and does not
168
+ control this strategy's completion. Exact database implementation is endpoint-specific.
156
169
 
157
170
  <!-- tested: tests/client_v2_test.py::test_iter_list_is_sequential_mechanics_only_and_report_is_post_cleanup -->
158
171
  ```python
172
+ from b24api import RouteKind
159
173
  from b24api import IdentityCoercion, IdentitySpec, ResultSelector
160
174
 
161
175
  identity = IdentitySpec(
@@ -166,7 +180,7 @@ identity = IdentitySpec(
166
180
  )
167
181
 
168
182
  stream = client.iter_list(
169
- Request("example.item.list", replay_safety=ReplaySafety.SAFE),
183
+ Request("example.item.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
170
184
  selector=ResultSelector(("items",)),
171
185
  identity=identity,
172
186
  )
@@ -184,10 +198,11 @@ sequence and records that degradation in the operation report.
184
198
 
185
199
  <!-- tested: tests/client_findings_3_test.py::test_shape_rejection_is_retained_as_zero_admission_page_evidence -->
186
200
  ```python
201
+ from b24api import RouteKind
187
202
  from b24api import ResultCollectionShape
188
203
 
189
204
  stream = client.iter_list(
190
- Request("example.dictionary.list", replay_safety=ReplaySafety.SAFE),
205
+ Request("example.dictionary.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
191
206
  selector=ResultSelector(("items",)),
192
207
  collection_shape=ResultCollectionShape.MAPPING_VALUES,
193
208
  )
@@ -204,8 +219,9 @@ bounded physical batches.
204
219
 
205
220
  <!-- tested: tests/client_v2_test.py::test_counted_traversal_preserves_frozen_request_shape_and_exact_identity -->
206
221
  ```python
222
+ from b24api import RouteKind
207
223
  stream = client.iter_list_counted(
208
- Request("example.item.list", replay_safety=ReplaySafety.SAFE),
224
+ Request("example.item.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
209
225
  selector=ResultSelector(("items",)),
210
226
  identity=identity,
211
227
  page_size=50,
@@ -216,35 +232,79 @@ stream = client.iter_list_counted(
216
232
  Use it only when `total` is exact for the supplied filter and offset pages are stable. Any missing
217
233
  range, overlap, duplicate identity or total contradiction raises `IncompleteTraversalError`.
218
234
 
235
+ Physical batching reduces HTTP exchanges, but does not suppress server-side COUNT in ordinary
236
+ counted list subrequests. Each command still performs its own offset page retrieval and associated
237
+ total calculation on the server. Do not confuse batching these commands with a no-count traversal.
238
+
219
239
  ### No-count keyset
220
240
 
221
- Keyset traversal is sequential by default, preserving the compatible request shape and first-pull
222
- behavior. An explicit execution contract can instead capture both ordered boundaries, validate five
223
- capability canaries, and batch numeric ranges or occupied-anchor partitions. Planning completes
224
- before any row is emitted, so partial consumption still pays that barrier cost. Fast execution fails
225
- synchronously when its declared controls or policy capacity are ineligible.
241
+ Keyset traversal uses automatic execution by default. Omitting `execution` is equivalent to
242
+ `AutoKeysetExecution(StableIntegerKeysetContract())`: the client captures both ordered boundaries,
243
+ then selects boundary-only, sequential, range, or partitioned execution from the observed geometry
244
+ and available policy capacity. Planning completes before any row is emitted, so partial consumption
245
+ still pays that barrier cost. A sequential selection made by auto is a cost decision; a failed or
246
+ contradictory fast plan is never silently restarted as sequential.
247
+
248
+ By using the default, the caller asserts that the endpoint has a stable, unique integer key, honors
249
+ strict numeric bounds and ordering, and satisfies empty-confirmation completion. Concurrent mutation
250
+ outside the captured middle is handled by the finishing sweep; mutation inside it is outside this
251
+ assertion. Pass `SequentialKeysetExecution()` explicitly when an endpoint cannot satisfy the fast
252
+ contract or when the previous request-by-request behavior is required.
253
+
254
+ Static incompatibility with the auto contract raises `CapabilityError` from the
255
+ `iter_list_keyset(...)` call before iteration begins. A portal that accepts but contradicts the
256
+ declared controls fails before emission with `IncompleteTraversalError`; auto never restarts that
257
+ operation silently. `KeysetTraversal` inside reference traversal remains sequential-only. The
258
+ terminal report now includes `keyset_execution` for omitted-execution keyset calls so consumers can
259
+ see the requested and selected plan.
260
+
261
+ Use `await client.verify_keyset_capability(...)` as a development/CI/staging guard on a stable
262
+ representative fixture. It performs five strict-bound checks and returns only a `VERIFIED` report;
263
+ unsupported and inconclusive verdicts raise `KeysetCapabilityError`. The ordinary
264
+ `iter_list_keyset()` remains a separate caller-asserted operation with zero verifier canaries and
265
+ may emit a partial prefix before a late endpoint contradiction is detected.
266
+ Keep the guard beside the traversal, for example under
267
+ `if os.environ.get("ENV") != "PROD":`; set `ENV=PROD` only after qualifying the exact portal,
268
+ credentials, method, request/filter, identity, ordering representation, and page cap.
269
+
270
+ Every list operation also accepts an immutable `PageAdapter` strategy. The adapter synchronously
271
+ maps selected frozen items using sibling result metadata while preserving cardinality, order and
272
+ configured identities. The identity adapter is the default and preserves existing JSON output.
226
273
 
227
- The caller must assert that the endpoint has a stable, unique integer key, honors strict numeric
228
- bounds and ordering, and satisfies the chosen page-completion rule. Concurrent mutation outside the
229
- captured middle is handled by the finishing sweep; mutation inside it is outside this assertion.
230
274
  An advisory `total` may only raise an automatic cost estimate and never proves completion. Reports
231
275
  record the selected strategy and reason: unbounded auto continuation has the same
232
276
  `ordered_prefix_only` assurance as sequential traversal, while bounded plans additionally report
233
- `canary_verified_bounds`.
277
+ `caller_asserted_bounds`. Runtime traversal does not perform verifier canaries.
234
278
 
235
- <!-- tested: tests/client_v2_test.py::test_keyset_and_cursor_are_explicit_strict_alternatives -->
279
+ <!-- tested: tests/keyset_fast_test.py::test_omitted_execution_defaults_to_auto -->
236
280
  ```python
237
- from b24api import AutoKeysetExecution, KeysetSpec, ParameterPath, StableIntegerKeysetContract
281
+ import os
282
+
283
+ from b24api import KeysetSpec, ParameterPath, ReplaySafety, Request, ResultSelector, RouteKind
284
+
285
+ request = Request("example.item.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE)
286
+ selector = ResultSelector(("items",))
287
+ keyset = KeysetSpec(
288
+ filter_path=ParameterPath(("filter",)),
289
+ order_path=ParameterPath(("order",)),
290
+ )
291
+
292
+ if os.environ.get("ENV") != "PROD":
293
+ # Accepting an ID filter does not prove strict bounds or ordering.
294
+ await client.verify_keyset_capability(
295
+ request,
296
+ selector=selector,
297
+ identity=identity,
298
+ page_size=50,
299
+ keyset=keyset,
300
+ )
238
301
 
239
302
  stream = client.iter_list_keyset(
240
- Request("example.item.list", replay_safety=ReplaySafety.SAFE),
241
- selector=ResultSelector(("items",)),
303
+ request,
304
+ selector=selector,
242
305
  identity=identity,
243
- keyset=KeysetSpec(
244
- filter_path=ParameterPath(("filter",)),
245
- order_path=ParameterPath(("order",)),
246
- ),
247
- execution=AutoKeysetExecution(contract=StableIntegerKeysetContract()),
306
+ page_size=50,
307
+ keyset=keyset,
248
308
  )
249
309
  ```
250
310
 
@@ -255,10 +315,11 @@ message-list methods.
255
315
 
256
316
  <!-- tested: tests/client_v2_test.py::test_keyset_and_cursor_are_explicit_strict_alternatives -->
257
317
  ```python
318
+ from b24api import RouteKind
258
319
  from b24api import CursorSpec, ParameterPath
259
320
 
260
321
  stream = client.iter_list_cursor(
261
- Request("example.message.list", replay_safety=ReplaySafety.SAFE),
322
+ Request("example.message.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
262
323
  selector=ResultSelector(("items",)),
263
324
  cursor=CursorSpec(
264
325
  parameter_path=ParameterPath(("LAST_ID",)),
@@ -273,6 +334,11 @@ stream = client.iter_list_cursor(
273
334
  Cursor values must be unique and strictly monotonic. If an endpoint exposes only a non-unique
274
335
  boundary, use an application-owned direct-call workflow or supply a unique tie-breaker.
275
336
 
337
+ For multiple parent-bound cursor chains, `iter_cursors()` keeps cursor progress and correlation
338
+ isolated per binding while ready pages share the physical batch queue.
339
+
340
+ ![Cursor batching across independent chats](cursor-batching.svg)
341
+
276
342
  See [architecture](docs/architecture.md), [migration](docs/migration.md),
277
343
  [performance](docs/performance.md), and [endpoint recipes](docs/recipes.md) for the complete
278
344
  contracts and selection guidance.
@@ -283,8 +349,11 @@ contracts and selection guidance.
283
349
  client remains unaware of entity types: a binding can represent a deal, lead, chat or any other
284
350
  caller-defined parent.
285
351
 
352
+ ![Reference batching across leads and deals](references-batching.svg)
353
+
286
354
  <!-- tested: tests/client_v2_test.py::test_bound_references_apply_nested_updates_off_wire_and_emit_exact_completion -->
287
355
  ```python
356
+ from b24api import RouteKind
288
357
  from b24api import (
289
358
  BatchDispatch,
290
359
  Binding,
@@ -305,7 +374,7 @@ bindings = (
305
374
  )
306
375
 
307
376
  stream = client.iter_references(
308
- Request("example.comment.list", replay_safety=ReplaySafety.SAFE),
377
+ Request("example.comment.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
309
378
  bindings,
310
379
  traversal=SequentialTraversal(selector=ResultSelector(("items",)), identity=identity),
311
380
  dispatch=BatchDispatch(batch_size=25, concurrency=2),
@@ -325,6 +394,7 @@ under different parents are not conflated.
325
394
 
326
395
  <!-- tested: tests/client_v2_test.py::test_bound_references_apply_nested_updates_off_wire_and_emit_exact_completion -->
327
396
  ```python
397
+ from b24api import RouteKind
328
398
  from b24api import (
329
399
  Binding,
330
400
  CursorSpec,
@@ -346,7 +416,7 @@ chat_bindings = (
346
416
  )
347
417
 
348
418
  messages = client.iter_references(
349
- Request("example.message.list", replay_safety=ReplaySafety.SAFE),
419
+ Request("example.message.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
350
420
  chat_bindings,
351
421
  traversal=CursorTraversal(
352
422
  selector=ResultSelector(("items",)),
@@ -389,13 +459,19 @@ and publish the same final report where the Python exception type permits it.
389
459
  ## Resource boundaries
390
460
 
391
461
  `ExecutionPolicy` bounds requests, pages, elapsed time, attempts, decompressed response bytes,
392
- buffered commands and rows, direct concurrency and active references. The default response ceiling
393
- is 16 MiB and is enforced while streaming, before JSON decoding.
394
-
395
- Sequential and counted exact traversal retain observed identities in memory. There is no database,
396
- spill file or identity-count refusal. Crossing 100,000 distinct identities emits one
397
- `RuntimeWarning`; exact tracking continues. Strict keyset and cursor traversal retain only
398
- monotonic progression state when sufficient.
462
+ buffered commands and rows, retained unordered identity keys, direct concurrency and active
463
+ references. The default response ceiling is 16 MiB and is enforced while streaming, before JSON
464
+ decoding.
465
+
466
+ Sequential, counted, and multi-reference exact traversal retain at most `max_identity_keys`
467
+ observed identities per operation in memory (100,000 by default). All active reference bindings
468
+ share that ceiling. A page that would exceed it is rejected atomically with typed budget evidence.
469
+ Set a larger finite ceiling when the expected aggregate cardinality is known, or pass
470
+ `identity_store=` to `iter_list`/`iter_list_counted` so a caller-owned `IdentityStore` (for example a
471
+ SQLite table keyed by `identity_store_key(...)`) proves uniqueness while in-process identity memory
472
+ stays bounded by one page; the client never closes that store. Repeated-page detection still keeps
473
+ one short fingerprint per page, so raise `max_pages` deliberately for very long traversals.
474
+ Strict keyset and cursor traversal retain only monotonic progression state when sufficient.
399
475
 
400
476
  ## CLI
401
477
 
@@ -404,10 +480,10 @@ errors go to stderr. Credentials come only from `Settings` and cannot be passed
404
480
 
405
481
  <!-- tested-console: tests/cli_test.py::test_call_routes_replay_safety_and_keeps_success_data_on_stdout -->
406
482
  ```console
407
- b24api call profile
408
- b24api call example.item.get --params '{"id":7}' --raw --replay-safety safe
409
- b24api list example.item.list --params @params.json
410
- b24api list example.item.list --strategy counted --contract @counted-contract.json
483
+ b24api call profile --route bare
484
+ b24api call example.item.get --route bare --params '{"id":7}' --raw --replay-safety safe
485
+ b24api list example.item.list --route bare --params @params.json
486
+ b24api list example.item.list --route bare --strategy counted --contract @counted-contract.json
411
487
  ```
412
488
 
413
489
  The `--raw` CLI option selects the response envelope; it does not alter the Python API. Advanced
@@ -17,10 +17,10 @@ work so its HTTP/2 connection pool and rate state are reused.
17
17
 
18
18
  <!-- tested: tests/client_v2_test.py::test_call_and_call_response_have_stable_detached_types -->
19
19
  ```python
20
- from b24api import Bitrix24, Request
20
+ from b24api import Bitrix24, Request, RouteKind
21
21
 
22
22
  async with Bitrix24() as client:
23
- profile = await client.call(Request("profile"))
23
+ profile = await client.call(Request("profile", route=RouteKind.BARE))
24
24
  ```
25
25
 
26
26
  The client owns its default transport. An injected transport remains caller-owned. `aclose()` is
@@ -36,15 +36,17 @@ The operation is explicit and never hides malformed JSON by falling back to byte
36
36
 
37
37
  <!-- tested: tests/client_findings_3_test.py::test_binary_call_returns_every_success_byte_without_json_sniffing -->
38
38
  ```python
39
- archive = await client.call_bytes(Request("example.export.download", replay_safety=ReplaySafety.SAFE))
39
+ from b24api import RouteKind
40
+ archive = await client.call_bytes(Request("example.export.download", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE))
40
41
  payload = archive.body
41
42
  ```
42
43
 
43
44
  <!-- tested: tests/client_v2_test.py::test_call_and_call_response_have_stable_detached_types -->
44
45
  ```python
46
+ from b24api import RouteKind
45
47
  from b24api import ReplaySafety
46
48
 
47
- request = Request("example.item.get", {"id": 7}, ReplaySafety.SAFE)
49
+ request = Request("example.item.get", {"id": 7}, ReplaySafety.SAFE, route=RouteKind.BARE)
48
50
  decoded = await client.call(request)
49
51
  response = await client.call_response(request)
50
52
  ```
@@ -85,11 +87,12 @@ matching a result to the object, file, chat or database row that produced its re
85
87
 
86
88
  <!-- tested: tests/client_v2_test.py::test_logical_batch_is_unbounded_ordered_and_correlation_is_strictly_off_wire -->
87
89
  ```python
90
+ from b24api import RouteKind
88
91
  from b24api import Command, CommandSuccess
89
92
 
90
93
  commands = (
91
94
  Command(
92
- Request("example.item.get", {"id": item_id}, ReplaySafety.SAFE),
95
+ Request("example.item.get", {"id": item_id}, ReplaySafety.SAFE, route=RouteKind.BARE),
93
96
  correlation=item_id,
94
97
  )
95
98
  for item_id in source_ids
@@ -127,23 +130,34 @@ more specialized mechanics have explicit names and explicit preconditions.
127
130
 
128
131
  | Operation | Use it when | Network mechanics | Completion proof |
129
132
  |---|---|---|---|
130
- | `iter_list` | The method supports ordinary offset pagination. | Pages are requested sequentially using server `next`; no separate count request is made. | Continuation and empty terminal page; add identity for duplicate detection. |
133
+ | `iter_list` | The method supports ordinary offset pagination. | Sequential requests follow server `next` (the next offset). Ordinary counted Bitrix list endpoints do server-side COUNT for `total` plus LIMIT/OFFSET page retrieval. | Continuation and empty terminal page; add identity for duplicate detection. |
131
134
  | `iter_list_counted` | The first response provides an exact filtered `total` and stable offset pages. | Head page is direct; all known tail offsets are grouped into physical Bitrix batches. | Exact total, ranges and identities. |
132
- | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Sequential by default; explicit range, partitioned, or auto execution may batch bounded work after a planning barrier. | Caller-asserted keyset contract, strict monotonic identity, bounded-plan canaries, and terminal empty confirmation. |
135
+ | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Auto by default: it plans first, then selects boundary-only, sequential, range, or partitioned execution; runtime sends no diagnostic canaries. | Caller-asserted keyset contract, strict monotonic identity, active bound validation, and terminal empty confirmation. |
133
136
  | `iter_list_cursor` | Each next request depends on a cursor from the previous response. | Sequential dependent cursor requests. | Strict unique monotonic cursor and empty terminal page. |
137
+ | `iter_cursors` | One or many parent-bound cursor traversals need correlation and shared batching. | Lazy per-parent drivers share the reference batch queue; each binding may have `start_cursor`. | Isolated strict cursor progress and terminal event per binding. |
134
138
  | `iter_references` | The same list method must run for many parent parameter sets, such as comments per owner or messages per chat. | Bindings are scheduled with direct or physical-batch dispatch; each binding has its own traversal state. | Per-binding rows, completion/failure and caller correlation. |
135
139
 
136
140
  `page_size` is a local decoded-page cap. It is sent to Bitrix only when you provide the endpoint's
137
141
  exact `limit_path`; the client never guesses method-specific parameter names.
138
142
 
143
+ ### List traversal comparison
144
+
145
+ ![List traversal comparison](list-traversal-comparison.svg)
146
+
139
147
  ### Sequential offset
140
148
 
141
149
  This is the canonical default. It follows the `next` returned by the server and confirms the end
142
- with an empty page. A `total` present in the response is observational; this strategy does not add
143
- a separate count request.
150
+ with an empty page. For ordinary counted Bitrix list endpoints, `next` is an offset for the next
151
+ LIMIT/OFFSET page, not a keyset cursor. Producing `total` involves a separate server-side count
152
+ query in addition to retrieving the page. These database operations are performed inside the same
153
+ REST request: the client does not issue an additional HTTP call just for the count. This distinction
154
+ matters for performance: not making a separate HTTP count call does **not** mean avoiding server-side
155
+ COUNT work. `iter_list` does not suppress that work; a returned `total` is observational and does not
156
+ control this strategy's completion. Exact database implementation is endpoint-specific.
144
157
 
145
158
  <!-- tested: tests/client_v2_test.py::test_iter_list_is_sequential_mechanics_only_and_report_is_post_cleanup -->
146
159
  ```python
160
+ from b24api import RouteKind
147
161
  from b24api import IdentityCoercion, IdentitySpec, ResultSelector
148
162
 
149
163
  identity = IdentitySpec(
@@ -154,7 +168,7 @@ identity = IdentitySpec(
154
168
  )
155
169
 
156
170
  stream = client.iter_list(
157
- Request("example.item.list", replay_safety=ReplaySafety.SAFE),
171
+ Request("example.item.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
158
172
  selector=ResultSelector(("items",)),
159
173
  identity=identity,
160
174
  )
@@ -172,10 +186,11 @@ sequence and records that degradation in the operation report.
172
186
 
173
187
  <!-- tested: tests/client_findings_3_test.py::test_shape_rejection_is_retained_as_zero_admission_page_evidence -->
174
188
  ```python
189
+ from b24api import RouteKind
175
190
  from b24api import ResultCollectionShape
176
191
 
177
192
  stream = client.iter_list(
178
- Request("example.dictionary.list", replay_safety=ReplaySafety.SAFE),
193
+ Request("example.dictionary.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
179
194
  selector=ResultSelector(("items",)),
180
195
  collection_shape=ResultCollectionShape.MAPPING_VALUES,
181
196
  )
@@ -192,8 +207,9 @@ bounded physical batches.
192
207
 
193
208
  <!-- tested: tests/client_v2_test.py::test_counted_traversal_preserves_frozen_request_shape_and_exact_identity -->
194
209
  ```python
210
+ from b24api import RouteKind
195
211
  stream = client.iter_list_counted(
196
- Request("example.item.list", replay_safety=ReplaySafety.SAFE),
212
+ Request("example.item.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
197
213
  selector=ResultSelector(("items",)),
198
214
  identity=identity,
199
215
  page_size=50,
@@ -204,35 +220,79 @@ stream = client.iter_list_counted(
204
220
  Use it only when `total` is exact for the supplied filter and offset pages are stable. Any missing
205
221
  range, overlap, duplicate identity or total contradiction raises `IncompleteTraversalError`.
206
222
 
223
+ Physical batching reduces HTTP exchanges, but does not suppress server-side COUNT in ordinary
224
+ counted list subrequests. Each command still performs its own offset page retrieval and associated
225
+ total calculation on the server. Do not confuse batching these commands with a no-count traversal.
226
+
207
227
  ### No-count keyset
208
228
 
209
- Keyset traversal is sequential by default, preserving the compatible request shape and first-pull
210
- behavior. An explicit execution contract can instead capture both ordered boundaries, validate five
211
- capability canaries, and batch numeric ranges or occupied-anchor partitions. Planning completes
212
- before any row is emitted, so partial consumption still pays that barrier cost. Fast execution fails
213
- synchronously when its declared controls or policy capacity are ineligible.
229
+ Keyset traversal uses automatic execution by default. Omitting `execution` is equivalent to
230
+ `AutoKeysetExecution(StableIntegerKeysetContract())`: the client captures both ordered boundaries,
231
+ then selects boundary-only, sequential, range, or partitioned execution from the observed geometry
232
+ and available policy capacity. Planning completes before any row is emitted, so partial consumption
233
+ still pays that barrier cost. A sequential selection made by auto is a cost decision; a failed or
234
+ contradictory fast plan is never silently restarted as sequential.
235
+
236
+ By using the default, the caller asserts that the endpoint has a stable, unique integer key, honors
237
+ strict numeric bounds and ordering, and satisfies empty-confirmation completion. Concurrent mutation
238
+ outside the captured middle is handled by the finishing sweep; mutation inside it is outside this
239
+ assertion. Pass `SequentialKeysetExecution()` explicitly when an endpoint cannot satisfy the fast
240
+ contract or when the previous request-by-request behavior is required.
241
+
242
+ Static incompatibility with the auto contract raises `CapabilityError` from the
243
+ `iter_list_keyset(...)` call before iteration begins. A portal that accepts but contradicts the
244
+ declared controls fails before emission with `IncompleteTraversalError`; auto never restarts that
245
+ operation silently. `KeysetTraversal` inside reference traversal remains sequential-only. The
246
+ terminal report now includes `keyset_execution` for omitted-execution keyset calls so consumers can
247
+ see the requested and selected plan.
248
+
249
+ Use `await client.verify_keyset_capability(...)` as a development/CI/staging guard on a stable
250
+ representative fixture. It performs five strict-bound checks and returns only a `VERIFIED` report;
251
+ unsupported and inconclusive verdicts raise `KeysetCapabilityError`. The ordinary
252
+ `iter_list_keyset()` remains a separate caller-asserted operation with zero verifier canaries and
253
+ may emit a partial prefix before a late endpoint contradiction is detected.
254
+ Keep the guard beside the traversal, for example under
255
+ `if os.environ.get("ENV") != "PROD":`; set `ENV=PROD` only after qualifying the exact portal,
256
+ credentials, method, request/filter, identity, ordering representation, and page cap.
257
+
258
+ Every list operation also accepts an immutable `PageAdapter` strategy. The adapter synchronously
259
+ maps selected frozen items using sibling result metadata while preserving cardinality, order and
260
+ configured identities. The identity adapter is the default and preserves existing JSON output.
214
261
 
215
- The caller must assert that the endpoint has a stable, unique integer key, honors strict numeric
216
- bounds and ordering, and satisfies the chosen page-completion rule. Concurrent mutation outside the
217
- captured middle is handled by the finishing sweep; mutation inside it is outside this assertion.
218
262
  An advisory `total` may only raise an automatic cost estimate and never proves completion. Reports
219
263
  record the selected strategy and reason: unbounded auto continuation has the same
220
264
  `ordered_prefix_only` assurance as sequential traversal, while bounded plans additionally report
221
- `canary_verified_bounds`.
265
+ `caller_asserted_bounds`. Runtime traversal does not perform verifier canaries.
222
266
 
223
- <!-- tested: tests/client_v2_test.py::test_keyset_and_cursor_are_explicit_strict_alternatives -->
267
+ <!-- tested: tests/keyset_fast_test.py::test_omitted_execution_defaults_to_auto -->
224
268
  ```python
225
- from b24api import AutoKeysetExecution, KeysetSpec, ParameterPath, StableIntegerKeysetContract
269
+ import os
270
+
271
+ from b24api import KeysetSpec, ParameterPath, ReplaySafety, Request, ResultSelector, RouteKind
272
+
273
+ request = Request("example.item.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE)
274
+ selector = ResultSelector(("items",))
275
+ keyset = KeysetSpec(
276
+ filter_path=ParameterPath(("filter",)),
277
+ order_path=ParameterPath(("order",)),
278
+ )
279
+
280
+ if os.environ.get("ENV") != "PROD":
281
+ # Accepting an ID filter does not prove strict bounds or ordering.
282
+ await client.verify_keyset_capability(
283
+ request,
284
+ selector=selector,
285
+ identity=identity,
286
+ page_size=50,
287
+ keyset=keyset,
288
+ )
226
289
 
227
290
  stream = client.iter_list_keyset(
228
- Request("example.item.list", replay_safety=ReplaySafety.SAFE),
229
- selector=ResultSelector(("items",)),
291
+ request,
292
+ selector=selector,
230
293
  identity=identity,
231
- keyset=KeysetSpec(
232
- filter_path=ParameterPath(("filter",)),
233
- order_path=ParameterPath(("order",)),
234
- ),
235
- execution=AutoKeysetExecution(contract=StableIntegerKeysetContract()),
294
+ page_size=50,
295
+ keyset=keyset,
236
296
  )
237
297
  ```
238
298
 
@@ -243,10 +303,11 @@ message-list methods.
243
303
 
244
304
  <!-- tested: tests/client_v2_test.py::test_keyset_and_cursor_are_explicit_strict_alternatives -->
245
305
  ```python
306
+ from b24api import RouteKind
246
307
  from b24api import CursorSpec, ParameterPath
247
308
 
248
309
  stream = client.iter_list_cursor(
249
- Request("example.message.list", replay_safety=ReplaySafety.SAFE),
310
+ Request("example.message.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
250
311
  selector=ResultSelector(("items",)),
251
312
  cursor=CursorSpec(
252
313
  parameter_path=ParameterPath(("LAST_ID",)),
@@ -261,6 +322,11 @@ stream = client.iter_list_cursor(
261
322
  Cursor values must be unique and strictly monotonic. If an endpoint exposes only a non-unique
262
323
  boundary, use an application-owned direct-call workflow or supply a unique tie-breaker.
263
324
 
325
+ For multiple parent-bound cursor chains, `iter_cursors()` keeps cursor progress and correlation
326
+ isolated per binding while ready pages share the physical batch queue.
327
+
328
+ ![Cursor batching across independent chats](cursor-batching.svg)
329
+
264
330
  See [architecture](docs/architecture.md), [migration](docs/migration.md),
265
331
  [performance](docs/performance.md), and [endpoint recipes](docs/recipes.md) for the complete
266
332
  contracts and selection guidance.
@@ -271,8 +337,11 @@ contracts and selection guidance.
271
337
  client remains unaware of entity types: a binding can represent a deal, lead, chat or any other
272
338
  caller-defined parent.
273
339
 
340
+ ![Reference batching across leads and deals](references-batching.svg)
341
+
274
342
  <!-- tested: tests/client_v2_test.py::test_bound_references_apply_nested_updates_off_wire_and_emit_exact_completion -->
275
343
  ```python
344
+ from b24api import RouteKind
276
345
  from b24api import (
277
346
  BatchDispatch,
278
347
  Binding,
@@ -293,7 +362,7 @@ bindings = (
293
362
  )
294
363
 
295
364
  stream = client.iter_references(
296
- Request("example.comment.list", replay_safety=ReplaySafety.SAFE),
365
+ Request("example.comment.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
297
366
  bindings,
298
367
  traversal=SequentialTraversal(selector=ResultSelector(("items",)), identity=identity),
299
368
  dispatch=BatchDispatch(batch_size=25, concurrency=2),
@@ -313,6 +382,7 @@ under different parents are not conflated.
313
382
 
314
383
  <!-- tested: tests/client_v2_test.py::test_bound_references_apply_nested_updates_off_wire_and_emit_exact_completion -->
315
384
  ```python
385
+ from b24api import RouteKind
316
386
  from b24api import (
317
387
  Binding,
318
388
  CursorSpec,
@@ -334,7 +404,7 @@ chat_bindings = (
334
404
  )
335
405
 
336
406
  messages = client.iter_references(
337
- Request("example.message.list", replay_safety=ReplaySafety.SAFE),
407
+ Request("example.message.list", replay_safety=ReplaySafety.SAFE, route=RouteKind.BARE),
338
408
  chat_bindings,
339
409
  traversal=CursorTraversal(
340
410
  selector=ResultSelector(("items",)),
@@ -377,13 +447,19 @@ and publish the same final report where the Python exception type permits it.
377
447
  ## Resource boundaries
378
448
 
379
449
  `ExecutionPolicy` bounds requests, pages, elapsed time, attempts, decompressed response bytes,
380
- buffered commands and rows, direct concurrency and active references. The default response ceiling
381
- is 16 MiB and is enforced while streaming, before JSON decoding.
382
-
383
- Sequential and counted exact traversal retain observed identities in memory. There is no database,
384
- spill file or identity-count refusal. Crossing 100,000 distinct identities emits one
385
- `RuntimeWarning`; exact tracking continues. Strict keyset and cursor traversal retain only
386
- monotonic progression state when sufficient.
450
+ buffered commands and rows, retained unordered identity keys, direct concurrency and active
451
+ references. The default response ceiling is 16 MiB and is enforced while streaming, before JSON
452
+ decoding.
453
+
454
+ Sequential, counted, and multi-reference exact traversal retain at most `max_identity_keys`
455
+ observed identities per operation in memory (100,000 by default). All active reference bindings
456
+ share that ceiling. A page that would exceed it is rejected atomically with typed budget evidence.
457
+ Set a larger finite ceiling when the expected aggregate cardinality is known, or pass
458
+ `identity_store=` to `iter_list`/`iter_list_counted` so a caller-owned `IdentityStore` (for example a
459
+ SQLite table keyed by `identity_store_key(...)`) proves uniqueness while in-process identity memory
460
+ stays bounded by one page; the client never closes that store. Repeated-page detection still keeps
461
+ one short fingerprint per page, so raise `max_pages` deliberately for very long traversals.
462
+ Strict keyset and cursor traversal retain only monotonic progression state when sufficient.
387
463
 
388
464
  ## CLI
389
465
 
@@ -392,10 +468,10 @@ errors go to stderr. Credentials come only from `Settings` and cannot be passed
392
468
 
393
469
  <!-- tested-console: tests/cli_test.py::test_call_routes_replay_safety_and_keeps_success_data_on_stdout -->
394
470
  ```console
395
- b24api call profile
396
- b24api call example.item.get --params '{"id":7}' --raw --replay-safety safe
397
- b24api list example.item.list --params @params.json
398
- b24api list example.item.list --strategy counted --contract @counted-contract.json
471
+ b24api call profile --route bare
472
+ b24api call example.item.get --route bare --params '{"id":7}' --raw --replay-safety safe
473
+ b24api list example.item.list --route bare --params @params.json
474
+ b24api list example.item.list --route bare --strategy counted --contract @counted-contract.json
399
475
  ```
400
476
 
401
477
  The `--raw` CLI option selects the response envelope; it does not alter the Python API. Advanced