diff --git a/CLAUDE.md b/CLAUDE.md
index 521a9e3..e0e25e9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,11 +13,11 @@ three-state PATCH — solved exactly once, and it deliberately does not compete
Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements
across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy.
-**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c and 7b are built — the whole of phase 6 and
-one of phase 7's three sub-phases; the domain model, the seam layer, the byte-streaming layer, the body layer,
+**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b and 7c are built — the whole of phase 6 and
+two of phase 7's three sub-phases; the domain model, the seam layer, the byte-streaming layer, the body layer,
the execution context, the recovery layer, the stage pipeline, the configuration layer, the tracing and
metrics layer, the logging facade with its redaction, the retry layer, the authentication layer, the redirect
-layer and the server-sent-events layer are the only domain code.** Six gems exist under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`,
+layer, the server-sent-events layer and the pagination layer are the only domain code.** Six gems exist under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`,
`Response`, `Headers`, `Status`, `Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, `HeaderName`, the
`HeaderSyntax`, `PercentEncoding` and `URL` function modules, and the construction contract `Dexpace::Model` /
`Dexpace::Builder` under one error root, `Dexpace::Error`
@@ -163,9 +163,28 @@ through `.open(response)`, `.owning(source, resource:)` and `.borrowing(…)`
`#events` and `#typed`, `Closeable`'s latch, and `logger:` on every factory); the typed adapter `TypedStream`
(`#each`, `#values`, delegating `#close`); the two namespaced errors `LimitExceededError` and
`StreamStateError`; the RBS interface `_ByteSource`; and, the mechanism spec-forced boundary 5 asked for,
-the eighteenth gate `gates:serde_boundary` over `tools/serde_boundary.rb`, a parsed scan of `lib/dexpace/sse/**`
-and its `sig/` mirrors for any serialization dependency, the pagination layer's globs on its printed
-`PENDING` list until 7c lands (`docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md`);
+the eighteenth gate `gates:serde_boundary` over `tools/serde_boundary.rb`, a parsed scan of `lib/dexpace/sse/**`,
+`lib/dexpace/page/**` and their `sig/` mirrors for any serialization dependency — the pagination rows sat
+on its printed `PENDING` list while the two lanes were built one base apart and moved to `GUARDED` when
+7c was reconciled onto the same tree on 2026-09-20
+(`docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md`) — and the
+pagination layer, §7.1's chapter 12, under `Dexpace::Page`, a class that is also the namespace: the page
+value owning one live response behind `Closeable`'s latch, with `.build` and the public resolution branch
+`.next_request_from` (same-document — blank or fragment-only — unresolvable and non-dispatchable targets
+all end-of-stream); `Info`, whose nil `next_request` is the one end-of-stream signal, with `.terminal`;
+the raw-query splice `QueryRewriter` (`.get`, `.set`, `.rewrite_url`) over phase 1's `PercentEncoding`,
+never `Query`; the three frozen `Data`
+strategies `CursorStrategy`, `PageNumberStrategy` and `LinkStrategy`, every one over a caller-supplied
+`#call(response)` extractor and never a codec; the private RFC 8288 state machine `LinkHeader`; the
+private lifetime owner `Walk` — the one drive routine, the cap, the exhaustion latch and the two page slots
+— over a private per-walk drive, and the private `Closing` disciplines both views share; the re-iterable,
+eager-closing `Items` and the single-use, look-ahead `Pages` (`#more?`, `#close`), both `Enumerable`; the
+frozen engines `Paginator` (`#items`, `#pages`, `#each_item`, `#each_page`) and `AsyncPaginator` (`#walk`,
+`#walk_pages`, a re-arm trampoline over `Future#on_settle` with an optional `#post` executor); the
+fetcher front-end `Fetchers`; the state error `PageStateError`; the three RBS interfaces `_Strategy`,
+`_Extractor` and `_Executor` in `page.rbs`; and one widening of phase 1, `Dexpace::URL.resolve`, the
+RFC 3986 reference resolution beside `.parse!`
+(`docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md`);
every other gem's `lib/` still holds its namespace module and a `VERSION` constant and nothing else. Nothing talks to a
socket yet. The workspace root
carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and
@@ -222,7 +241,7 @@ bundle exec rake test:gems # gem suites: warnings fatal,
bundle exec rake test:gates # the repository's gate suites (test/gates/)
bundle exec rake gates:gemspec_audit # SEAM-1, NFR-1, NFR-2
bundle exec rake gates:require_allowlist # SEAM-1, SEAM-2: the allowlist and the denylist
-bundle exec rake gates:serde_boundary # SSE-37, spec-forced boundary 5: no serde under lib/dexpace/sse/**
+bundle exec rake gates:serde_boundary # SSE-37, spec-forced boundary 5: no serde under lib/dexpace/{sse,page}/**
bundle exec rake gates:clean_bundle # the scratch-Gemfile isolation run, all six gems
bundle exec rake gates:rbs_surface # NFR-11
bundle exec rake gates:sig_diff # NFR-4, RBS half (vacuous until the first v* tag)
@@ -520,7 +539,9 @@ Each is one line plus the chapter to read before touching the area.
`URI::RFC3986_PARSER.join("https://host/c?sig=1", "/pets")` is `https://host/pets`, dropping the base path
segment and the query the requirement keeps; `Dexpace::Operation` composes by hand. Reference resolution is
`REDIR-13`'s, phase 6, and its spelling there is `URI::RFC3986_PARSER.join`, because `URI.join` is cop-banned
- (phase 2's design, §3 addendum A1, deviation P2-3).
+ (phase 2's design, §3 addendum A1, deviation P2-3); phase 7c's `Dexpace::URL.resolve(base, reference)`
+ wraps the same call for `PAGE-19` and answers `nil` where the reference cannot resolve — reference
+ resolution, never composition, and `Operation` must never call it (7c's P7-3).
- **`Response#body_string` is the SDK's one decode boundary, and it is three steps, not one** — resolve the
charset through `MediaType#charset` (already `nil` for absent or unknown), **retag** the BINARY bytes with
3a's `#read_string(encoding)`, then transcode with the target **named**,
@@ -897,6 +918,40 @@ Each is one line plus the chapter to read before touching the area.
list). And the per-byte read path costs ~0.9 µs a byte through `BufferedSource#getbyte` — a mutex
acquisition and a one-byte String per call — against ~0.3 µs through a plain duck, which is why the
at-scale cap tests run over `FakeByteSource` and why the bulk path is a phase-10 item.
+- **A bare `ensure` that closes a page INVERTS `PAGE-13`/`PAGE-32`'s primary, and `$!` cannot repair it** —
+ a close raised from an `ensure` replaces the in-flight consumer error as the primary (its `#cause`),
+ and `$!` is thread-dynamically scoped, so it is a CALLER's unrelated error inside anything called from
+ the caller's `rescue`; the pagination layer records the primary in a `rescue ::Exception => error` arm
+ that re-raises unchanged and hands the local to `Page::Closing.close_walk`, which surfaces a close
+ failure when nothing is in flight (`PAGE-15`) and attaches it otherwise; `$!` appears nowhere under
+ `lib/dexpace/page/`, and `lifetime_test.rb` drives a walk from inside a caller's rescue (7c's P7-105).
+ `Dexpace.close_quietly` is reached only where a requirement says swallow: `PAGE-26`'s drop, `PAGE-32`'s
+ already-failed drain, `PAGE-30`'s rejected carry.
+- **Every live page a walk holds is an ivar on the private `Page::Walk` — `@current` and the one-slot
+ `@buffered` — never a local of an `Enumerator` block or a `#each` method** — `Items#each` opens a fresh
+ walk per call and closes every page BEFORE yielding its first item (`PAGE-11`), so it is safe under an
+ abandoned `#next`; `Pages` is single-use (a second `#each`, block or none, raises `Page::PageStateError`,
+ a state error and never `InvalidArgumentError` — the roadmap's 2026-09-13 inbound bullet) and holds up to
+ two pages, which `#each_page` or `#close` releases; `Walk#hold` writes the new page into its slot before
+ closing the previous, `Walk#buffer` never displaces a staged page, and the engines' `Data`s hold no
+ per-walk state at all (`PAGE-8`, `PAGE-12`, `PAGE-14`; 7c's P7-103, P7-111).
+- **The built-in strategies take an extractor, `#call(response)`, and the words `Serde` and `JSON` appear
+ NOWHERE under `lib/dexpace/page/` or in `page.rb` — YARD and strings included** — 7b's
+ `tools/serde_boundary.rb` scans without stripping comments and its `page/**` rows went `GUARDED` on
+ 2026-09-20, when 7c was reconciled onto the tree that holds the gate — `page.rb`, `page/**` and both
+ `sig/` mirrors, each violation naming "spec-forced boundary 5"; `page_test.rb` scans the fifteen files
+ for the two tokens as 7c's own guard beside it (7c's P7-6, P7-108). The blocking engine passes `Cancellation.none` to the transport, never `nil`, which dies inside
+ `Pipeline.standard`'s retry step (P7-102); `Page.next_request_from` screens a resolved target for an
+ http/https scheme and a host, 6b's screen copied because `Redirect::Location` is private (P7-104), and
+ reads RFC 3986 §4.4's two same-document forms — `<>` and `<#…>` — off the RAW target before resolving,
+ never off the resolved URL, so `/>` and the current URL spelled out are followed and the cap bounds
+ them (P7-5, P7-117).
+- **`AsyncPaginator`'s pump is a re-arm trampoline whose every callback is total, and its future settles
+ with the page count, never nil** — a raising `on_settle` block escapes `Completer#fulfil` through
+ `Hooks.notify`'s re-raise, so every body runs inside `Pump#guarded`; a pump re-entering itself from the
+ settlement overflows at ~2,600 pages through the real `Completer`; with an `executor:` the FIRST dispatch
+ is posted too, so a queued executor fetches nothing until it runs (`PAGE-29`, `PAGE-31`; 7c's P7-109,
+ P7-112). `Future#value(deadline:)` is the bounded wait a test uses where "hangs" is the failure mode.
## Public API surface
@@ -999,16 +1054,17 @@ probe compares each against the live tree, and a count written anywhere else in
body layer, the phase-4a execution context, the phase-4b recovery layer, the phase-4c stage pipeline,
the phase-5a configuration layer, the phase-5b logging facade and redaction, the phase-5c tracing and
metrics layer, the phase-6a retry layer, the phase-6c authentication layer, the phase-6b redirect
- layer and the phase-7b server-sent-events layer — one hundred and ninety-three phase-1, phase-2,
- phase-3a, phase-3b, phase-4a, phase-4b, phase-4c, phase-5a, phase-5b, phase-5c, phase-6a, phase-6b,
- phase-6c and phase-7b files under `lib/dexpace/`
- beside phase 0's `version.rb`, every one mirrored in `sig/`, and every one of the one hundred and
- ninety-three but the eighteen `private_constant`s `hooks.rb`, `context/call_key.rb`,
+ layer, the phase-7b server-sent-events layer and the phase-7c pagination layer — two hundred and
+ eight phase-1, phase-2, phase-3a, phase-3b, phase-4a, phase-4b, phase-4c, phase-5a, phase-5b,
+ phase-5c, phase-6a, phase-6b, phase-6c, phase-7b and phase-7c files under `lib/dexpace/` beside
+ phase 0's `version.rb` (7b's nine are `sse.rb` and the eight under `sse/`; 7c's fifteen are `page.rb`
+ and the fourteen under `page/`), every one mirrored in `sig/`, and every one of the two hundred and
+ eight but the nineteen `private_constant`s `hooks.rb`, `context/call_key.rb`,
`recovery/ownership.rb`, `pipeline/sync_driver.rb`, `pipeline/async_driver.rb`,
`configuration/parsers.rb`, `deep_value.rb`, `proxy/resolution.rb`, `instrumentation/render.rb`,
`instrumentation/emitter.rb`, `resilience/pacing_parsers.rb`, `resilience/retry_step_helpers.rb`,
`auth/validation.rb`, `redirect/origin.rb`, `redirect/location.rb`, `redirect/chain.rb`,
- `redirect/emitter.rb` and `redirect/reissue.rb` mirrored
+ `redirect/emitter.rb`, `redirect/reissue.rb` and `page/closing.rb` mirrored
in `test/`; every
other
gem is a phase-0 skeleton whose `lib/` holds the namespace module and a `VERSION` constant and nothing
@@ -1021,7 +1077,7 @@ probe compares each against the live tree, and a count written anywhere else in
carry that phase's design, plan and checklist; `phase3/` carries its segmentation design,
`docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories —
`phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design, plan and checklist —
- fifteen checklists written so far, each at implementation; `phase4/`
+ sixteen checklists written so far, each at implementation; `phase4/`
carries its segmentation design,
`docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md`, and three sub-phase
directories — `phase4/phase4a/`, `phase4/phase4b/` and `phase4/phase4c/`; each holds that sub-phase's
@@ -1042,8 +1098,8 @@ probe compares each against the live tree, and a count written anywhere else in
`phase7/` carries its segmentation design,
`docs/work/mvp/phase7/2026-09-10-phase7-segmentation-design.md`, and three sub-phase
directories — `phase7/phase7a/` (serialization), `phase7/phase7b/` (server-sent events) and
- `phase7/phase7c/` (pagination); each holds a design and a plan, and `phase7b/` its checklist too,
- written at implementation on 2026-09-20. Phase 7 is 107 IDs
+ `phase7/phase7c/` (pagination); each holds a design and a plan, and `phase7b/` and `phase7c/` their
+ checklists too, both written at implementation on 2026-09-20. Phase 7 is 107 IDs
(`SERDE-1`–`30`, `SSE-1`–`41`, `PAGE-1`–`36`) and ships the workspace's second real gem,
`dexpace-serde-json`, inside `7a`. Its three sub-phases are independent — `SSE-37` makes `7b`'s
serde-independence a mechanised MUST, and §12's chapter intro states the same property for
@@ -1094,6 +1150,7 @@ probe compares each against the live tree, and a count written anywhere else in
and closes or narrows five `docs/first-release.md` lines while publishing nothing: every gem stays
at `0.0.0`.
Every checklist but phase 0's, phase 1's, phase 2's, phase 3a's, phase 3b's, phase 4a's, phase 4b's,
- phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's and phase 7b's is
+ phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's, phase 7b's and
+ phase 7c's is
still to be written at execution time.
- There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05.
diff --git a/README.md b/README.md
index 3c48130..efd6f53 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin
## Status
-**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a and 6c are built.** Nothing is published. The repository holds six gems under
+**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b and 7c are built.** Nothing is published. The repository holds six gems under
`gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — the frozen,
validated wire types every later phase stands on (`docs/sdk-documentation/http.md`) — the seam
layer: the provider registry, the transport and codec seams, the core-owned async pivot,
@@ -69,8 +69,13 @@ server-sent-events layer: the WHATWG line machine over `BufferedSource#getbyte`
documented, rejecting caps, the immutable five-field event, the field machine whose one persistent
state is the BOM flag, the resource-owning single-pass stream facade with its three factories and
its two consumption shapes, the typed adapter over a caller-supplied mapper with `SKIP` and `DONE`,
-and the eighteenth gate, `gates:serde_boundary`, a parsed scan that keeps the layer free of any
-serialization dependency (`docs/sdk-documentation/sse.md`); the
+and the eighteenth gate, `gates:serde_boundary`, a parsed scan that keeps the layer and the
+pagination layer free of any serialization dependency (`docs/sdk-documentation/sse.md`) — and the
+pagination layer: the page value that owns one live response, the strategy contract with its three
+built-in strategies over a caller-supplied extractor and never a codec, the byte-for-byte query
+splice, the eager-closing item view and the single-use, look-ahead page view over one private walk,
+the blocking engine, the non-blocking engine driven through `Future#on_settle` as a re-arm
+trampoline, the fetcher front-end, and `URL.resolve` (`docs/sdk-documentation/pagination.md`); the
operation-lifecycle and transport groups of the HTTP-tracer vocabulary are emitted by nothing yet,
and nothing talks to a socket yet. The other five are still skeletons — a namespace, a `VERSION`, a gemspec, a
signature mirror and a smoke suite:
diff --git a/docs/README.md b/docs/README.md
index a90f2b5..6b99fde 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -104,10 +104,10 @@ root `VERSIONS` file, a `sig/` mirror and a smoke suite. `dexpace-core` holds ph
domain model, phase 2's seam layer, phase 3a's byte-streaming layer, phase 3b's body layer, phase
4a's execution context, phase 4b's recovery layer, phase 4c's stage pipeline, phase 5a's
configuration layer, phase 5b's logging facade and redaction, phase 5c's tracing and metrics
-layer, phase 6a's retry layer and phase 6c's authentication layer, and phase 7b's server-sent-events
-layer beside them; the other five are
+layer, phase 6a's retry layer, phase 6c's authentication layer, phase 6b's redirect layer, phase
+7b's server-sent-events layer and phase 7c's pagination layer; the other five are
phase 0's skeletons, a namespace and a `VERSION`. The as-built documentation lands in
-[`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the fifteen pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
+[`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the sixteen pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
gate set is the thing phase 0 built, [`sdk-documentation/http.md`](./sdk-documentation/http.md),
because the domain model is the thing phase 1 built,
[`sdk-documentation/seams.md`](./sdk-documentation/seams.md), because the seam layer is the thing
@@ -129,11 +129,14 @@ because the logging facade and redaction are the things phase 5b built,
[`sdk-documentation/retry.md`](./sdk-documentation/retry.md), because the retry layer — one policy
core and its two stacks — is the thing phase 6a built,
[`sdk-documentation/auth.md`](./sdk-documentation/auth.md), because the authentication layer is
-the thing phase 6c built, and
+the thing phase 6c built,
[`sdk-documentation/redirect.md`](./sdk-documentation/redirect.md), because the redirect layer and
-the two `standard` constructors are the things phase 6b built, and
+the two `standard` constructors are the things phase 6b built,
[`sdk-documentation/sse.md`](./sdk-documentation/sse.md), because the server-sent-events layer and
-the serde-boundary gate are the things phase 7b built.
+the serde-boundary gate are the things phase 7b built, and
+[`sdk-documentation/pagination.md`](./sdk-documentation/pagination.md), because the pagination
+layer — the page value, the strategies, the two views, the two engines and the fetcher front-end —
+is the thing phase 7c built.
## Keeping this file true
diff --git a/docs/knowledge/notes/pagination.md b/docs/knowledge/notes/pagination.md
index c0ba3e0..f09dffa 100644
--- a/docs/knowledge/notes/pagination.md
+++ b/docs/knowledge/notes/pagination.md
@@ -9,3 +9,13 @@ stable key.
review · `docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md` · high · sha:manual-phase3-enumerator-range
- **The rule reaches an ordinary `#each` method, not only an `Enumerator.new` block, and no guard inside the method can catch it.** Supersedes `pagination/f57c50f6` ("Resource acquisition and release must never live inside an Enumerator block in the Ruby SDK, because Ruby's cleanup guarantee on break only holds for internal iteration and not for external iteration via #next"), which states the right rule over one class of object too few. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. **The fact:** a plain `def each; @log << :open; begin; yield "a"; yield "b"; yield "c"; ensure; @log << :close; end; end` — no `Enumerator.new` anywhere — behaves exactly like the generator block does. Driven with `obj.to_enum(:each)`, two `#next` calls and then dropping the reference, the `ensure` is **unrun** after two `GC.start` calls; `#rewind` does not run it either; `obj.each { |c| break if c == "b" }` **does** run it; and a full external drive to `StopIteration` **does** run it, which is why `Dexpace::IO::BufferedSource.over(body)` draining a body to exhaustion is safe and only early abandonment is not. Uniform across the range. **The half that closes the obvious escape hatch:** `block_given?` is **true** inside `#each` when the method is reached through `to_enum(:each)` and `#next`, because the enumerator supplies the yielder as the block — so a `raise ArgumentError unless block_given?` guard, which looks like it would forbid external iteration, does nothing at all. There is no in-method defence; the only defence is where the resource lives. **Why the widening matters rather than being a tidier citation:** `docs/sdk-design-ruby/07-pagination-sse-and-serialization.md` §7.1's rule is read by every phase as "do not write `Enumerator.new { ... ensure ... }`", and phase 3b's bodies do not write one — `Dexpace::Body#each` is an ordinary method derived from `#write_to`. Under the narrow reading a `Dexpace::FileBody` that opens its handle inside `#each` looks compliant and leaks a file descriptor on abandonment. **What the SDK does**, per `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle-design.md`: bodies that hold a resource hold it **on the object** with `#close` — `Dexpace::ResponseBody` and an ownership-transferred `Dexpace::StreamBody` — so abandoning their enumerator leaks nothing `#close` would not still release; and `Dexpace::FileBody`, which `BODY-11` obliges to open a fresh handle **per write** so the handle cannot live on the object, states the residue in its YARD instead of pretending to close it, on the same principle as `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md` item 10 and `BufferedSource.over`'s own documented residue. `resource-management/1676974d` is why a finalizer is not the answer.
review · `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle-design.md` · high · sha:manual-phase3b-each-method-abandonment
+
+## Reference
+- **The SSE facade's single-use rule is filed under `PAGE-14` and under no `SSE` ID, and the defect is a PAIR, not a singleton.** `sse-streaming/5f4803a0` (Rules: "An SSE Enumerable view must not be taken twice over the same source … the facade enforces this by latching a `@viewed` flag") and its Conclusions companion `sse-streaming/b94ce49e` ("The single-use guard on the SSE `Enumerable` view is a SHOULD requirement that the port implements outright") both carry only `PAGE-14`, so `--prefix PAGE --section rules` hands a pagination author the SSE facade's latch and `--prefix SSE` returns neither. Both rules are right about SSE; only the attribution is wrong, and a correction naming `5f4803a0` alone leaves half of it standing. Phase 7c's own `PAGE-14` latch is `Dexpace::Page::Pages`' `@viewed` flag raising `Dexpace::Page::PageStateError` — the same shape as `SSE-26`'s, on an unrelated object (the phase-7 segmentation design's *Convergence points*), and the same error family (the roadmap's phase-10 inbound bullet of 2026-09-13 on `PAGE-14` / `SSE-26`; 7c's ledger row P7-103). Found by 7c's design on 2026-09-10 while running the pagination audit group, confirmed at implementation on 2026-09-20; a harvest attribution, routed here because `harvested/` is never hand-edited.
+ review · `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md` · high · sha:manual-phase7c-sse-under-page14
+- **The `#each`-method entry above (the note keyed pagination/b2a85752) carries `BODY-11` and not "no requirement ID at all".** The phase-7 segmentation design's corpus-attribution finding characterises it as an entry with no ID; measured, `--req BODY-11 --origin note` and `--prefix BODY` return it while `--prefix PAGE` and `--prefix PAGE --origin note` do not, although its text is the whole of `PAGE-11`/`PAGE-12`'s lifetime rule. The consequence the charter names is exactly right — a pagination author's audit group misses it — and the characterisation is not, which matters because a reader chasing "an entry with no IDs" will not find it and may conclude the defect was fixed. Recorded beside the entry it describes so the next harvest, or a re-keying of the note, is checked against it.
+ review · `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md` · high · sha:manual-phase7c-b2a85752-carries-body11
+- **§12's transport-agnosticism and serde-agnosticism are harvested nowhere.** `pagination/cb5f1b9e` harvests `docs/product-spec/12-pagination.md:3` as its second sentence only — "A port MUST preserve the two-view model, the page-lazy fetch discipline, deterministic response-lifecycle management, and the strategy contract" — and the same line's first sentence, "It is transport-agnostic and serde-agnostic: a single stateless *strategy* parses each response into the page's items plus the fully-formed request for the next page (or an end-of-stream signal)", appears in no entry: `--grep 'serde-agnostic|transport-agnostic'` returns exactly one hit, `http-domain-model/f4bd2330`, which is `XCUT-18`'s validation layer and unrelated. The property carries no requirement ID, is the reason the phase-7 segmentation design's spec-forced boundary 5 exists, and is what phase 7c builds to: the built-in strategies take a caller-supplied `#call(response)` extractor and never a serializer (7c's P7-6), nothing under `lib/dexpace/page/` names one, `gems/dexpace-core/test/dexpace/page_test.rb` scans the fifteen files for the two bare tokens, and phase 7b's `tools/serde_boundary.rb` (its Task 11) is the gate that carries the directory's row. A harvest-COVERAGE gap rather than an attribution one — a species neither the CLI's `[cited by …]`/`[overridden by …]` conflation nor the `--prefix` rules gap covers — recorded so the next harvest can be checked against it.
+ review · `docs/product-spec/12-pagination.md:3` · high · sha:manual-phase7c-serde-agnosticism-unharvested
+- **`R8`'s "read `$!` at the top of the `ensure`" is unsafe for the reason the pipeline note keyed pipeline/7ce4431d (`notes/pipeline.md:8`) already measured, and the pagination layer is built without it.** Adds to that note, whose finding — `$!` is dynamically scoped to the thread and non-nil inside anything called from a CALLER's `rescue` — reaches the close discipline as well as the re-raise: an `ensure` that read `$!` to decide between surfacing a close failure (`PAGE-15`) and attaching it (`PAGE-13`, `PAGE-32`) would, inside a consumer's own rescue block, attach the walk's close failure to an unrelated in-flight error and swallow what the requirement says to surface. Measured on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 on 2026-09-20 (`gems/dexpace-core/test/dexpace/page/matrix_facts_test.rb`, fact 2) and driven as a test (`lifetime_test.rb`, the two `CALLER'S RESCUE` cases): the frame that owns the walk records the primary in a `rescue ::Exception => error` arm that re-raises unchanged and hands the local to `Dexpace::Page::Closing.close_walk`; `$!` appears nowhere under `lib/dexpace/page/` (7c's ledger row P7-105). The measurement under `R8` — a bare `ensure` whose close raises REPLACES the in-flight error as the primary — stands and is what the shape exists to answer.
+ review · `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md` · high · sha:manual-phase7c-r8-without-dollar-bang
diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md
index ad86bcd..796611b 100644
--- a/docs/sdk-documentation/architecture.md
+++ b/docs/sdk-documentation/architecture.md
@@ -8,17 +8,18 @@ recovery layer and the error trail, phase 4c the stage pipeline, phase 5a the co
layer and the clock, phase 5c the tracing and metrics layer, phase 5b the logging facade and
redaction, phase 6a the retry layer — one policy core and its two stacks — phase 6c the
authentication layer, phase 6b the redirect layer with the two `standard` pipeline
-constructors and phase 7b the server-sent-events layer with the serde-boundary gate; the adapters
-are still to come, so this page is still a stub: it names the
+constructors, phase 7b the server-sent-events layer with the serde-boundary gate and phase 7c the
+pagination layer; the adapters are still to come, so this page is still a stub: it names the
pages this tree will eventually hold and where each one's content will come from, so the plan for
-the documentation exists before the documentation does. Fifteen pages are real already, because
+the documentation exists before the documentation does. Sixteen pages are real already, because
their subjects are: [`quality-gates.md`](./quality-gates.md), [`http.md`](./http.md),
[`seams.md`](./seams.md), [`io.md`](./io.md), [`body.md`](./body.md),
[`execution-context.md`](./execution-context.md), [`recovery.md`](./recovery.md),
[`pipelines.md`](./pipelines.md), [`configuration.md`](./configuration.md),
[`tracing-and-metrics.md`](./tracing-and-metrics.md),
[`logging-and-redaction.md`](./logging-and-redaction.md), [`retry.md`](./retry.md),
-[`auth.md`](./auth.md), [`redirect.md`](./redirect.md) and [`sse.md`](./sse.md).
+[`auth.md`](./auth.md), [`redirect.md`](./redirect.md), [`sse.md`](./sse.md) and
+[`pagination.md`](./pagination.md).
Once `dexpace-core` and the first adapters ship, this page becomes the same kind of front door the
sibling Node SDK's `docs/sdk-documentation/architecture.md` is — package by package, seam by seam
— and the entries below turn from plain text into real links, one at a time, as each page is
@@ -176,8 +177,11 @@ write-a-serde.md — implementing the `Serde` seam: the serializer/deserializer
`Tristate` PATCH convention, and the four encode profiles. Derives from
`docs/sdk-design-ruby/07-pagination-sse-and-serialization.md`.
-write-a-paging-strategy.md — implementing a pagination strategy over a response and a template
-request. Derives from `docs/sdk-design-ruby/07-pagination-sse-and-serialization.md`.
+[pagination.md](./pagination.md) covers what write-a-paging-strategy.md was planned to hold —
+the strategy contract (`#parse(response, template) -> Info`), the three built-ins over a
+caller-supplied extractor, and `Page.next_request_from` as the supported route for a strategy
+written by hand — so that page is retired rather than written twice. Written against the layer
+phase 7c shipped; derives from `docs/sdk-design-ruby/07-pagination-sse-and-serialization.md` §7.1.
write-a-response-handler.md — turning a `Response` into a caller's own model, including the
memoizing-wrapper pattern for a body read more than once. Derives from
diff --git a/docs/sdk-documentation/pagination.md b/docs/sdk-documentation/pagination.md
new file mode 100644
index 0000000..a692e78
--- /dev/null
+++ b/docs/sdk-documentation/pagination.md
@@ -0,0 +1,393 @@
+# Pagination
+
+**As built by phase 7c, in `dexpace-core`, written against source on 2026-09-20.** This page says what
+chapter 12 gives an SDK author today: the page value that owns one live response, the strategy contract
+with its three built-in strategies, the byte-for-byte query splice, the two consumption views over one
+lazy walk, the blocking engine, the non-blocking engine driven through phase 2's `Future#on_settle`, and
+the fetcher-based front-end. What each is *required* to do is `docs/product-spec/12-pagination.md`
+(`PAGE-1`–`PAGE-36`); how the design maps it to Ruby is
+`docs/sdk-design-ruby/07-pagination-sse-and-serialization.md` §7.1, read together with entry 6 of
+`docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`; the per-requirement proof
+is `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md`, and the phase's own
+decisions are the ledger of `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md`.
+Signatures live in `gems/dexpace-core/sig/dexpace/page.rbs` and `sig/dexpace/page/`, and this page does
+not restate them. Every example below was run against the built code on 4.0.6 and 3.2.11 and printed
+the same on both. The examples use two test doubles from `gems/dexpace-core/test/support/` —
+`ScriptedTransport`, whose script is one reply per call, and its async twin `ScriptedAsyncTransport` —
+because a paginator sends through a transport and nothing on this page should talk to a socket; `P` is
+`Dexpace::Page` throughout, `req(url)` builds a request, `response(request, headers:)` a response over
+a closable body whose `#closes` counts, and `cursor_server` a scripted transport that answers each
+request with a response built around it.
+
+**Transport-agnostic and serde-agnostic, and the second is a rule with a gate.** A paginator takes any
+object answering `#call(request, options, cancellation)` — a `Pipeline`, a bare adapter, a lambda — and
+never closes it (`PIPE-26`, `PIPE-27`: a built pipeline is a transport whose `#close` is a no-op). It
+names no serializer: the built-in strategies take a caller-supplied **extractor**, `#call(response)`,
+and a caller who wants a codec writes it into that closure, outside core. Nothing under
+`lib/dexpace/page/` spells a serializer's name, in code or in comment, and the suite scans for it;
+phase 7b's `gates:serde_boundary` is the gate that will hold the row.
+
+## The page value: `Page`
+
+`Dexpace::Page` is one page of a walk and the namespace of the whole subsystem. It is built through
+`.build(response:, items:, next_link:, continuation_token:)` over a real `Dexpace::Response` — the two
+optional keys nil or a String copied frozen, anything else refused by name — with `.new` private, and it
+owns that response: whoever holds the page owns closing it, the close is latched
+(`Dexpace::Closeable`, so a second `#close` touches nothing), and it forwards to the response exactly once
+(`PAGE-3`, `PAGE-27`). The materialized `#items` (a frozen shallow copy — the collection is the page's,
+the elements stay the caller's), `#status`, `#headers` and `#request` are read off frozen values and
+stay readable after the close; only the body is invalidated (`PAGE-2`). It is a plain class rather than
+a `Data` because a `Data` instance is frozen and cannot hold the latch, and the latch cannot be borrowed
+from the response, whose body may be `nil` or a `BufferBody` whose close is a documented no-op.
+
+```ruby
+page = P.build(response: response(req("https://api.example/v1/items?page=1")), items: [1, 2])
+page.items # => [1, 2]
+page.status.code # => 200
+page.close
+page.close
+page.response.body.closes # => 1
+page.items # => [1, 2]
+Dexpace::URL.external_form(page.request.url) # => "https://api.example/v1/items?page=1"
+```
+
+## The strategy contract: `Info` and the three built-ins
+
+A strategy is any object answering `#parse(response, template) -> Dexpace::Page::Info`, validated with
+`respond_to?` and never by a nominal test (the RBS interface is `Dexpace::Page::_Strategy`). It reads
+everything it needs synchronously inside `#parse`, retains nothing, closes nothing, and is immutable
+(`PAGE-5`); the three built-ins are frozen `Data`s with no instance state at all, so one instance serves
+any number of concurrent walks. `Info` carries the page's items and the next request, and **a `nil` next
+request is the single end-of-stream signal** — no flag, no sentinel, no exception path (`PAGE-4`);
+`Info.terminal(items:)` names that case so an empty items list is never mistaken for it.
+
+```ruby
+info = P::Info.build(items: [1], next_request: req("https://api.example/v1/items?page=2"))
+info.next_request.url.query # => "page=2"
+P::Info.terminal(items: [9]).next_request # => nil
+P::Info.build(items: [], next_request: req("https://h/")).items # => []
+```
+
+**`CursorStrategy.build(extract:, parameter: "cursor")`** (`PAGE-16`) calls the extractor once,
+expects `[items, cursor]` back, treats a `nil` or empty cursor as end-of-stream, and otherwise splices
+the cursor into the template's query under the parameter name — replacing an earlier value in place,
+never appending a second. Core reads the body zero times; an extractor that reads through
+`Response#body_string` gets the single-use rule from phase 3b's body (a second read raises
+`Dexpace::ClosedError`). An extractor that raises is a parse failure, never an end-of-stream.
+
+```ruby
+template = req("https://api.example/v1/items")
+cursor = P::CursorStrategy.build(extract: ->(r) { [[r.status.code], r.headers["x-next"]&.first] })
+cursor.parse(response(template, headers: { "X-Next" => "c2" }), template).next_request.url.query
+# => "cursor=c2"
+cursor.parse(response(template), template).next_request # => nil
+cursor.parse(response(template), template).items # => [200]
+```
+
+**`PageNumberStrategy.build(extract_items:, parameter: "page", start: 1)`** (`PAGE-17`) treats an
+empty items list as end-of-stream before anything else is computed, reads the current page from the
+**executed** request — `response.request`, the final hop after any redirect chain — and falls back to
+`start` when the parameter is absent, empty or not a run of ASCII digits (a percent-encoded digit run is
+read decoded and counts); `start: 0` serves a 0-based server.
+
+```ruby
+numbered = P::PageNumberStrategy.build(extract_items: ->(_r) { [1] })
+numbered.parse(response(req("https://api.example/v1/items?page=4")), template).next_request.url.query
+# => "page=5"
+numbered.parse(response(template), template).next_request.url.query # => "page=2"
+P::PageNumberStrategy.build(extract_items: ->(_r) { [] }).parse(response(template), template).next_request
+# => nil
+```
+
+**`LinkStrategy.build(extract_items:, header: "Link")`** (`PAGE-18`–`PAGE-20`) reads every instance
+of the header, joins them, scans them by RFC 8288's grammar — a character-level state machine, not a
+regexp, because a comma inside `<…>` or inside a quoted value must not split link-values and a quoted
+pair must be honoured — and takes the first link-value whose `rel` carries the token `next`, quoted or
+not, in any case; only a link-value's first `rel` parameter is read, as RFC 8288 §3.3 requires, so
+`; rel="prev"; rel="next"` is a prev link. The target is resolved against the originating page's
+response URL as an RFC 3986 reference (a query-only `` keeps the whole path), and it is
+end-of-stream, never an error, when the header or the segment is absent, when the target is a
+same-document reference — blank, or fragment-only like `<#top>`, RFC 3986 §4.4's two forms, which would
+otherwise re-fetch the current page until the cap — when it cannot resolve, or when it resolves to
+something this client cannot dispatch — `mailto:`, `javascript:`, a host-less `http:foo`. That
+same-document check reads the raw target, never the resolved URL: `>`, `/>` or the current URL
+spelled out are followed like any other next link, and a server that loops through one is bounded by
+the cap.
+
+```ruby
+link = P::LinkStrategy.build(extract_items: ->(_r) { [1] })
+linked = response(req("https://api.example.com/repo/issues?page=1"),
+ headers: { "Link" => '; rel="last", ; rel="next"' })
+Dexpace::URL.external_form(link.parse(linked, template).next_request.url)
+# => "https://api.example.com/repo/issues?page=2"
+link.parse(linked, template).next_link # => "?page=2"
+link.parse(response(template, headers: { "Link" => "; rel=next" }), template).next_request
+# => nil
+```
+
+That resolution is public as **`Page.next_request_from(template, response, target)`**, because a next-page
+URL carried in the response *body* is the one common shape none of the three built-ins covers, and a
+strategy written by hand that re-derives the branch gets the end-of-stream rules quietly wrong. It
+answers the template with only its URL swapped — method, headers and body travel unchanged (`PAGE-23`).
+
+```ruby
+P.next_request_from(template, response(template), "?page=2").url.query # => "page=2"
+P.next_request_from(template, response(template), " ") # => nil
+P.next_request_from(template, response(template), "#top") # => nil
+```
+
+## The query splice: `QueryRewriter`
+
+`PAGE-21`–`PAGE-24`, as three pure functions over the raw query substring. `.set(query, name, value)`
+replaces the first occurrence in place, drops later duplicates, appends when absent and removes when the
+value is `nil`; every other segment is copied byte-for-byte — a value-less flag stays value-less,
+`filter=a:b` keeps its bare colon — and only the targeted name and value are encoded, with RFC 3986
+component encoding (space to `%20`, a literal `+` to `%2B`). `.get(query, name)` decodes with the same
+semantics, first match wins, a flag reads as `""`. `.rewrite_url(uri, name, value)` changes the query
+and nothing else. Phase 1's `Dexpace::Query` is deliberately not used here: it re-encodes every
+parameter, which is the canonicalisation `PAGE-21` forbids; and Ruby's `URI.decode_www_form` /
+`encode_www_form_component` are the exact inverse of `PAGE-22`. Removing the only parameter yields a URL
+with no `?` at all, not a dangling one (the phase's `P7-4`).
+
+```ruby
+QR = P::QueryRewriter
+QR.set("flag&filter=a:b&page=1", "page", "2") # => "flag&filter=a:b&page=2"
+QR.set("", "q", "a b") # => "q=a%20b"
+QR.set("", "token", "a+b/c=") # => "token=a%2Bb%2Fc%3D"
+QR.get("q=a+b", "q") # => "a+b"
+QR.get("flag", "flag") # => ""
+QR.set("page=1&sort=asc", "page", nil) # => "sort=asc"
+QR.set("page=1", "page", nil) # => nil
+url = Dexpace::URL.parse!("https://user:pw@h:8443/a/b?flag&page=1#frag")
+Dexpace::URL.external_form(QR.rewrite_url(url, "page", "2")) # => "https://user:pw@h:8443/a/b?flag&page=2#frag"
+```
+
+The RFC 3986 resolution the Link strategy uses is phase 1's `Dexpace::URL`, widened by one function:
+`URL.resolve(base, reference)` wraps the pinned `URI::RFC3986_PARSER.join` and answers `nil` for a
+reference that cannot resolve. It is reference resolution and never `SEAM-27`'s base-URL composition,
+which `Dexpace::Operation` does by hand because the two disagree on a query-bearing base.
+
+```ruby
+base = Dexpace::URL.parse!("https://api.example.com/repo/issues?page=1")
+Dexpace::URL.external_form(Dexpace::URL.resolve(base, "?page=2"))
+# => "https://api.example.com/repo/issues?page=2"
+Dexpace::URL.resolve(base, "not a url") # => nil
+```
+
+## The blocking engine: `Paginator`, `Items`, `Pages`
+
+`Paginator.build(transport:, template:, strategy:, cap: Float::INFINITY, options: RequestOptions::EMPTY)`
+is a frozen `Data` of immutable configuration and nothing else, so one instance is shared freely
+(`PAGE-8`). Nothing is fetched until a consumer probes: constructing it, obtaining a view and obtaining
+an enumerator all cost zero exchanges, and then exactly one exchange per page consumed (`PAGE-6`). The
+per-call `options:` are passed to the transport on **every** page, not only the first (`PAGE-36`); the
+cancellation the transport receives is always `Cancellation.none` — the blocking engine takes no
+per-walk token in v1. `cap:` counts exchanges, is validated strictly positive at construction
+(`PAGE-9`), and defaults to `Float::INFINITY`; **set a finite cap in production**, because it is the
+only bound the engine has over a server that never advances its cursor (`PAGE-10`). The `strategy`
+every engine example below shares is one cursor strategy that reads the page it is on off the
+**executed** request and answers a three-page walk:
+
+```ruby
+strategy = P::CursorStrategy.build(extract: lambda { |r|
+ case P::QueryRewriter.get(r.request.url.query, "cursor")
+ when nil then [[1, 2], "c2"]
+ when "c2" then [[3], "c3"]
+ else [[4], nil]
+ end
+})
+transport = cursor_server
+paginator = P::Paginator.build(transport: transport, template: template, strategy: strategy)
+paginator.frozen? # => true
+paginator.cap # => Infinity
+paginator.items; transport.calls.size # => 0
+paginator.items.to_a # => [1, 2, 3, 4]
+transport.calls.size # => 3
+transport.calls.map { |(r, _o, _c)| r.url.query } # => [nil, "cursor=c2", "cursor=c3"]
+P::Paginator.build(transport: transport, template: template, strategy: strategy, cap: 0)
+# => Dexpace::InvalidArgumentError: cap must be a strictly positive number, got 0 (PAGE-9)
+capped = P::Paginator.build(transport: cursor_server(10), template: template, cap: 3,
+ strategy: P::CursorStrategy.build(extract: ->(_r) { [[1], "again"] }))
+capped.items.to_a # => [1, 1, 1]
+capped.transport.calls.size # => 3
+```
+
+**`#items`** is the item-level view: every page's items in server order across page boundaries, over a
+fresh walk on every `#each` (so `to_a` twice is two full fetch sequences), with **each page closed
+before its first item is yielded** (`PAGE-11`) — which is what makes this view safe under external
+iteration, since an `Enumerator` abandoned mid-`#next` never runs its `ensure`: at every point a
+consumer can stop, nothing is open. `Items#close` is a documented no-op for the same reason. Both views
+`include Enumerable`, so `first`, `take`, `lazy` and `count` work, each spending exactly the exchanges
+it consumes.
+
+```ruby
+transport2 = cursor_server
+paginator2 = paginator.with(transport: transport2)
+paginator2.items.first(1) # => [1]
+transport2.calls.size # => 1
+transport2.calls.first[1].equal?(Dexpace::RequestOptions::EMPTY) # => true
+```
+
+**`#pages`** is the page-level view: whole live pages, each open for the length of the consumer's turn,
+the previous one closed as the consumer advances and the last at exhaustion (`PAGE-12`). It is
+**single-use** — obtaining its iterator a second time raises `Page::PageStateError`, a state error and
+never an `ArgumentError` (`PAGE-14`) — and it has a one-slot look-ahead: `#more?` runs an exchange the
+first time and stages the page in storage the walk owns, a second probe reads the staged page for free,
+and `#close` releases both the held page and the staged one. **Wrap the view in `#each_page`**, or call
+`#close` yourself: a page pulled through external iteration and dropped stays open until then, which is
+the residue design §7.1 states rather than hides. A close failure while releasing a held page is
+surfaced, never swallowed, and when both held pages fail to close the first failure propagates with the
+second on its suppressed trail (`PAGE-15`, `Dexpace.suppressed`).
+
+```ruby
+transport3 = cursor_server
+pages = paginator.with(transport: transport3).pages
+pages.more? # => true
+transport3.calls.size # => 1
+pages.more? # => true
+transport3.calls.size # => 1
+seen = []
+pages.each { |pg| seen << [pg.items, pg.closed?] }
+seen # => [[[1, 2], false], [[3], false], [[4], false]]
+pages.closed? # => true
+pages.each { |_pg| nil }
+# => Dexpace::Page::PageStateError: a page view is single-use: its iterator was already obtained (PAGE-14)
+opened = []
+paginator.with(transport: cursor_server).each_page { |pg| opened << pg; break if pg.items == [1, 2] }
+opened.map(&:closed?) # => [true]
+```
+
+**`#each_item { }` and `#each_page { }`** are the scoped openers: the same views, driven with a block
+that is required, the walk closed on every exit — a return, a `break`, a raise.
+
+A parse failure closes the response inline (the page is never built, so nothing else would) and
+propagates as itself; a close failure on that path is attached to the parse error's suppressed trail,
+never raised over it (`PAGE-13`). The same order holds for a consumer that raises inside a page loop:
+the consumer's error stays primary and the walk's close failure joins its trail. That order is the
+opposite of what a bare `ensure` gives in Ruby — a close that raises from an `ensure` *replaces* the
+in-flight error — and the frame that owns the walk records the primary explicitly rather than reading
+`$!`, which inside a caller's own `rescue` is the caller's unrelated error.
+
+```ruby
+raising = P::CursorStrategy.build(extract: ->(_r) { raise KeyError, "no items key" })
+P::Paginator.build(transport: cursor_server, template: template, strategy: raising).items.to_a
+# => KeyError: no items key (the response closed inline)
+```
+
+## The non-blocking engine: `AsyncPaginator`
+
+`AsyncPaginator.build(transport:, template:, strategy:, cap:, options:, executor: nil)` takes an async
+transport — `#call(request, options, cancellation) -> Dexpace::Async::Future` — and offers two walk
+methods over one pump: **`#walk(consumer, cancellation: nil)`** delivers every item, one at a time and
+in server order, to `consumer.call(item)`, never concurrently (`PAGE-29`); **`#walk_pages(consumer,
+cancellation: nil)`** delivers each live `Page` once, before it is closed. Both return a
+`Dexpace::Async::Future` that settles with the number of pages delivered, or fails with the **original**
+cause of a consumer throw, a transport failure, a parse failure or an eager transport raise, unwrapped
+(`PAGE-28`). Invoking a walk method is itself the consumption trigger and begins fetching immediately;
+there is no lazy obtain step on this engine (`PAGE-6`'s own carve-out).
+
+The engine calls no wait — not `Clock#sleep`, not `Async.delay` — and needs no scheduler, no executor
+and no thread: by default whoever settles the transport's future drives the next page, on that thread
+or fiber (`PAGE-29`'s stated default). The driver is a re-arm-flag trampoline, never a callback that
+calls the next page: `Future#on_settle` runs its block inline on an already-settled future, so a
+recursive pump overflows at a few thousand synchronously-answered pages, and this one is flat at any
+count (`PAGE-31`). With an `executor:` — any object answering `#post { }`; phase 8's
+`dexpace-async-thread` is the first real one — the first dispatch and every continuation are posted to
+it, so every consumer invocation runs on the executor and a blocking consumer never ties up a transport
+callback thread; a rejecting `#post` fails the walk with the rejection and closes the response it was
+carrying (`PAGE-30`).
+
+```ruby
+async_transport = ScriptedAsyncTransport.new(Array.new(3) { ->(r, _o, _c) { response(r) } })
+async = P::AsyncPaginator.build(transport: async_transport, template: template, strategy: strategy)
+seen = []
+future = async.walk(->(item) { seen << item })
+future.settled? # => true
+future.value # => 3
+seen # => [1, 2, 3, 4]
+async_transport = ScriptedAsyncTransport.new(Array.new(3) { ->(r, _o, _c) { response(r) } })
+pages_seen = []
+async.with(transport: async_transport).walk_pages(->(pg) { pages_seen << [pg.items, pg.closed?] }).value
+pages_seen # => [[[1, 2], false], [[3], false], [[4], false]]
+executor = InlineExecutor.new
+on_executor = P::AsyncPaginator.build(transport: ScriptedAsyncTransport.new(Array.new(3) { ->(r, _o, _c) { response(r) } }),
+ template: template, strategy: strategy, executor: executor)
+on_executor.walk(->(_i) { nil }).value # => 3
+executor.posts # => 4
+```
+
+Cancelling the returned future halts the walk at the next page boundary and cancels the in-flight
+transport future (`PAGE-25`): items already being delivered from the settling page still reach the
+consumer, and a page that was fetched and parsed but not yet drained is closed quietly and dropped
+(`PAGE-26`). A caller's `cancellation:` token is bridged to the same abort and released when the walk
+settles. Each page's response is closed exactly once on whichever path consumes it — after the drain,
+when a staged page is dropped, when a re-dispatch is rejected, or inline on a parse failure (`PAGE-27`);
+on the drain path a throwing close is reported through the future on the success path and swallowed
+when the consumer already failed, so that cause stays primary (`PAGE-32`).
+
+```ruby
+deferred = ScriptedAsyncTransport.new(Array.new(3) { ->(r, _o, _c) { response(r) } }, settle_later: true)
+future = async.with(transport: deferred).walk(->(_item) { nil })
+deferred.calls.size # => 1
+future.cancel(:shutting_down)
+future.cancelled? # => true
+deferred.pending.first.first.future.cancelled? # => true
+one_page = ScriptedAsyncTransport.new([->(r, _o, _c) { response(r) }])
+async.with(transport: one_page).walk(->(_i) { raise ArgumentError, "consumer" }).value
+# => ArgumentError: consumer (the same object, and the page closed)
+```
+
+**The inherent cancellation race, documented as `PAGE-33` requires.** If a cancel settles the
+transport's future *before* the transport delivers its response, that response never reaches this
+engine's close path: releasing it is the transport's responsibility, and for a future built on phase 2's
+`Completer` the completer does it (`Completer#fulfil` on a settled completer closes what it was handed,
+`SEAM-30`). Conversely, a page request already dispatched may still complete after the abort, and when
+it completes successfully this engine closes and discards the response at the page boundary rather than
+delivering it. The first half is not this engine's code; the second is `#walk`'s settled check.
+
+## The fetcher front-end: `Fetchers`
+
+`Fetchers.build(first:, next_page:, options: RequestOptions::EMPTY)` replaces the transport and the
+strategy with two caller callables and offers the same `#items`, `#pages`, `#each_item` and
+`#each_page` over the same walk (`PAGE-34`). The first-page fetcher is called exactly once per walk;
+every later page keys the next-page fetcher off the previous page's `next_link`, falling back to its
+`continuation_token` when the link is absent or blank — next link wins; a blank link with no token, or
+a `nil` page from either fetcher, ends the stream, and a `nil` first page is an empty stream. Each
+fetcher builds a `Page` that owns its response and must not close it — the walk releases it — and a
+fetcher that raises before building the page remains responsible for that response. A fetcher that takes
+a second parameter is handed the same frozen `options` instance every time, by identity; that is all
+`PAGE-35`'s mutable-options clause can mean here, since the port offers no mutable options object (design
+§12 calls the clause vacuous rather than declined), and a fetcher that wants per-page state has the
+previous page's `continuation_token` and its own closure.
+
+```ruby
+front = P::Fetchers.build(
+ first: -> { P.build(response: response(template), items: [1, 2], next_link: "/v1/items?after=2") },
+ next_page: lambda { |key|
+ key == "/v1/items?after=2" ? P.build(response: response(template), items: [3], continuation_token: "t3") : nil
+ },
+)
+front.items.to_a # => [1, 2, 3]
+```
+
+## What is deliberately not here
+
+- **No codec, no serializer, no default JSON-flavoured strategy.** The built-ins take an extractor; the
+ codec is `dexpace-serde-json`'s (phase 7a) and it belongs in the caller's closure. Nothing under
+ `lib/dexpace/page/` names a serializer, and the suite scans for the tokens; phase 7b's
+ `gates:serde_boundary` carries the row for this directory.
+- **No per-walk cancellation on the blocking engine.** `Paginator` and `Fetchers` take no
+ `cancellation:`; the transport receives `Cancellation.none`. A keyword is a widening a later phase can
+ add without moving any signature.
+- **No wrapper type for `PAGE-15`'s "re-thrown wrapped" clause.** Its antecedent — a stream terminal
+ that cannot declare the underlying error type — does not exist in Ruby: a close error from an `ensure`
+ reaches the caller unwrapped through `first`, `lazy.first(2)`, an explicit `break` and a plain block
+ on every supported interpreter. The other two clauses of the ID are implemented; the vacuity is the
+ phase's `P7-1` and `docs/first-release.md`'s `C13`.
+- **No wait and no scheduler in the async engine.** No `PAGE` requirement computes or requests a delay,
+ so phase 5a's `Async.delay` and its no-scheduler `SeamError` are unreachable from here.
+- **No fourth built-in strategy, no logger and no instrumentation event.** Chapter 12 names three
+ strategies and no `OBS` vocabulary; a non-dispatchable `rel=next` target is dropped silently as
+ end-of-stream, with nothing to emit it through.
+- **No redirect following, retry or authentication of its own.** Those are the transport's — hand the
+ paginator a `Pipeline.standard(...)` and every page request goes through all three pillars.
diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md
index 3a47cb7..482c1e3 100644
--- a/docs/sdk-documentation/quality-gates.md
+++ b/docs/sdk-documentation/quality-gates.md
@@ -19,7 +19,7 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T
| 6 | `test:gates` | `NFR-17`: the gate suites under `test/gates/`, which drive every other gate against its failing fixtures | any fixture a gate stops rejecting |
| 7 | `gates:gemspec_audit` | `SEAM-1`, `NFR-1`, `NFR-2`: core declares nothing; an adapter declares `dexpace-core` at `~> MAJOR.MINOR` plus at most one gem; every floor is `>= 3.2` | a core dependency, a second third-party gem, a stale core constraint, a wrong floor, a gemspec that runs a subprocess (`git ls-files`) or `require_relative`s a helper that does, a gemspec that does not load (named, not a `NoMethodError` on nil) |
| 8 | `gates:require_allowlist` | `SEAM-1`, `SEAM-2`, `NFR-1`: every `require` and `autoload` in every gem — parsed, so `require("x")`, `Kernel.require "x"` and a require after a `;` are all seen — is allowlisted, `dexpace` or under `dexpace/`, or the gem's one declared dependency; every `require_relative` stays inside the gem's `lib/`; a denylist names `json`, the transport names and `timeout` with the requirement that forbids each — `socket` scoped to core and the transport adapters, so the conformance gem's wire server may have it, the rest denied to every gem; a feature that is not a string literal is refused as unreadable | `base64`, `logger`, `tsort`, `json`, `timeout`, `net/http` in any gem, `socket` in a bound gem, an escaping `require_relative`, `require("json")`, `Kernel.require`, `autoload :JSON, "json"`, `require name` |
-| 9 | `gates:serde_boundary` | `SSE-37`, and the phase-7 segmentation design's spec-forced boundary 5: core's SSE layer -- `lib/dexpace/sse.rb`, `lib/dexpace/sse/**` and their `sig/` mirrors -- holds no serialization dependency, checked by a PARSED scan (prism for every `require`/`require_relative`/`autoload` spelling and every constant read or path, the RBS lexer for every type name), so the YARD may say the words the requirement asks for; every guarded glob must match at least one file, and the pagination layer's globs sit on a printed `PENDING` list until 7c's files land on the same base, when the rows move to `GUARDED` (added by phase 7b) | `require "json"`, `require_relative "../serde/json"`, `Dexpace::Serde`, a bare `Serde` or `JSON`, `::JSON`, `self::JSON`, a serde type in a guarded `.rbs`, a guarded file that does not parse, a feature that is not a string literal, a `GUARDED` glob matching nothing |
+| 9 | `gates:serde_boundary` | `SSE-37`, and the phase-7 segmentation design's spec-forced boundary 5: core's SSE layer and its pagination layer -- `lib/dexpace/sse.rb`, `lib/dexpace/sse/**`, `lib/dexpace/page.rb`, `lib/dexpace/page/**` and their `sig/` mirrors -- hold no serialization dependency, checked by a PARSED scan (prism for every `require`/`require_relative`/`autoload` spelling and every constant read or path, the RBS lexer for every type name), so the YARD may say the words the requirement asks for; every guarded glob must match at least one file, and a path whose files are not on the base yet sits on a printed `PENDING` list until they land, when its rows move to `GUARDED` -- the pagination layer's did on 2026-09-20, when 7c was reconciled onto the tree that holds the gate, and the list has been empty since (added by phase 7b) | `require "json"`, `require_relative "../serde/json"`, `Dexpace::Serde`, a bare `Serde` or `JSON`, `::JSON`, `self::JSON`, a serde type in a guarded `.rbs`, a guarded file that does not parse, a feature that is not a string literal, a `GUARDED` glob matching nothing |
| 10 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 |
| 11 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve |
| 12 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) |
diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
index 1e3e306..a372a99 100644
--- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
+++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
@@ -4024,3 +4024,121 @@ layer's `#values` had `Stream#drive`'s rescue and not its `ensure`, so `values.f
resource where `events.first(1)` released it; and `stream_state_error_test.rb` was written. Guards 51
and 52 are the round's, red on both rows against the pre-fix `lib/`; no public name, signature or
manifest row changed.
+
+**2026-09-20** — **Phase 7c implemented**, as three stacked branches against issue #28: code, tests,
+documentation, cut from `main` at `c53638b`, which holds the whole of phase 6, concurrently with 7a and
+7b on the same base — so nothing of theirs is on this tree, and phase 7's one convergence point, spec-forced
+boundary 5's audit, went to 7b's Task 11 by the manager's decision of the same day: 7c builds no
+`gates:serde_isolation`, no `tools/` file and no fixture, and `page_test.rb` scans the fifteen files under
+`lib/dexpace/page/` and `page.rb` for the tokens `Serde` and `JSON` (comments included, because 7b's scan
+reads them) until 7b's `PENDING` row for `page/**` flips to `GUARDED` on the second lane to rebase.
+`dexpace-core` carries the pagination layer beside the thirteen layers before it — fifteen new `lib/`
+files: `page.rb` (the page value, the namespace, `.next_request_from`) and, under `page/`,
+`page_state_error.rb`, `info.rb`, `query_rewriter.rb`, `link_header.rb` (private), `cursor_strategy.rb`,
+`page_number_strategy.rb`, `link_strategy.rb`, `walk.rb` (private), `closing.rb` (private), `items.rb`,
+`pages.rb`, `paginator.rb`, `async_paginator.rb` and `fetchers.rb` — with their `sig/` mirrors (the three
+interfaces `_Strategy`, `_Extractor` and `_Executor` nested in `sig/dexpace/page.rbs`, because a `sig/`-only
+file fails the gem-layout gate), every public one with a `test/` mirror, two suites with no `lib/` mirror
+(`page/matrix_facts_test.rb`, `page/lifetime_test.rb`); one earlier-phase file widened in place, a designed
+widening: phase 1's `http/url.rb` gains `URL.resolve` beside `.parse!` (P7-3); the entry file's
+fifteen-line `# Phase 7c:` block after 6b's; the surface manifest regenerated once from 1 137 to 1 214 rows
+with all 77 read against the object model; and two top-level test-support doubles (`PageFixtures`,
+`ProbeExecutor`) beside 6a's `ScriptedTransport` / `ScriptedAsyncTransport`, 3b's `FakeResponseBody`, 4b's
+`RecordingBody` and phase 2's `InlineExecutor`, reused as they are — **no Response double**: every response
+in the suite is a real `Dexpace::Response`. Two existing tests changed: the smoke suite's layer table (a
+`PAGE_LAYER`) with a `PhaseSevenLayers` class (the eleven public constants under `Page`, the six private
+names unreachable, the fourteen nested names shadowing nothing — the design's P7-2 audit as a standing
+test) on the code branch, a pin the gates read; and phase 1's `http/url_test.rb` (a nested `ResolveTest`
+over the widened `URL.resolve`) on the tests branch, a new test rather than an invalidated pin. **The
+design's R7, R8, R9 and R10 stand as decided, with R8's prescription corrected in execution**: the measurement is right — a bare `ensure` inverts `PAGE-13`/`PAGE-32`'s primary —
+but its `primary = $!` is wrong the other way, because `$!` is the CALLER's inside anything called from the
+caller's `rescue` (`pipeline/7ce4431d`, re-measured on every row), so the frame that owns the walk records
+the primary in a `rescue ::Exception => error` arm and hands the local to `Page::Closing.close_walk`, and
+`$!` appears nowhere under `page/` (P7-105). **Every one of the 36 `PAGE` IDs is ✅** — 35 outright and
+`PAGE-35` vacuous by construction on design §12's authority — and no ⏳ row is added. The other execution
+findings, each a ledger row or a guard: the items are owned shallowly, never through `Model.own`, which
+deep-copies and raises on a Proc (P7-101); the blocking engine passes `Cancellation.none` to the
+transport and takes no per-walk token — a nil dies inside `Pipeline.standard`'s retry step (P7-102);
+`PAGE-14` raises `Page::PageStateError`, a state error and never `InvalidArgumentError`, so **the
+`PAGE-14`/`SSE-26` inbound bullet of 2026-09-13 above is half closed — the argument-family half — and its
+shared-supertype half stays on this list** for phase 10 (P7-103); `Page.next_request_from` screens a
+resolved target for an http/https scheme and a host, consistent with `REDIR-18` (P7-104); every private
+constant has a `sig/` mirror and 8b's plan Task 9 is told the interface's real file (P7-106); the
+extractors', a strategy's and a fetcher's answers are checked, never read as end-of-stream (P7-107); the
+walk future settles with the page count (P7-109); the fatal family closes the response on a parse failure
+too (P7-110); the `Walk` is generic over a per-walk drive so `Fetchers` reuses it (P7-111); the executor
+runs the FIRST dispatch too (P7-112); a block-less `Pages#each` claims the latch on obtaining the
+enumerator (P7-113); the page-number screen is ASCII `[0-9]+` (P7-114); and the link grammar's
+malformed-input rule (P7-115); and, from review round 0 (2026-09-20), `Page.build` names a `next_link`
+or `continuation_token` that is not a String the way `Info` already did (R0-1, P7-107) and `LinkHeader`
+reads only the first `rel` parameter of a link-value, RFC 8288 §3.3's rule (R0-3, P7-116); and, from
+review round 1 (2026-09-20), `Walk#release`'s slots-cleared-before-the-raise invariant is pinned — the
+round's one surviving mutation, now guard 48 (R1-1) — and a fragment-only `rel=next` target (`<#>`,
+`<#frag>`) is end-of-stream before resolution as the empty one already was, RFC 3986 §4.4's two
+same-document forms read together and read syntactically, never off the resolved URL (R1-2, P7-117,
+guard 49). The checklist
+is at `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md`: thirty-six own rows,
+all ✅, plus the boundary-5 row (owner 7b) and twelve cross-reference rows; forty-nine guards run red
+on 4.0.6 and 3.2.11, none surviving; thirty-two departures from the plan's text itemised; the design's
+As-built addendum adds P7-101–P7-117. Every one of the seventeen gates is green on the docs tip on 4.0.6
+(2,925 runs, 68,978 assertions, 0 skips, line coverage 99.98 % — the one uncovered line is phase 2's
+`registry.rb:253`, as on `main`; every line under `lib/dexpace/page/` and in `url.rb` is exercised,
+the shape refusals included), the matrix rows on 3.2.11, 3.3.12 and
+3.4.10, and RuboCop by the honest `--ignore-parent-exclusion` command; the code tip alone is green on
+every one of the seventeen gates run individually on 4.0.6 and on the 3.2.11 matrix row, above the
+coverage floor, so no tip in the stack is red. `docs/sdk-documentation/pagination.md` is the fifteenth
+as-built page, every example run on 4.0.6 and 3.2.11 and identical on both; `architecture.md` retires the
+planned `write-a-paging-strategy.md` into it; the two READMEs and `docs/README.md` point at it (the root
+`README.md`'s built-phases sentence had omitted 6b, and `docs/README.md`'s layer sentence too — both now
+name it beside 7c); `CLAUDE.md`'s built-phases paragraph gains the pagination layer, its counts move to
+one hundred and ninety-nine `lib/dexpace/` files, nineteen `private_constant`s without a `test/` mirror
+and fifteen checklists, its `SEAM-27` constraint line names `URL.resolve`, and its constraints-that-bite
+list gains four lines. `docs/first-release.md` is untouched — the `PAGE-36` conformance test is 8a's Tasks
+13 and 20 already, `C13` already carries `P7-1`, and the blocking engine's missing `cancellation:` keyword
+has no ID to file under; `docs/deviations.md` is untouched, for phase 10 to flip;
+`docs/knowledge/notes/pagination.md` gains four Reference entries (the SSE-under-`PAGE-14` pair, the
+`BODY-11` attribution, §12's unharvested serde-agnosticism, and R8's `$!` finding). The consolidation of
+P7-1–P7-6 and P7-101–P7-117 into design §10 — beside 7a's P7-1–P7-9, which collide with 7c's by number,
+knowingly — and the `PAGE-15` addition to §12's `PAGE` row are a human's, as for every phase before:
+`docs/sdk-design-ruby/` is frozen.
+
+**2026-09-20** — **Phase 7c reconciled onto `main` after phase 7b (server-sent events)**, by a
+rebase-and-reprove pass. Phase 7b's stack merged first (#81 `90abdb8` → #82 `eacf165` → #83 `34f52e8`),
+so 7c's three branches — built off `c53638b` and reviewed at `d319e8a` → `58d45af` → `cd12764` — were
+rebased onto `main` `34f52e8` with `git rebase --onto` (rerere disabled), every 7c commit preserved and
+none reordered, and the stack is `3a1f0a4` → `549e683` → this paragraph's own commit. Two commits are
+the pass's own, one per branch that needed one: on the code branch, `chore: guard page/ in
+gates:serde_boundary now that 7c is on the same tree` — the flip 7b's tool said would happen "in the
+change that lands the files": the two `page/**` rows move from `PENDING` to `GUARDED` and `page.rb` and
+`page.rbs` gain rows beside them (a `**` under a directory cannot match the file beside it, the reason
+`sse.rb` has its own row), each violation naming "spec-forced boundary 5"; `PENDING` is empty and stays
+as the mechanism; the gate's test asserts nothing pending, the four rows and "8 guarded globs clean",
+both fixture workspaces gain clean page files so `empty_glob` still reports exactly one empty glob, and
+the guard was proven to bite by hand — a `Dexpace::Serde` read appended to `page/info.rb` and a
+`require "json"` appended to `page.rb` each ran the gate red before being reverted. On the docs branch,
+this paragraph's commit carries what no 7c commit could: `CLAUDE.md`'s and `quality-gates.md`'s
+`PENDING` sentences now say guarded, 7b's checklist gains one dated line at its item 15, and 7c's
+checklist a dated paragraph, its boundary-5 row's note and one line at its item 30. Eight files both
+lanes had changed were reconciled inside the rebased 7c commits and nowhere else:
+`gems/dexpace-core/lib/dexpace.rb` (7b's `# Phase 7b:` block, then 7c's `# Phase 7c:` block, each
+verbatim — 7c's comment still says "after 6b's block", which stays true with 7b's between),
+`gems/dexpace-core/test/dexpace_test.rb` (both layer pins, `SSE_LAYER` then `PAGE_LAYER`, and the two
+same-named `PhaseSevenLayers` classes merged into one carrying 7b's SSE case then 7c's pagination case),
+`test/fixtures/surface/dexpace-core.txt` (the auto-merge was already the regenerated manifest, confirmed
+by a `surface:regenerate` that changed nothing: 1,180 rows on `main` plus 7c's 77, 1,257 — `URL#resolve`
+and the seventy-six `Page` rows, nothing of `Walk`, `Closing` or `LinkHeader`), `CLAUDE.md` (re-derived
+from the combined tree: "… 6c, 7b and 7c are built", two hundred and eight `lib/` files beside
+`version.rb` with two hundred and eight `sig/` mirrors, nineteen `private_constant` test-mirror
+exceptions — `main`'s eighteen and `page/closing.rb` — sixteen checklists, both layers in the opening
+paragraph, 7b's four and 7c's four "Constraints that will bite" lines), `README.md` (both layer
+sentences; its built-phases sentence, which `main` had left at "… 6a and 6c", names every built phase
+in merge order), `docs/README.md` (both layers, both pages, sixteen pages),
+`docs/sdk-documentation/architecture.md` and `gems/dexpace-core/README.md` (both pages, `sse.md` and
+`pagination.md`), and this roadmap (both status notes in merge order; the phase-10 inbound list is
+`main`'s forty-seven bullets — 7b's two, cited by date and content — and 7c adds none). Every file only
+one lane touched is byte-identical to that lane's reviewed tip, the flip's tool and test excepted. The
+7c checklist's and status note's count sentences describe its own base, `c53638b`, and now say so; the
+combined tree's counts are `CLAUDE.md`'s: eighteen gates, the test suite at 3,146 runs, 70,084
+assertions, 0 failures, 0 errors, 0 skips and 99.98 % line coverage on 4.0.6 at the tests tip. Re-proven
+at every rebased tip on 4.0.6 and the matrix rows before the push, `gates:serde_boundary` with the
+pagination rows guarded at every one of the three.
diff --git a/docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md b/docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md
index 71d82b9..74ffcb0 100644
--- a/docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md
+++ b/docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md
@@ -362,6 +362,7 @@ phase cites, or a statement the design makes are also the as-built ledger rows P
15. **7b built the gate alone, with `page/**` PENDING** (the manager's decision (2)): both the `lib/`
and the `sig/` pagination globs are on the printed list, and their move to `GUARDED` is the
reconcile pass's after both lanes are on `main`. 7c builds nothing for it.
+ *Reconciled 2026-09-20:* the move happened — 7c's stack was rebased onto `main` `34f52e8` (this phase's docs tip) and its code branch carries the one `chore:` commit that guards `page.rb`, `page/**` and both `sig/` mirrors under "spec-forced boundary 5", leaving `PENDING` empty.
16. **`lib/dexpace.rb`'s `# Phase 7b:` block is appended after 6b's**, nine lines, nothing reordered;
`sse.rb` requires `sse/sentinel` before assigning `SKIP`/`DONE`; the smoke suite's `LAYERS` gains
`SSE_LAYER = %i[SSE]` and a `PhaseSevenLayers` class; the namespace file's mirror is
diff --git a/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md b/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md
new file mode 100644
index 0000000..8c129a5
--- /dev/null
+++ b/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md
@@ -0,0 +1,461 @@
+# Phase 7c — Pagination: Checklist
+
+**Written at execution time, 2026-09-20, from what was built** — not from the plan. A row whose task
+did not do what the plan said is a row that says so, and the "Deviations from the plan" section
+below is where each departure is stated with its reason. The design and the plan were written on
+2026-09-10 against phases 4–6's *designs*, on a machine that then had only Ruby 3.4.10, concurrently
+with 7a's and 7b's documents. Since then phases 4a–6c were built, reviewed and merged (PRs #53–#80),
+every interpreter in the matrix was installed, and a cross-check agent read this plan in full against
+`main` at `c53638b` on 2026-09-20. **This phase was cut from `main` at `c53638b`**, which holds the whole
+of phase 6, concurrently with 7a and 7b on the same base: nothing of theirs exists on this tree, and the
+one convergence point the charter names — spec-forced boundary 5's audit — went to 7b by the manager's
+decision of 2026-09-20, so this lane builds no gate and carries a non-ID row for it below. Where the
+plan's text and the built tree disagree the tree wins and this document records it.
+
+**Reconciled 2026-09-20.** Phase 7b's stack merged first (#81 → #82 → #83, `main` at `34f52e8`), so
+this phase's three branches were rebased onto it by `git rebase --onto` with rerere disabled, every
+7c commit preserved. The sentences below that count the tree describe **this phase's own base**,
+`c53638b`, and are left as written; on the combined tree the figures are: 208 `lib/dexpace/` files
+beside `version.rb` (7b's nine and 7c's fifteen over the 184 of phase 6), the same nineteen
+`private_constant` test-mirror exceptions, sixteen checklists, sixteen as-built pages, the core
+manifest 1 180 → 1 257 (still exactly this phase's 77 rows), and **eighteen** gates — 7b's
+`gates:serde_boundary` being the one this phase's base did not have. The boundary-5 row below records
+the one thing the reconcile pass built. The combined tree's counts are `CLAUDE.md`'s and the roadmap's
+reconciliation note's; this document's are its base's.
+
+Legend, verbatim from the roadmap's cross-cutting constraint 3: ✅ implemented and tested ·
+🚫 not built (permanent simplification, named reason) · ⏳ deferred (naming the plan task — phase,
+task number and path — that will do it, or the `docs/first-release.md` entry that owns it) ·
+N/A not applicable in this port.
+
+Plan: `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination.md`. Task numbers are that plan's
+(seventeen). Design: `docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md`, whose
+Deviation Ledger numbered P7-1–P7-6 before execution (colliding with 7a's P7-1–P7-9, numbered in
+isolation; phase 10's consolidation resolves it) and whose as-built rows **P7-101–P7-117** are cited
+below (7a's rows are cited as "7a's P7-n", 7b's as "7b's P7-n"; the manager fixed the numbering so the
+three lanes never collide); the charter is `docs/work/mvp/phase7/2026-09-10-phase7-segmentation-design.md`.
+Review round 0 (2026-09-20) asked for two changes and this document records them where they land:
+`Page.build` now names a non-String key (R0-1, guard 46) and `LinkHeader` reads only the first `rel`
+parameter of a link-value (R0-3, P7-116, guard 47); the sentence below about which branch carried
+`http/url_test.rb` was corrected (R0-2). Review round 1 (2026-09-20) asked for one test and raised one
+nit, both landed: `Walk#release`'s slots-cleared-before-the-raise invariant is pinned (R1-1, guard 48),
+and a fragment-only `rel=next` target — `<#>`, `<#frag>` — is end-of-stream before resolution, as the
+empty one already was, RFC 3986 §4.4's two same-document forms read together (R1-2, P7-117, guard 49).
+Every test file named here is under `gems/dexpace-core/test/`, mirrors its `lib/` file one for one
+(two carry no `lib/` mirror and say so below; one `private_constant`, `page/closing.rb`, carries no
+`test/` mirror and is asserted through the two views and the lifetime suite), and opens with the IDs
+it exercises.
+
+## Requirement rows
+
+Thirty-six own rows — `PAGE-1`–`PAGE-36` — plus the non-ID row for spec-forced boundary 5 and the
+cross-reference rows for the non-`PAGE` IDs this phase owns a share of. **Thirty-five ✅, one ✅
+vacuous by construction** (`PAGE-35`, design §12's own disposition), nothing ⏳, nothing 🚫, nothing
+N/A; the seven rows the design says must be stated rather than ticked (`PAGE-1`, `PAGE-15`, `PAGE-16`,
+`PAGE-21`–`PAGE-24`, `PAGE-28`, `PAGE-33`, `PAGE-35`) state their clause.
+
+| ID | Level | Status | Task(s) | What was built, and where it is proven |
+|---|---|---|---|---|
+| `PAGE-1` | MUST | ✅ | 11, 12, 14 | **Four sites, not two**: the blocking engine's `Items` (server order across page boundaries, `[[1, 2], [], [3, 4], [5]]` → `[1, 2, 3, 4, 5]`) and `Pages` (three page objects for three pages, each with status, headers and the executed request), and the async engine's `#walk` (items, serially) and `#walk_pages` (whole live pages, each open for the consumer's call and closed exactly once after it) over one pump with a two-branch drain — `PAGE-27` names the page-level drain in as many words, so a row citing the blocking views alone would tick a MUST the async engine did not meet (`items_test.rb` "PAGE-1"; `pages_test.rb` "PAGE-1"; `async_paginator_test.rb` "PAGE-1 (async)", "PAGE-27 (async, page-level)") |
+| `PAGE-2` | MUST | ✅ | 5 | `Page#items` is a frozen SHALLOW copy (P7-101: the collection is owned, the elements stay the caller's, never `Model.own`'s deep copy, which raises on a Proc), never nil, possibly empty; `#status`, `#headers`, `#request` are read off the frozen `Response` `Data` and survive `#close`, which invalidates the body alone (`page_test.rb`, three `PAGE-2` cases) |
+| `PAGE-3` | MUST | ✅ | 5, 15 | `Page` is a plain class including `Dexpace::Closeable`, not a `Data` (a `Data` cannot hold the latch; `Response#close` is a pure forward with none of its own): three `#close`es release the response once, a raising release leaves the latch flipped and is never retried; `.build` refuses anything but a real `Dexpace::Response`, and a `next_link` or `continuation_token` that is neither nil nor a String, named (P7-107; guard 46); a fetcher's page owns its response and the walk, not the fetcher, closes it (`page_test.rb`, two `PAGE-3` cases and the duck-refusal; `fetchers_test.rb` "PAGE-34 / PAGE-3"; guard 39) |
+| `PAGE-4` | MUST | ✅ | 4, 7, 8, 9 | `Info` is a frozen `Data` whose `next_request == nil` is the single end-of-stream signal — no `terminal?`, no sentinel, no exception path; `.terminal(items:)` names the case; empty items with a non-nil next request is a valid non-terminal page. The three built-ins do NOT rescue the extractor: a raise is a parse failure and `PAGE-13`'s path, never an end (`info_test.rb`, four `PAGE-4` cases; the "PAGE-4: an extractor that raises" case in each of the three strategy suites; guard 44) |
+| `PAGE-5` | MUST | ✅ | 7, 8, 9, 10 | The three strategies are frozen `Data`s with no instance variables at all (a `Data` holds none, measured) so "immutable and safe to share" is structural; one `CursorStrategy` serves two walks with different templates; `#parse` closes nothing (`closes` 0 on the real response) and the walk hands the strategy an open, unmutated response (`closes` 0 seen from inside `#parse`) (`cursor_strategy_test.rb`, `page_number_strategy_test.rb`, `link_strategy_test.rb`, each "PAGE-5"; `walk_test.rb` "PAGE-5") |
+| `PAGE-6` | MUST | ✅ | 13, 14 | `R10`'s two assertions, separately. **Blocking**: `Paginator.build` → 0 exchanges; `#items` → 0; `#items.to_enum(:each)` → 0; `#pages` → 0; `#pages.each` (block-less) → 0; then `first(1)` is 1 exchange and `first(2)` two more. A repeated `Pages#more?` reads the staged page and costs nothing. **Non-blocking**: `AsyncPaginator.build` → 0; `#walk` → 1 exchange immediately (the requirement's own carve-out), then one per page consumed, the future settling with the page count (`paginator_test.rb`, two `PAGE-6` cases; `pages_test.rb` "PAGE-6 / PAGE-12", "PAGE-6"; `async_paginator_test.rb`, two `PAGE-6` cases; guards 4, 18, 19) |
+| `PAGE-7` | MUST | ✅ | 10 | `Walk#fetch_next_page` latches `@exhausted` when the drive answers nil and answers nil with no exchange from then on; the strategy drive itself answers nil without an exchange once the last `Info` said end-of-stream; a closed walk fetches nothing (`walk_test.rb` "PAGE-7", "a closed walk fetches nothing"; guard 14) |
+| `PAGE-8` | MUST | ✅ | 11, 12, 13 | `Paginator` is a frozen `Data` with no ivars and only immutable configuration; every per-walk quantity is on a private `Walk` that `Items#each` opens afresh on EVERY call (`to_a` twice = two full fetch sequences, the same URLs in the same order; two interleaved enumerators of one view do not share state; two threads over one engine each drive their own sequence) and that `Pages` opens once per view (two `#pages` calls = two walks = 4 calls) (`items_test.rb`, two `PAGE-8` cases; `pages_test.rb` "PAGE-8"; `paginator_test.rb`, two `PAGE-8` cases; `fetchers_test.rb` "PAGE-8"; guards 12, 13) |
+| `PAGE-9` | MUST | ✅ | 10, 13, 14 | `cap:` counts exchanges, checked in `Walk#fetch_next_page` BEFORE the exchange (`>=`), so a server echoing one cursor forever under `cap: 3` costs exactly 3 exchanges and 3 items, on both engines; validated strictly positive in the models' `initialize` so `.build`, `#with` and a forged `send(:new, …)` all meet it — `0`, `-1`, `0.0`, `-0.5`, `NaN`, `nil`, `"3"` and `:three` refused at construction, naming `PAGE-9`; a Float cap is admitted (`walk_test.rb` "PAGE-9"; `paginator_test.rb`, three `PAGE-9` cases; `async_paginator_test.rb` "PAGE-36 / PAGE-9"; guards 15, 16) |
+| `PAGE-10` | SHOULD | ✅ | 13 | The default cap is `Float::INFINITY` — strictly positive, comparable against an Integer count, a value not a sentinel — and 600 single-item pages walk under it; the documentation clause is discharged in `Paginator`'s YARD ("**set a finite cap in production**") and in `docs/sdk-documentation/pagination.md`, not as a configuration key (`paginator_test.rb` "PAGE-10") |
+| `PAGE-11` | MUST | ✅ | 11 | `Items#each` closes each page BEFORE its first item is yielded (`closes` reads `[1, 1]` across a two-item page); one item from a two-item first page closes it and fetches no second page; an enumerator abandoned after one `#next` strands nothing because the close already happened; `Items#close` is a documented no-op and never raises (`items_test.rb`, two `PAGE-11` cases, "Items#close", "external iteration"; guards 1, 2) |
+| `PAGE-12` | MUST | ✅ | 10, 12 | **The look-ahead lives on the `Walk`** (`@buffered`), never in the enumerator's closure. The previous page is closed as the consumer advances (`[[0,0,0],[1,0,0],[1,1,0]]`), the last at exhaustion; `Pages#more?` runs an exchange the first time and stages the page, reads it for free the second time (1 call, nothing displaced), and a probe on an exhausted-and-closed view fetches nothing; `#close` releases BOTH slots; a `break` inside `#each_page` releases the held page; `Walk#hold` writes the new page into its slot BEFORE closing the previous one so a raising close strands nothing; `Walk#buffer` refuses to displace a staged page (P7-103's error). Consumers are told to wrap the view in `#each_page` or call `#close` in `Pages`' YARD (`pages_test.rb`, seven `PAGE-12` cases; `walk_test.rb` `SlotsTest`; guards 3, 4, 5) |
+| `PAGE-13` | MUST | ✅ | 10, 11, 12, 14 | `Closing.parse_or_close` closes the response INLINE on a `#parse` raise (the page is never built) and re-raises the parse error as itself (`raise error, cause: nil`); a close failure is attached to its suppressed trail, never raised over it (`[IOError]` on a `KeyError`); the fatal family closes quietly and propagates with no trail (P7-110); the same on the async path, and a parse failure on page 2 releases the held page 1 with the parse error primary (`walk_test.rb`, three `PAGE-13` cases; `items_test.rb`, `pages_test.rb`, `lifetime_test.rb`, `async_paginator_test.rb` "PAGE-28 / PAGE-13"; guards 9, 10) |
+| `PAGE-14` | MUST | ✅ | 12 | `Pages` latches `@viewed`: a second `#each`, with or without a block, and a `#to_a` after one raise `Dexpace::Page::PageStateError` — a `StandardError` in the `Dexpace::Error` family, NOT `InvalidArgumentError`, per the roadmap's phase-10 inbound bullet of 2026-09-13 (P7-103; the bullet's shared-supertype half stays phase 10's). A block-less `#each` claims the latch on obtaining the enumerator (P7-113) (`pages_test.rb` `SingleUseTest`, two `PAGE-14` cases; `page_state_error_test.rb`; guards 11, 41) |
+| `PAGE-15` | MUST | ✅ (P7-1) | 10, 12, 16 | **Two of three clauses**, stated: (a) a close error while releasing a held page is SURFACED — on `Pages#close` after a probe, on the advance in `Walk#hold`, on the eager close in `Items`, and through a short-circuiting terminal (`break`, `first`) — never swallowed; (c) when both held pages fail to close the first failure propagates with the second on its suppressed trail, on the one reachable two-slot state (a probe from INSIDE the loop, then `break`). (b), "re-thrown wrapped", is **vacuous by a false antecedent** — no Ruby terminal can fail to declare an error type; measured four ways on every row, `matrix_facts_test.rb` fact 1 — and `lifetime_test.rb` "P7-1" asserts the concrete class with a nil `#cause` so a wrapper introduced later fails loudly; the vacuity is `docs/first-release.md`'s `C13`, already filed. The advance close is a bare close, never `close_quietly`. `Walk#release` clears BOTH slots before anything can raise, so after a raising close the walk holds nothing, a closed `Pages` answers `more?` false and a drive over it yields nothing — the invariant review round 1 found unpinned (R1-1) is asserted in `walk_test.rb` `SlotsTest` and `pages_test.rb` `SurfacedCloseTest`, and guard 48 (the reviewer's surviving mutation 73) is red on both rows (`pages_test.rb` `SurfacedCloseTest`; `walk_test.rb` `SlotsTest`; `lifetime_test.rb`, five `PAGE-15` cases; guards 6, 7, 8, 48) |
+| `PAGE-16` | MUST | ✅ (P7-6) | 7 | `CursorStrategy.build(extract:, parameter: "cursor")`: ONE extractor call, `[items, cursor]` back, `nil` OR `""` end-of-stream, otherwise the template with the parameter spliced (replacing an earlier value in place); the cursor also travels as `Info#continuation_token`. **"A single read of the response body" is the EXTRACTOR's read and core performs none**: a `RecordingBody`'s `source_count` reads 1 through an extractor that reads and 0 through one that does not, and an extractor reading through `Response#body_string` gets the single-use rule from 3b's body (`ClosedError` on a second read). The extractor's answer shape is checked, not destructured blindly (P7-107) (`cursor_strategy_test.rb`, six `PAGE-16` cases; guard 20) |
+| `PAGE-17` | MUST | ✅ | 8 | `PageNumberStrategy.build(extract_items:, parameter: "page", start: 1)`: an empty items list is end-of-stream, checked FIRST; the current page is read from the EXECUTED request (`response.request`: `?page=5` on the response and `?page=1` on the template gives `page=6`); absent, empty, `abc`, `-2`, `1.5`, `1e3`, a non-ASCII numeral and a space all fall back to `start` (`page=8` under `start: 7`); a percent-encoded digit run is read decoded and counts (P7-114); `parameter:` and `start:` are configurable, `start: 0` admitted; the next request is the TEMPLATE with its page spliced, other parameters byte-identical, method and body carried (`page_number_strategy_test.rb`, six `PAGE-17` cases; guards 21, 22) |
+| `PAGE-18` | MUST | ✅ | 6, 9 | `LinkHeader`, a character-level state machine with no `Regexp` (a source scan asserts it): a comma inside `<…>` and inside a quoted value does not split link-values, a semicolon inside a quoted value does not split parameters, quoted-pair escapes are honoured, `rel` may be unquoted, multi-token (space or tab) and any case (`NEXT`, `"prev next"`, `"last\tnext"`), the token is `next` and not `nextish` or `prev-next`, the FIRST matching link-value wins, parameter names fold (`REL=next`), and only the FIRST `rel` parameter of a link-value is read — RFC 8288 §3.3's "occurrences after the first MUST be ignored", so `; rel="prev"; rel="next"` is not a next link (P7-116); no header, no `rel=next` segment, an empty set and malformed input are all end-of-stream, never a raise (P7-115). `LinkStrategy.build(extract_items:, header: "Link")` reads the header under the fold and the name is configurable (`link_header_test.rb`, twelve cases; `link_strategy_test.rb` "PAGE-18", two cases; guards 23, 24, 47) |
+| `PAGE-19` | MUST | ✅ (P7-3, P7-4, P7-104) | 2, 9 | `Dexpace::URL.resolve(base, reference)` wraps the pinned `URI::RFC3986_PARSER.join` (phase 1's file widened by one function, P7-3): base `/repo/issues?page=1` + `?page=2` → `/repo/issues?page=2`, the path preserved; `not a url`, `http://[bad` and whitespace answer nil. `Page.next_request_from(template, response, target)` — public, so a body-derived next URL reaches the same rules — answers nil for a nil target and for a same-document reference BEFORE resolution — the empty or whitespace-only target (`join(base, "")` is the base itself, P7-5) and, since review round 1, the fragment-only one (`<#>`, `<#top>`, `join(base, "#top")` is the base plus a fragment the wire never carries; RFC 3986 §4.4 names the two forms together, P7-117) — for an unresolvable one, and for one this client cannot dispatch: `mailto:`, `javascript:`, `ftp:`, `http:foo`, `http:///p`, every one a SUCCESSFUL join (P7-104, consistent with `REDIR-18`; no diagnostic, the engine has no logger); the base is the RESPONSE's request URL, not the template's. The same-document screen is syntactic — read off the raw target, never its resolution — so `>`, `/>` (which uri resolves to the base itself) and the current URL spelled out are followed and bounded by `PAGE-9`'s cap, deliberately, and the suites pin that too (`http/url_test.rb` `ResolveTest`; `page_test.rb` `NextRequestFromTest`; `link_strategy_test.rb`, five `PAGE-19`/`P7-5`/`P7-104`/`P7-117` cases; guards 25, 26, 43, 49) |
+| `PAGE-20` | SHOULD | ✅ | 6, 9 | Several `Link` instances are joined with `", "` and scanned as one (one `last` and one `next` in either order → next followed); `nil`, `[]` and `[""]` are no next link (`link_header_test.rb` "PAGE-20", "no rel=next segment and no header"; `link_strategy_test.rb` "PAGE-20") |
+| `PAGE-21` | MUST | ✅ | 3 | `QueryRewriter.set` tokenises on `&` and the FIRST `=`, copies every untargeted segment as the bytes it found — `flag` stays value-less, `filter=a:b` keeps its colon, `a=b=c`, an empty segment and `%zz` survive — and re-encodes only the targeted name and value; `Dexpace::Query` is deliberately not used (`Query.parse(q).encode` rewrites `filter=a:b` to `filter=a%3Ab`, guard 27). **The half already built**: the codec is phase 1's `PercentEncoding`, shipped and tested. Asserted as byte identity of the untargeted SEGMENTS, plus a 128-sample property test (`query_rewriter_test.rb`, two `PAGE-21` cases and the property; guard 27) |
+| `PAGE-22` | MUST | ✅ | 3 | `q='a b'` → `q=a%20b`; `token='a+b/c='` → `token=a%2Bb%2Fc%3D`; `get` of `q=a+b` → `a+b`, of `q=a%20b` → `a b`, of a flag → `""`, first match wins; the name is matched decoded (`pa%67e` is `page`). Ruby's `URI.decode_www_form` / `encode_www_form_component` are the exact inverse, measured on every row (`query_rewriter_test.rb`, three `PAGE-22` cases; `matrix_facts_test.rb` fact 5; guard 28) |
+| `PAGE-23` | MUST | ✅ | 3, 9 | `set`: `page=1&sort=asc` → `page=2&sort=asc`; nil → `sort=asc`; duplicates dropped (`a=1&p=1&b=2&p=2` → `a=1&p=3&b=2`); absent → appended. Following a whole next URL is `template.with(url:)` through `Request.build`, so the template's `POST`, its headers and the SAME body object travel (`query_rewriter_test.rb` "PAGE-23"; `link_strategy_test.rb` "PAGE-23"; `page_test.rb` "PAGE-23") |
+| `PAGE-24` | MUST | ✅ (P7-4) | 3 | `rewrite_url` `dup`s the URI and assigns the spliced query alone: `https://user:pw@api.example.com:8443/a/b?flag&page=1#frag` keeps scheme, userinfo, `8443`, `/a/b` and `frag` exactly; the caller's frozen URI is untouched; a URL with no query gains one before its fragment. `query=` is byte-transparent for every parser-produced query through the model's re-parse (fact 4, every printable ASCII byte, zero disagreements), and an empty splice is `nil` so the URL carries no dangling `?` (P7-4) (`query_rewriter_test.rb`, two `PAGE-24` cases and "P7-4"; `matrix_facts_test.rb` facts 4 and 7; guard 29) |
+| `PAGE-25` | MUST | ✅ | 14 | The pump drives fetch, parse, delivery and re-arm inside `Future#on_settle` with no thread blocking on a page (the thread count is flat across a walk); cancelling the result future halts the walk at the next boundary and `Completer#on_cancel { @in_flight&.cancel(reason) }` cancels the in-flight transport future (`cancelled?` true on it; the late response then meets an already-settled completer and no second exchange is dispatched); a caller's `cancellation:` token is bridged to the same abort and its subscription detached when the walk settles (the source's hook list reads 0) (`async_paginator_test.rb` `CancellationTest` "PAGE-25", two cases; `ConstructionTest`, two token cases; guard 35) |
+| `PAGE-26` | MUST | ✅ | 14 | A cancel from INSIDE the drain lets the current page finish (item 2 still delivered after a cancel at item 1) and stops at the boundary (no second exchange); a page parsed after the walk settled — the cancel lands inside `#parse`, deterministically — is dropped undrained AND closed through `Dexpace.close_quietly`, its close error swallowed, the future's outcome the cancellation and not the `IOError` (`async_paginator_test.rb` `CancellationTest`, two `PAGE-26` cases; guard 34) |
+| `PAGE-27` | MUST | ✅ | 5, 14 | The `Page`'s own `Closeable` latch is the exactly-once, on whichever path consumes it: normal completion, parse failure (inline), cancellation (the staged drop) and executor rejection each close their response exactly once, asserted in one test over four un-latched `FakeResponseBody` counters; the page-level drain closes after the consumer returns, `[[0, 0], [1, 0]]` seen from inside (`async_paginator_test.rb` `TrampolineTest` "PAGE-27", "PAGE-27 (async, page-level)"; guard 38, a second close past the latch) |
+| `PAGE-28` | MUST | ✅ | 14 | A consumer throw, a transport failure, a parse failure and an eagerly-throwing transport each fail the future with the ORIGINAL object (`assert_same` on the consumer's and the transport's error; phase 2's pivot wraps nothing); a transport answering a non-future fails the walk with `InvalidArgumentError` rather than a `NoMethodError`; a transport-side cancellation is forwarded as a cancellation carrying its reason (P7-109). **The null-success clause has a code site** — `Pump#null_success`, `SeamError` — although `Settlement`'s own validation makes it unreachable through any real future, so the suite drives the private pump with a duck settlement and reads the failed future; and a fatal-family consumer error fails the future AND propagates out of `#walk` unchanged, the page's response closed once (`RECOV-2`) (`async_paginator_test.rb` `FailureTest`, eight `PAGE-28` cases; guard 30) |
+| `PAGE-29` | MUST | ✅ | 14 | Items are delivered one at a time in server order with a re-entrancy depth of exactly 1; with no executor the consumer runs on the settling thread (the walk completes on the caller's thread with no new thread); with an `executor:` the WHOLE driver is posted — the first dispatch too (P7-112) — so every consumer invocation runs on the executor's thread (`ProbeExecutor#drain`'s fresh thread, never the test's) and nothing is fetched until it runs (`async_paginator_test.rb`, two `PAGE-29` cases; guard 45) |
+| `PAGE-30` | MUST | ✅ | 14 | `Pump#submit` wraps the one `#post` site: a rejection after two posts — the first dispatch and page 1's continuation — fails the walk with the rejection (`ProbeExecutor::Rejected`), closes the response it was carrying (page 2's `closes` 1) and delivers page 1's item only; a rejection of the FIRST post fails the walk before any exchange (`async_paginator_test.rb` `FailureTest`, two `PAGE-30` cases; guard 31) |
+| `PAGE-31` | SHOULD | ✅ | 14 | The pump is 6a's re-arm-flag trampoline over `claim(:start | :continue)` under a mutex held across the flip only: 3,000 synchronously-completed pages through BOTH the inline path and an `InlineExecutor` leave `caller.size` at page 3,000 equal to page 1's. Measured through the real `Completer`: a pump that re-enters itself from `on_settle` overflows at 2,619 (3.2.11) / 2,847 (3.3.12, 3.4.10, 4.0.6) pages; guard 32 is that shape and raises `SystemStackError` (`async_paginator_test.rb` `TrampolineTest` "PAGE-31"; `matrix_facts_test.rb` fact 3; guard 32) |
+| `PAGE-32` | MUST | ✅ | 14, 16 | `Pump#drain` closes the page whether the consumer returned or raised: on the success path a throwing close propagates into the fence and fails the future (`IOError` out of `Future#value`, within the deadline — never a hang); when the consumer already failed the close is `Dexpace.close_quietly`, the consumer's cause stays primary (`assert_same`) and the close error is swallowed, not attached (`Dexpace.suppressed` empty — the requirement's own word) (`async_paginator_test.rb` `FailureTest`, two `PAGE-32` cases; `lifetime_test.rb` "INVERSION, PAGE-32 (async)"; guard 33) |
+| `PAGE-33` | MUST | ✅ | 14 | **Discharged by documentation, at `AsyncPaginator#walk`'s YARD** (and `docs/sdk-documentation/pagination.md`), with both halves given a code site: the first half — a response the transport delivers after the cancel settled its future never reaches the paginator — is phase 2's `Completer#fulfil` on a settled completer returning false and closing the response itself (`SEAM-30`), measured (`closes` 1, the consumer never invoked); the second — an already-dispatched request completing after the abort is closed and discarded — is `Pump#settled`'s settled check before the drain, `PAGE-26`'s drop. The race itself is not observable and is not tested (`async_paginator_test.rb` `CancellationTest` "PAGE-33", "PAGE-26: a page parsed after the walk settled") |
+| `PAGE-34` | MUST | ✅ | 15 | `Fetchers.build(first:, next_page:, options:)`: the first fetcher is called exactly once per walk; the next-page fetcher is keyed off `previous.next_link`, falling back to `continuation_token` when the link is absent or blank (`["L"]` when both are present; `%w[T T2]` across two blank-link pages); a blank link with no token and a nil page from either fetcher end the stream; a nil first page is an empty stream; a fetcher's page owns its response and the walk closes it, the fetcher never does (`closes` 0 while the consumer holds it, 1 after); a non-`Page` answer is refused, and so is a page whose `next_link` or `continuation_token` is not a String — at `Page.build`, by name, before the front-end keys off it (P7-107; guard 46); the same `Items`/`Pages`/`each_item`/`each_page` surface over the same private `Walk`, uncapped (P7-111) (`fetchers_test.rb`, eight `PAGE-34` cases and "PAGE-1 / PAGE-12"; guard 36) |
+| `PAGE-35` | SHOULD | ✅ vacuous by construction | 15 | **Design §12's own disposition, neither ⏳ nor 🚫**: the SHOULD is conditional on a MUTABLE paging-options object and the port offers a frozen `RequestOptions`. What the port supplies, stated: the SAME frozen instance is threaded to every fetcher that takes it, by identity (`assert_same` across both fetcher calls; arity decides — `-> { }` and `->(key) { }` are called without it), `Page#continuation_token` is the first-class per-page channel (`PAGE-34`) and a Ruby callable carries its own binding; the "mutation visibility" clause is documented as unobservable in `Fetchers`' YARD (`fetchers_test.rb` `OptionsAndViewsTest`, two `PAGE-35` cases; guard 37) |
+| `PAGE-36` | MUST | ✅ | 10, 13, 14 | The paginator's frozen `options` is passed to the transport on EVERY exchange — `assert_same` on all three recorded calls, on both engines — and the default is `RequestOptions::EMPTY`, also by identity; a `Pipeline.standard` over a scripted transport receives `EMPTY` and `Cancellation.none` on each page. What 7c cannot assert — that an ADAPTER honours per-call options on pages 2..N — is phase 8a's: its plan's Task 13 carries the per-call-options conformance assertion and Task 20 wires it (`docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance.md`), and its checklist cross-references `PAGE-36`; no `docs/first-release.md` line is filed (`walk_test.rb` "PAGE-36"; `paginator_test.rb` "PAGE-36", "PIPE-26 / PIPE-27"; `async_paginator_test.rb` "PAGE-36 / PAGE-9"; guard 17) |
+
+The non-ID row the charter and the design both require:
+
+| Item | Status | What 7c did, and who owns the rest |
+|---|---|---|
+| Spec-forced boundary 5 / `R11` — the serde-isolation audit over `lib/dexpace/page/**` | owner elsewhere: **7b's Task 11** (`docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events.md`, `tools/serde_boundary.rb` + `gates:serde_boundary`), by the manager's decision of 2026-09-20 | 7c builds no gate, no `tools/` file, no fixture and no `tasks/gates.rake` edit; 7b's tool ships a `PENDING` row for `gems/dexpace-core/lib/dexpace/page/**/*.rb` that whichever lane rebases second flips to `GUARDED`. Because 7b's constant scan does not strip comments, the tokens `Serde` and `JSON` appear NOWHERE under `lib/dexpace/page/` or in `page.rb` — YARD included; `R7`'s own sentence is spelled "a caller who wants a codec" — and `page_test.rb` "spec-forced boundary 5" scans the fifteen files for them as 7c's own guard until the row flips (P7-108). The plan's Task 1 Steps 2–4 and its resolved question 5 are recorded as a deviation below. **Reconciled 2026-09-20:** the row flipped — on the rebased code branch, one `chore:` commit moves the two `page/**` rows from `PENDING` to `GUARDED` and adds `page.rb` and `page.rbs` rows beside them (the `**` under a directory cannot match the file beside it, the reason `sse.rb` has its own row), each naming "spec-forced boundary 5" in its violation; `PENDING` is empty; the gate's test asserts it and both fixture workspaces gain clean page files; a `Dexpace::Serde` read in `page/info.rb` and a `require "json"` in `page.rb` each ran the gate red before being reverted. `page_test.rb`'s token scan stays as this phase's own guard beside it |
+
+Cross-reference rows, the IDs this phase owns a share of:
+
+| ID | Status | What 7c supplies, and where it is proven |
+|---|---|---|
+| `HTTP-46`, `HTTP-47` | ✅ widened | `Dexpace::URL.resolve(base, reference) -> URI::Generic?` beside `.parse!` and `.external_form` (P7-3): the one place the parser pin lives; the result frozen through the same private `own`; a String or URI base, a String reference (nil is the caller's mistake, named); `.parse!` untouched, so a relative `rel=next` target still cannot reach `Request` except through resolution (`http/url_test.rb` `ResolveTest`, six cases) |
+| `HTTP-3`, `HTTP-4`, `SEAM-29` | ✅ | `Info`, `CursorStrategy`, `PageNumberStrategy`, `LinkStrategy`, `Paginator`, `AsyncPaginator` and `Fetchers` follow the construction pattern without exception: `.new` private, keyword `.build`, `Model.required!`'s one message form (`template is required`), validation in `initialize` so `#with` re-validates on the 3.2 floor (`paginator.with(cap: 0)` raises; `info.with(items: nil)` raises); `Page` and `Walk` are the two plain classes the plan names, because a `Data` cannot hold the latch (every suite's "HTTP-3" / "HTTP-4" case) |
+| `HTTP-13` | ✅ | The one fold site is `LinkHeader#next_token?`'s `token.downcase == "next"` (plus the parameter-name fold and `Page.dispatchable?`'s scheme fold), every one the no-argument `downcase` the `Dexpace/NoLocaleCaseFold` cop enforces; `rel=NEXT` and `HTTPS://h/z` are the behavioural proofs (`link_header_test.rb`; `page_test.rb` "P7-104") |
+| `PIPE-26`, `PIPE-27` | ✅ consumed | A `Pipeline.standard(scripted)` is handed to `Paginator.build` with no declaration, every page request runs through the three pillars, and the pipeline is never closed (`closed?` false after the walk) — which is also where a nil cancellation would have died (`paginator_test.rb` "PIPE-26 / PIPE-27") |
+| `SEAM-11`, `SEAM-16` | ✅ consumed | The blocking engine calls `#call(request, options, cancellation)` for a `Response` and the async engine for a `Future`, against phase 6a's `ScriptedTransport` and `ScriptedAsyncTransport` (no socket, no `TCPServer`: roadmap constraint 4); an async transport that raises eagerly or answers a non-future is a failed walk (`PAGE-28`'s row) |
+| `RECOV-12`, `SEAM-30` | ✅ consumed | `Dexpace.close_quietly` is the ONE swallow route, reached exactly where a requirement says swallow — `PAGE-26`'s drop, `PAGE-32`'s already-failed drain, `PAGE-30`'s rejected carry, the fatal-family parse close — and `Dexpace.attach_suppressed` the one trail writer (`Closing.close_walk`, `Walk#release`, `parse_or_close` through `close_quietly(onto:)`); the frozen-primary no-op (4b's P4-13) is stated in `Closing`'s and `Walk#release`'s YARD and asserted (`lifetime_test.rb` "the frozen-primary caveat") |
+| `XCUT-9` | N/A here | 7c walks no `#cause` chain and calls `Dexpace.each_cause` nowhere; boundary 13 forbids hand-walking and does not require walking |
+| `XCUT-11`, `XCUT-12`, `XCUT-15` | ✅ | `Paginator`, `AsyncPaginator`, `Fetchers` and the three strategies are frozen `Data`s with no lock and no per-call state; the per-walk state is a `Walk` (or a `Pump`) allocated per iteration; the pump's one mutex is held across the re-arm flag flip only; every collection a model holds is copied and frozen at construction (`paginator_test.rb` "PAGE-8: two threads"; `async_paginator_test.rb` "PAGE-31") |
+| `SEAM-1` | ✅ | No new `require`: `uri` is named by `url.rb`, which `page.rb` requires, and was allowlisted before phase 1; nothing under `page/` requires a stdlib feature of its own (`gates:require_allowlist` green) |
+| `NFR-4` | ✅ | Every addition is a widening: no existing signature moved. The manifest grew by exactly 77 rows, 1 137 → 1 214, read row by row against the object model — `Page` (`.build`, `.next_request_from`, six readers), `Info`, `QueryRewriter` (three `#` rows, `extend self`), the three strategies, `Items`, `Pages` (with `#more?`, `#closed?`), `Paginator`, `AsyncPaginator`, `Fetchers`, `PageStateError` and `URL#resolve`; no private constant, no `DIGITS`, no `DISPATCHABLE_SCHEMES`; the RBS baseline diff is vacuous until the first tag |
+| `NFR-11` | ✅ | No constant outside `Dexpace::` and the stdlib allowlist in any public signature; `URI::Generic`, `Enumerable`, `Enumerator`, `Numeric` and `Thread::Mutex` are on it |
+| `NFR-13` | ✅ for `.rb`; the `.rbs` half is phase 10's | The thirty-three new `.rb` files — fifteen under `lib/`, sixteen suites and two doubles under `test/` — open with the two headers the `Dexpace/SpdxHeader` cop gates; the fifteen new `.rbs` files carry no SPDX line, as no `.rbs` in the repository does (phase 10's `gates:spdx_rbs`), as 6a's and 6b's rows record |
+
+## What was built
+
+Fifteen new `lib/` files: `lib/dexpace/page.rb` (the page value, the namespace, `.next_request_from`
+and the private dispatchability screen) and fourteen under `lib/dexpace/page/` — `page_state_error.rb`,
+`info.rb`, `query_rewriter.rb`, `link_header.rb` (private), `cursor_strategy.rb`,
+`page_number_strategy.rb`, `link_strategy.rb`, `walk.rb` (private), `closing.rb` (private),
+`items.rb`, `pages.rb`, `paginator.rb` (with its private nested `Drive`), `async_paginator.rb` (with
+its private nested `Pump`) and `fetchers.rb` (with its private nested `Drive`). One earlier file
+widened in place, a designed widening: phase 1's `http/url.rb` gains `.resolve` beside `.parse!` and
+`.external_form` (P7-3). `lib/dexpace.rb` gains a fifteen-line `# Phase 7c:` block after 6b's, in
+dependency order. Every new file has a `sig/` mirror — the three private ones with `hooks.rbs`'s
+comment, and the three nested private classes declared inside their owners' mirrors, because the strict
+`core` Steep target types every call site — and `sig/dexpace/page.rbs` holds the three interfaces
+`_Strategy`, `_Extractor` and `_Executor` nested in `class Page` (P7-106). Every public file has a
+`test/` mirror; the two private ones with a mirror reach their constant through `const_get`
+(`link_header_test.rb`, `walk_test.rb`, 4c's `cursor_test.rb` precedent) and `closing.rb` has none.
+Two suites carry no `lib/` mirror and say so in their headers: `page/matrix_facts_test.rb` (Task 1's
+facts as a standing test, 5a's through 6b's precedent, with the plan's Task 17 Step 4 `Fiber[]`
+assertion) and `page/lifetime_test.rb` (Task 16, the inversion suite). Two new top-level test-support
+doubles, one class per file: `PageFixtures` (the requests, real responses over `FakeResponseBody`, the
+scripted strategy, the two engines and the walk every pagination suite uses) and `ProbeExecutor` (the
+queued executor whose `#drain` runs the queue on a fresh joined thread, and the rejecting one, with
+`Rejected` as its one error). No Response double: every response in the suite is a real
+`Dexpace::Response` built through `Response.builder` (7a is creating `test/support/counting_response.rb`
+concurrently and nothing of 7c's collides with or depends on it); no third scripted transport (6a's
+`ScriptedTransport` and `ScriptedAsyncTransport` as they are, the latter's `settle_later:` and
+`#settle_next!` being exactly `PAGE-25`/`PAGE-26`'s hold-and-release); phase 2's `InlineExecutor` is
+the inline executor. Two existing tests changed: on the **code** branch, the smoke suite
+(`gems/dexpace-core/test/dexpace_test.rb`, a pin the surface and layer gates read) gains
+`PAGE_LAYER = %i[Page]` and a `PhaseSevenLayers` class (the eleven public constants under `Page`, the
+six private names unreachable, the fourteen nested names shadowing nothing at the top level or flat
+under `Dexpace` — Task 17's shadowing audit as a standing test); on the **tests** branch, phase 1's
+`http/url_test.rb` gains a nested `ResolveTest` for the widened `URL.resolve` (`Style/OneClassPerFile`
+being live) — a new test over a new function, not an invalidated pin, so the layering rule puts it
+there. The surface manifest was regenerated once, 1 137 → 1 214, and all 77 rows read against the
+object model (the `NFR-4` row).
+
+## Matrix facts, re-run on every interpreter
+
+The plan's eight facts and the cross-check's were run on 2026-09-20 on **3.2.11, 3.3.12, 3.4.10 and
+4.0.6**, first as a scratch script per interpreter and then as `page/matrix_facts_test.rb`, a standing
+test on every CI row (thirteen cases). Every fact holds identically on every row, uri 0.12.5 (the
+version `gates:clean_bundle` loads on 3.2.11), 0.13.3, 1.0.4 and 1.1.1 alike; the only things that
+differ are `Hash#inspect`'s spelling and `NoMethodError`'s message, neither asserted. A close error
+raised from an `ensure` reaches the caller unwrapped through `Enumerable#first`, `Lazy#first(2)`, an
+explicit `break` and a plain block, and a raising `ensure` REPLACES an in-flight consumer error as the
+primary (its `#cause`); **`$!` is the caller's inside a method called from the caller's rescue** — the
+fact the plan's `R8` prescription did not weigh (P7-105); a re-arm loop over 20,000 inline-settled
+futures is flat where a pump re-entering itself from `on_settle` overflows at 2,619 (3.2.11) or 2,847
+(the other three) pages through the real `Completer`; `query=` round-trips every parser-produced query
+for every printable ASCII byte through `Request#with`'s re-parse (the canonicalising happens at PARSE
+time, on exactly space, `"`, `#`, `'`, `<`, `>` and `` ` ``); `URI.decode_www_form("q=a+b")` is
+`[["q", "a b"]]` and `encode_www_form_component("a b")` is `"a+b"`; `join` resolves `mailto:a@b`,
+`javascript:alert(1)`, `http:foo` and `http:///p` successfully to a nil or empty host; `query = nil`
+removes the query and `query = ""` leaves a dangling `?`; `Fiber[]` is visible inside an `Enumerator`'s
+internal fiber, which is created at the FIRST pull, so a correlation value set after construction and
+before the first pull IS seen by the walk and one set after the first pull is not; `Model.own` deep-copies
+and freezes elements and raises on a Proc; a `Data` holds no instance variables; `Completer#fulfil` on a
+cancelled completer returns false and closes the response itself; and a nil cancellation dies with
+`NoMethodError` inside `Pipeline.standard`'s retry step. 6b's `redirect/matrix_facts_test.rb` already
+pins the join facts this phase rests on (a query-only reference preserves the path; `join(base, "")`
+answers the base; `join(base, nil)` raises `ArgumentError`; `join` raises on a space and on nothing
+else) and is cited rather than re-asserted.
+
+## Guards run red
+
+Every guard the brief asks to be seen red was seen red, on 4.0.6 and on 3.2.11, and the bytes
+restored after each: **forty-nine single-edit mutations of `lib/`** — the reviewer's thirty-nine,
+six of this build's, two added in review round 1 for the two fixes it made (46, 47) and two in review
+round 2 (48, the reviewer's surviving mutation 73, now caught; 49, the fragment-only half of the
+same-document screen dropped) — one at a time through a harness that applies the edit in a scratch
+copy of the tree, runs the owning suites under `ruby -w`, captures the first failure and restores the
+file. Two mutations were re-spelled to keep a variable live, because their first spelling crashed the
+suite on an unused-variable warning (`FatalWarnings`, `NFR-6`'s rule firing before any assertion)
+rather than on an assertion: guard 3 (`previous.nil?` in place of the deleted close) and guard 40
+(`$! || primary` in place of `$!` alone). **Forty-nine of forty-nine are caught on 4.0.6 and
+forty-nine of forty-nine on 3.2.11; no equivalent mutant.** Round 2 also ran the reviewer's 5, 8, 26
+(re-spelled against the round-2 source, the whole pre-resolution guard skipped) and 43, the
+neighbours of the two edits, and one negative mutation — the same-document screen moved onto the
+RESOLVED URL, which the "deliberately not screened" pins in `page_test.rb` and
+`link_strategy_test.rb` turn red — all caught on both rows. The plan's guard 4 ("let `Walk#buffer`
+overwrite a staged page") is unreachable through `Pages#more?` as built — the probe reads a staged
+page before it would ever call `#buffer` — so the mutation applied is the reachable one, dropping
+that early return; `Walk#buffer`'s own refusal is `walk_test.rb`'s "never displaced" case. The plan's
+guard 38 ("close the page both in the drain `ensure` and after it") is absorbed by the `Page`'s own
+latch — which IS `PAGE-27`'s mechanism — so the mutation applied closes the RESPONSE a second time
+past the latch.
+
+| # | Fix reverted | Guard | What it said (4.0.6; identical on 3.2.11 unless stated) |
+|---|---|---|---|
+| 1 | `PAGE-11`: `page.close` moved after the items are yielded | `items_test.rb` | `PAGE-11: the page is closed BEFORE any of its items is yielded` — `Expected: [1, 1] Actual: [0, 0]` (5 failures) |
+| 2 | `PAGE-11`: the eager `page.close` deleted | `items_test.rb` | `Items#close releases nothing …` — `Expected: 1 Actual: 0`; "taking one item … closes it" (6 failures) |
+| 3 | `PAGE-12`: `Walk#hold` does not close the previous page | `pages_test.rb` | `PAGE-15: a close error while ADVANCING is surfaced` — nothing raised; "the previous page is closed as the consumer advances" `[[0,0,0],[0,0,0],[0,0,0]]` (5 failures) |
+| 4 | `PAGE-12` / `PAGE-6`: a second `Pages#more?` re-fetches instead of reading the staged page | `pages_test.rb` | `PAGE-6 / PAGE-12: a second probe costs no exchange and strands no page` — `Dexpace::Page::PageStateError: a page is already staged` (1 error) |
+| 5 | `PAGE-12`: `@buffered` dropped from `Walk#release` | `pages_test.rb` | `Expected: [1, 0, 0] Actual: [0, 0, 0]`; "probing without advancing, then closing, releases the prefetched page" (3 failures) |
+| 6 | `PAGE-15`: the advance close made `close_quietly` | `pages_test.rb` `SurfacedCloseTest` | `PAGE-15: a close error while ADVANCING is surfaced, not swallowed` — `IOError expected but nothing was raised` |
+| 7 | `PAGE-13` / `PAGE-15`: `Closing.close_walk` ignores the primary (a bare close) | `lifetime_test.rb` | `INVERSION, PAGE-13: … the CONSUMER's error is primary` — `[KeyError] exception expected, not Class: ` (3 failures) |
+| 8 | `PAGE-15`: the FIRST close failure attached to the second, the second raised | `lifetime_test.rb`, `walk_test.rb` | `Expected: "first" Actual: "second"` (2 failures) |
+| 9 | `PAGE-13`: the inline close on parse failure removed | `walk_test.rb` | `Expected: 1 Actual: 0` on `closes`; "a close failure does NOT mask" `Expected: [IOError] Actual: []` (2 failures) |
+| 10 | `PAGE-13`: the close error propagates over the parse failure | `walk_test.rb` | `[KeyError] exception expected, not Class: ` |
+| 11 | `PAGE-14`: the `@viewed` latch removed | `pages_test.rb` `SingleUseTest` | `Dexpace::Page::PageStateError expected but nothing was raised` (2 failures) |
+| 12 | `PAGE-8`: the `Walk` cached on `Items` | `items_test.rb` | `Expected: 4 Actual: 2` transport calls; the interleaved case `StopIteration` (2 failures, 1 error) |
+| 13 | `PAGE-8`: one `Walk` shared across `paginator.pages` calls | `pages_test.rb` | `Expected: 4 Actual: 2`, and 12 more failures as every view shares one walk |
+| 14 | `PAGE-7`: the exhaustion latch dropped and the drive restarts from the template | `walk_test.rb` | `ScriptedTransport: script exhausted after 2 calls` — the walk fetched on (1 error) |
+| 15 | `PAGE-9`: the cap compared with `>` | `walk_test.rb` | `Expected: 3 Actual: 4` transport calls |
+| 16 | `PAGE-9`: the positivity check removed from `initialize` | `paginator_test.rb` | `Dexpace::InvalidArgumentError expected but nothing was raised` at construction and on `#with` (2 failures) |
+| 17 | `PAGE-36`: `RequestOptions::EMPTY` from page 2 onward | `walk_test.rb` | `Expected … RequestOptions timeout=1.5 … to be the same as … EMPTY` on call 2 |
+| 18 | `PAGE-6`: the first fetch performed inside `Paginator#items` | `paginator_test.rb` | `PAGE-6 (blocking engine): … ZERO exchanges` — `Expected: 0 Actual: 1` (3 failures, 6 errors) |
+| 19 | `PAGE-6` (async): the first dispatch never submitted from `#start` | `async_paginator_test.rb` | `Expected: 1 Actual: 0` calls after `walk`, and every walk hangs to its deadline (13 failures, 13 errors) |
+| 20 | `PAGE-16`: `""` treated as a real cursor | `cursor_strategy_test.rb` | `Expected nil, not #` |
+| 21 | `PAGE-17`: the current page read from the template | `page_number_strategy_test.rb` | `Expected: "page=6" Actual: "page=2"` (3 failures) |
+| 22 | `PAGE-17`: the empty-items check removed | `page_number_strategy_test.rb` | `Expected nil, not #` |
+| 23 | `PAGE-18`: link-values split on every `,` | `link_header_test.rb` | `Expected: "https://x/p?a=1,2" Actual: nil` |
+| 24 | `PAGE-18`: `rel` compared whole and case-sensitively | `link_header_test.rb` | `Expected: "https://x/2" Actual: nil` on `rel=NEXT` (2 failures) |
+| 25 | `PAGE-19`: the target parsed with `URL.parse!` instead of resolved | `link_strategy_test.rb` | `Dexpace::InvalidArgumentError: url "?page=2" is not an absolute URI (HTTP-47)` (3 errors) |
+| 26 | P7-5: the blank-target guard skipped | `link_strategy_test.rb` | `Expected nil, not #` — the base itself |
+| 27 | `PAGE-21`: the splice routed through `Query.parse(q).encode` | `query_rewriter_test.rb` | `Expected: "a=b=c&&x=%zz&page=2" Actual: "a=b%3Dc&x=%25zz&page=2"`; `filter=a%3Ab` (4 failures) |
+| 28 | `PAGE-22`: values encoded with `URI.encode_www_form_component` | `query_rewriter_test.rb` | `Expected: "q=a%20b" Actual: "q=a+b"` (2 failures) |
+| 29 | P7-4: an empty splice answered as `""` | `query_rewriter_test.rb` | `Expected nil, not ""`; `https://x/a?` |
+| 30 | `PAGE-28`: the consumer's error wrapped in `SeamError` | `async_paginator_test.rb` `FailureTest` | `[KeyError] exception expected, not Class: ` (7 failures) |
+| 31 | `PAGE-30`: the carried response not closed when `#post` raises | `async_paginator_test.rb` `FailureTest` | `Expected: 1 Actual: 0` on the staged page's `closes` (2 failures) |
+| 32 | `PAGE-31`: the pump launches the next page directly from the settlement | `async_paginator_test.rb` `TrampolineTest` | `SystemStackError: stack level too deep` inside the 3,000-page walk |
+| 33 | `PAGE-32`: the success-path close made quiet | `async_paginator_test.rb` `FailureTest` | `IOError expected but nothing was raised` — the future fulfilled |
+| 34 | `PAGE-26`: the settled check before the drain removed | `async_paginator_test.rb` `CancellationTest` | `dropped pages are never drained` — the consumer was invoked after the cancel |
+| 35 | `PAGE-25`: `completer.on_cancel { in_flight&.cancel }` dropped | `async_paginator_test.rb` | `Expected # to be cancelled?` on the in-flight future (2 failures) |
+| 36 | `PAGE-34`: the continuation token keyed before the next link | `fetchers_test.rb` | `Expected: ["L"] Actual: ["T"]` |
+| 37 | `PAGE-35`: the second fetcher handed `options.dup` | `fetchers_test.rb` `OptionsAndViewsTest` | `Expected … to be the same as …` on the second fetcher's options |
+| 38 | `PAGE-27`: the response closed a second time past the page's latch | `async_paginator_test.rb` `TrampolineTest` | `Expected: [1, 1, 1, 1, 1] Actual: [2, 2, 1, 1, 1]` (2 failures) |
+| 39 | `PAGE-3`: `Page#close` bypasses the latch | `page_test.rb` | `Expected: 1 Actual: 3` closes; the raising close retried (2 failures) |
+| 40 | P7-105: the primary read off `$!` instead of the recorded local | `lifetime_test.rb` | `CALLER'S RESCUE, PAGE-15 (P7-105)` — `IOError expected but nothing was raised` inside the caller's rescue; and the source scan (2 failures) |
+| 41 | `PAGE-14` / P7-103: `InvalidArgumentError` raised instead of `PageStateError` | `pages_test.rb` `SingleUseTest` | `[Dexpace::Page::PageStateError] exception expected, not Class: ` (2 failures) |
+| 42 | P7-102: `nil` passed as the transport's cancellation | `walk_test.rb`, `paginator_test.rb` | `NoMethodError: undefined method 'check!' for nil` out of `Pipeline.standard`'s retry step; `Expected Cancellation.none to be the same as nil` (1 failure, 1 error) |
+| 43 | P7-104: the dispatchability screen dropped | `link_strategy_test.rb`, `page_test.rb` | `Expected nil, not #` (2 failures) |
+| 44 | `PAGE-4`: a raising extractor rescued and read as end-of-stream | `cursor_strategy_test.rb` | `KeyError expected but nothing was raised` (2 failures) |
+| 45 | `PAGE-29`: the consumer invoked inline instead of through the executor | `async_paginator_test.rb` | `Expected #}> to be empty` — the consumer ran on the test's thread; the rejection cases (4 failures) |
+| 46 | `PAGE-34` / P7-107 (round 1, R0-1): `Page#initialize` stores `next_link` and `continuation_token` unchecked | `page_test.rb`, `fetchers_test.rb` | `Dexpace::InvalidArgumentError expected but nothing was raised`; `Expected "L" to be frozen?`; `[Dexpace::InvalidArgumentError] exception expected, not … NoMethodError` out of the fetcher walk (3 failures) |
+| 47 | P7-116 (round 1, R0-3): `LinkHeader#next_target` honours ANY `rel` parameter (`params.any?`) | `link_header_test.rb` | `Expected "https://x/1" to be nil` on `rel="prev"; rel="next"` |
+| 48 | `PAGE-15` (round 2, R1-1; the reviewer's mutation 73): `Walk#release` clears its two slots only AFTER a successful close, past the raise | `walk_test.rb` `SlotsTest`, `pages_test.rb` `SurfacedCloseTest` | `Expected # to be nil` on `walk.current` after the raising close; `Expected # to not be more?` on the closed view (2 failures) |
+| 49 | P7-117 (round 2, R1-2): the fragment-only half of `same_document?` dropped, `<#>` resolved and followed | `page_test.rb` `NextRequestFromTest`, `link_strategy_test.rb` | `Expected # to be nil` (2 failures) |
+
+## Audit groups run
+
+The phase-start pair first, at implementation: `--origin note --brief` returned entries across 22
+note files (`pipeline/86343352`, `pipeline/7ce4431d`, `error-handling/5322e965`,
+`execution-context/b58728da` and `url-and-query-encoding/08c54234` read in full, the five the brief
+binds); `--section conflicts --brief` returned six harvested conflicts, every one
+`[overridden by notes/…]`, none open. The audit group's `PAGE` slice, `--prefix PAGE --section rules
+--brief`, returned 36 entries across three topic files, **zero tagged `[appendix-B roll-up]`**,
+covering 33 of the 36 (`PAGE-16`, `PAGE-17`, `PAGE-18` filed under Reference, read with `--req`, whose
+substantive entries `pagination/3174b6ac`, `3422b2f6` and `f4b0a107` are the three strategies' rules
+verbatim); chapter 12 was read in full, its `*Conformance:*` clauses included, because `PAGE-12`'s
+two-shape and `PAGE-27`'s four-path clauses are each two or four tests for one row. `--req PAGE-14`
+confirmed the SSE-under-`PAGE-14` pair (`sse-streaming/5f4803a0`, `b94ce49e`) and `--req BODY-11
+--origin note` the `pagination/b2a85752` attribution, both now recorded in
+`docs/knowledge/notes/pagination.md` (Findings routed).
+
+| Group | Result at implementation |
+|---|---|
+| Public API surface | `module-organization/1828a984` (one public constant per file) holds for every new file — the three nested private classes are `private_constant`s of their owners, 6a's `Pump` precedent; `api-design/b0e18938` is why `DIGITS` and `DISPATCHABLE_SCHEMES` are private, why no engine exposes a logger or a reader beyond its `Data` members, and why `Page.next_request_from` is the ONE public function the design argues for rather than three private re-derivations; every public name is in the design's object model or in P7-103, P7-106 and P7-109 |
+| RBS / Steep typing | Fifteen new mirrors and one widened (`url.rbs`), the strict target green with no relaxation; `transport`, `strategy`, `extract`, `extract_items`, `executor`, `first`, `next_page` and the views' opener are `untyped` because a lambda is a valid value (`respond_to?` validates each); the three interfaces are nested in `class Page` and validate under rbs 4.2.0; `include ::Enumerable[untyped]` and the two-overload `each` are core's first, and validate; every `URI::RFC3986_PARSER` site writes `#: untyped` as `url.rb` does; one `#: Exception` on `Settlement#error` in the forwarding arm |
+| Minitest conventions | Every suite subclasses `DexpaceTestCase`; the async suite is four nested classes, the pages, walk, fetchers, lifetime and matrix-facts suites two each, under `Metrics/ClassLength` (6b's shape); `assert_same` wherever identity is the claim (the same options, the same body, the same error object); every `URI::InvalidURIError` asserted by class; no `Hash#inspect` asserted; `sample(count: 128)` is the property test's driver; every thread a test or a double starts is joined (`ProbeExecutor#drain`) |
+| Fiber scheduler, thread safety | The engines are frozen and hold no per-walk state; the one mutable object per walk is the private `Walk` or `Pump`; the pump's mutex is held across the re-arm flip only; no thread started by `lib/`, no wait, no scheduler read (a source scan pins `Async.delay`, `.sleep`, `Fiber.scheduler`, `Thread.new` absent); `Fiber[]`'s first-pull snapshot asserted |
+| Serialization, SSE and pagination | Every `PAGE` rule in the group restates a clause implemented above; `pagination/f57c50f6` prints `[overridden by notes/pagination.md:10]` and the build wrote no resource into an `Enumerator` block or a `#each` local; the two SSE rules the group returns under `PAGE-14` are 7b's and 7c neither consumes nor duplicates them (the error family is shared by construction, P7-103); no `SERDE` rule binds, because 7c names no serializer |
+
+## Deviations from the plan
+
+Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. Items
+1–19 are where the built tree overrode the plan's assumptions, in the order the brief's as-built list
+gives them; 20–30 are this build's, 31 is review round 1's and 32 is review round 2's. The ones that
+touch public behaviour, the contract a later phase cites, or a statement the design makes are also the
+as-built ledger rows P7-101–P7-117.
+
+1. **Nothing was installed.** All four interpreters were present; the plan's Task 1 Step 1 and the
+ design's "the only interpreter installed" were stale. The facts were re-run on every row and live in
+ `page/matrix_facts_test.rb`, the per-phase convention since 5a; Task 17 Step 4's `Fiber[]`
+ assertion is there rather than in `lifetime_test.rb`.
+2. **Every private constant has a `sig/` mirror, and there is no `sig/dexpace/page/strategy.rbs`**
+ (P7-106): `test/gates/gem_layout_test.rb` maps `lib/` to `sig/` one for one, so the design's
+ "carry no `sig/`" and the plan's sig-only interface file both fail `test:gates`. The interfaces
+ live in `sig/dexpace/page.rbs`, nested in `class Page`; `walk.rb` and `link_header.rb` keep test
+ mirrors through `const_get`, `closing.rb` has none and joins `CLAUDE.md`'s exception list.
+3. **The doubles as built were reused, and no Response double was written**: 6a's
+ `ScriptedTransport` / `ScriptedAsyncTransport` (the plan's `CountingTransport`), 3b's
+ `FakeResponseBody` with its un-latched `#closes` and 4b's `RecordingBody` with its `source_count`
+ (the plan's `ClosingProbe`), phase 2's `InlineExecutor` (the plan's `ProbeExecutor(mode: :inline)`).
+ The two new doubles are top level, one class per file: `PageFixtures` (all of the plan's fixture
+ helpers, over a stateless `ScriptedStrategy` that reads the page index off the executed request's
+ `p` parameter and a transport script of callables that build each response around the request the
+ engine actually sent — so `response.request` is real) and `ProbeExecutor` (`:queued` with a
+ thread-running `#drain`, `:rejecting` with `after:`; `Rejected` its one error).
+4. **The sync engine passes `Dexpace::Cancellation.none` to the transport, never `nil`** (P7-102); it
+ takes no `cancellation:` keyword in v1, per the manager's decision.
+5. **`RequestOptions.build` takes all three keywords**, so the plan's `.build(timeout: 1.0)` is
+ `RequestOptions.builder.tap { |b| b.timeout = 1.0 }.build`, 8a's spelling.
+6. **`Model.own` is not called on the items** (P7-101); the plan's `instance_variables - %i[@extract
+ @parameter]` assertion is the truthful `assert_empty(strategy.instance_variables)` plus
+ `frozen?`, because a `Data` holds no ivars.
+7. **RuboCop 1.91 under `NewCops: enable` shaped the fences**: no `$!` (P7-105; the 6b
+ `rescue ::Exception => error` shape, the one inline disable per site); `=> error` everywhere; a plain
+ `def resolve` inside `URL`'s `extend self`; `Dexpace::Page.next_request_from` is `def
+ self.next_request_from`; `::Thread::Mutex`; `items.each(&)`; every method under the length, ABC and
+ complexity caps by splitting (`QueryRewriter#splice`, `Info#optional_string`, the engines'
+ `validate_engine!`, `LinkHeader`'s seven small scanners); `Metrics/ClassLength` recorded inline
+ with its reason on `Pump` alone (6a's shape); the suites split into nested classes; the plan's
+ `DexpaceURLResolveTest` is the nested `ResolveTest`; `assert_predicate` for every `more?`; no
+ `Lint/UnreachableLoop` (`break if page.items == [1]`); the YARD gate at 100.00%, every
+ `attr_reader` on its own line with its own `@return`.
+8. **`Dexpace::URL.resolve` freezes through the private `own`, takes a String or URI base, requires
+ a String reference (nil is `InvalidArgumentError`, never a silent end-of-stream), and does NOT
+ screen dispatchability** — the screen is `Page.next_request_from`'s (P7-104), 6b's `dispatchable?`
+ copied because `Redirect::Location` is a `private_constant`. `SEAM-27`'s composition in
+ `Dexpace::Operation` is untouched and unrelated.
+9. **The splice was measured through the real path** (`dup` / `query=` / `with(url:)` / `URL.parse!`)
+ on every row, not `query=` alone: zero disagreements, the seven bytes canonicalised at parse time.
+10. **The trampoline's hazard was measured through the real `Completer`**: ~2,600 frames, not ~11,000;
+ the pump is 6a's `Pump` in shape, the `PAGE-31` test measures `caller.size` across 3,000 pages
+ through both paths, and every callback body is total (a raising `on_settle` block escapes
+ `Completer#fulfil` through `Hooks.notify`'s re-raise).
+11. **`PAGE-33`'s two halves have two code sites**, one of them phase 2's (`Completer#fulfil` on a
+ settled completer), named in `#walk`'s YARD and in the row.
+12. **The async token plumbing is 6a's**: `completer.on_cancel { @in_flight&.cancel(reason) }`, a
+ caller's token bridged with `on_cancel { |reason| @completer.request_cancel(reason) }` and detached
+ on settle; a transport-side cancellation forwarded as a cancellation (P7-109).
+13. **`Future#value(deadline:)` is the bounded wait**; no `Timeout_free_value` helper.
+14. **Phase-1 and phase-2 surfaces as built**: `Request.build(method:, url:, headers:, body:)` with an
+ OUTBOUND `Headers`; `Headers#[]` the frozen list under the fold; `Page.build` checks
+ `response.is_a?(Dexpace::Response)` (P7-107); `Dexpace.attach_suppressed`'s frozen-primary no-op
+ stated and asserted.
+15. **No gate, no fixture, no `tasks/gates.rake` edit for boundary 5** (P7-108): the plan's Task 1
+ Steps 2–4 and resolved question 5 are superseded by the manager's decision; 7b's Task 11 owns it,
+ and 7c's own guard is `page_test.rb`'s token scan. `R7`'s sentence is spelled "a caller who wants a
+ codec" in every YARD block.
+16. **`lib/dexpace.rb`'s block is fifteen lines**, the design's fourteen plus `page/page_state_error`
+ (P7-103), in the order given, `page` first.
+17. **The smoke suite gained `PAGE_LAYER = %i[Page]` and a `PhaseSevenLayers` class** with the
+ constants pin, the six private names and the fourteen-name shadowing audit; the manifest was
+ regenerated once, +77 rows.
+18. **The RBS validates as the plan wanted**, `include ::Enumerable[untyped]` and the nested
+ interfaces included; nothing outside `STDLIB_ALLOWED` is named.
+19. **The design's four findings were verified at their owners** (Findings routed): `C13`, the two
+ corpus corrections and the harvest gap (now notes), 8a's `PAGE-36` conformance tasks; nothing
+ re-filed in `docs/first-release.md`, `docs/deviations.md` or the roadmap's inbound list for them.
+20. **`Info` and `Page` validate `next_link` and `continuation_token` as `String` or nil** and copy
+ them frozen; `Info.terminal` is the named factory the design asks for. (`Page`'s half of this
+ sentence was false at review round 0 — only `Info` checked — and is true since round 1's fix,
+ R0-1, guard 46.)
+21. **The extractor's answer shape is checked** (P7-107) — the plan's fences destructured it blindly.
+22. **`Pages#more?` returns `true` on a staged page before touching the walk**, `Walk#buffer` raises
+ `PageStateError` rather than the plan's `InvalidArgumentError` on a second staged page, and
+ `Walk#hold` writes the new page into the slot BEFORE closing the previous one (P7-105's second
+ half): the plan's `release_current` cleared `@current` first and would have stranded the page
+ being advanced to when the previous close raised.
+23. **`Closing` is `extend self` with two functions** — `close_walk(walk, primary)` and
+ `parse_or_close(strategy, response, template)` — rather than the plan's included mixin, so the
+ two engines' drives and the async pump call the same two lines; `parse_or_close` also refuses a
+ non-`Info` answer and closes on the fatal family (P7-110).
+24. **The `Walk` is generic over a drive** (P7-111); `Items.new` / `Pages.new` take the engine's walk
+ factory (a `Method` object), validated `respond_to?(:call)`.
+25. **`Paginator#each_item` / `#each_page` (and `Fetchers`') require a block** and return nil; they
+ delegate to the views, whose `#each` carries the ensure.
+26. **`Pages#each` without a block claims the latch on obtaining the enumerator** (P7-113) through
+ `to_enum(:drive)` over a private method (works on every row, verified), and `#closed?` is exposed
+ beside `#close` and `#more?`.
+27. **The async engine posts the first dispatch through the executor too** (P7-112); a rejected first
+ post fails the walk before any exchange; the future settles with the page count (P7-109).
+28. **`PageNumberStrategy`'s screen is `[0-9]+`, private** (P7-114); `LinkHeader`'s malformed-input
+ rule and its parameter-name fold (P7-115).
+29. **Fetcher arity is read through `Registry.callable?`** — `arity: 1` for the first fetcher,
+ `arity: 2` for the next-page one — so a `-> { }` and a `->(key) { }` are called without the
+ options and their two-parameter forms with it; a non-`Page` fetcher answer is refused (P7-107).
+30. **`CLAUDE.md`'s counts are re-derived from the tree on top of `main`**: 199 `lib/dexpace/` files
+ beside `version.rb`, nineteen `private_constant` test-mirror exceptions (`page/closing.rb` joins),
+ fifteen checklists, `phase7/phase7c/` holding a checklist; the lib-file count was counted by hand
+ because the probe does not read it. *Reconciled 2026-09-20:* on top of `main` with 7b, the same
+ hand count gives 208, the exceptions stay nineteen, and the checklists are sixteen.
+31. **`LinkHeader` reads only the first `rel` parameter of a link-value** (P7-116; review round 0's
+ R0-3): RFC 8288 §3.3 says `rel` MUST NOT appear more than once and occurrences after the first MUST
+ be ignored, and the first build's `params.any?` honoured a later one. `PAGE-18`'s own words ("the
+ first link-value whose `rel` parameter contains the token `next`") do not decide the case; the RFC
+ does, and the strict reading is the one that cannot follow a link the server marked `prev`.
+32. **A fragment-only `rel=next` target is end-of-stream before resolution, as the empty one is**
+ (P7-117; review round 1's R1-2). The design's P7-5 screened the empty and whitespace-only target
+ because `join(base, "")` answers the base itself; `join(base, "#frag")` answers the base plus a
+ fragment the wire never carries, so `<#>; rel=next` re-fetched the current page until the cap,
+ which defaults to unbounded. RFC 3986 §4.4 names the two forms together as the same-document
+ references whose dereference "should not result in a new retrieval action", and the guard now
+ reads both off the raw target through a private `Page.same_document?`. It stays syntactic: `>`,
+ `/>` (uri resolves it to the base itself, measured on every row and under uri 0.12.5) and the
+ current URL spelled out are next requests like any other, bounded by `PAGE-9`'s cap — a screen on
+ the resolved URL would silently end a walk against an endpoint that advances server-side state
+ under one URL, with no knob to turn it off — and the suites pin that they are followed.
+
+## Findings routed
+
+- **The design's four findings were verified at their owners and not re-filed.** (1) `PAGE-15`'s
+ wrapping-clause vacuity: `docs/first-release.md`'s fourteen-corrections blocker already lists it as
+ `C13` ("§12's `PAGE` row recording one vacuity fewer … `PAGE-15`'s wrapping clause and `7c P7-1`"),
+ `docs/deviations.md` carries the interim note and the roadmap's phase-10 inbound list the
+ fold-into-§10 bullet — the `PAGE-15` row cites `C13` and edits none of the three. (2)/(3) The two
+ corpus corrections — the SSE-under-`PAGE-14` defect is a PAIR (`sse-streaming/5f4803a0` and
+ `b94ce49e`), and `pagination/b2a85752` carries `BODY-11` rather than no ID — are new `## Reference`
+ entries in `docs/knowledge/notes/pagination.md` (sha markers `manual-phase7c-sse-under-page14`,
+ `manual-phase7c-b2a85752-carries-body11`), beside a third for §12's unharvested serde-agnosticism
+ (`manual-phase7c-serde-agnosticism-unharvested`) and a fourth adding P7-105's `$!` finding to the
+ pipeline note it rests on (`manual-phase7c-r8-without-dollar-bang`); never an edit to `harvested/`,
+ and `verify_knowledge_structure.rb` is OK (60 notes). (4) `PAGE-36`'s per-call-options conformance
+ test is 8a's plan, Tasks 13 and 20, already carrying the assertion and its wiring — the `PAGE-36`
+ row cites them and no `docs/first-release.md` line is filed. The knowledge-lookup skill's audit
+ table already has the "Serialization, SSE and pagination" row; the design's "owed a thirteenth row"
+ is discharged.
+- **Routed to 8b's plan, Task 9** (`docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter.md`):
+ the correction that `Dexpace::Page::_Executor` lives in `sig/dexpace/page.rbs`, nested in
+ `class Page`, not in `sig/dexpace/page/strategy.rbs` — one sentence added at the point that plan
+ names the file; the interface's name and `def post: () { () -> void } -> void` are exactly as
+ `bridge/async_over.rb:61`, `docs/deviations.md:63` and that plan already cite.
+- **Already routed, cited and not re-filed**: the `PAGE-14`/`SSE-26` error-family bullet on phase 10's
+ inbound list (2026-09-13) — its argument-family half is closed by P7-103 and its shared-supertype
+ half stays on the list, which the roadmap's 7c status note says; the three-spellings replayability
+ predicate and the two scripted transports' reconciliation (6b's and 6c's), which 7c touches not at
+ all.
+- **The design's ledger** gains an "As built" addendum (P7-101–P7-117); the consolidation of P7-1–P7-6
+ and P7-101–P7-117 into design §10 — beside 7a's and 7b's rows, whose P7-1–P7-9 collide with this
+ design's by number, knowingly — and the addition to §12's `PAGE` row are a human's, as for 3a
+ through 6c, because `docs/sdk-design-ruby/` is frozen. `docs/deviations.md` is untouched, for phase
+ 10 to flip.
+- **A finding in material this phase may write was fixed rather than filed**: the root `README.md`'s
+ built-phases sentence omitted 6b although the same paragraph describes the redirect layer; 6b is
+ added beside 7c (the docs PR says so).
+
+## Postponed work
+
+**None of this phase's own.** Every one of the 36 IDs is implemented — 35 outright and `PAGE-35` as
+vacuous by construction on design §12's authority — and design §12's `PAGE` row's "*Deferred:* none"
+is confirmed. **What this phase leaves to others, none of it its own to defer**: the boundary-5 gate
+row for `page/**` (7b's Task 11 ships it `PENDING`; whichever lane rebases second flips it to
+`GUARDED`); the shared single-use-latch supertype across `Page::PageStateError` and 7b's
+`SSE::StreamStateError` (phase 10's inbound list, the bullet of 2026-09-13); a `cancellation:` keyword
+on the blocking engine (a pure widening phase 8 or 10 can add, P7-102); the adapter half of `PAGE-36`
+(8a's Tasks 13 and 20); and `PAGE-33`'s "the transport's responsibility" half, which phase 8's
+adapters must honour and which `Completer#fulfil` already honours for a future built on the core pivot.
+`docs/sdk-documentation/pagination.md` is written; the first worked cross-gem example — a paginated
+JSON endpoint with `dexpace-serde-json`'s codec in the caller's extractor — waits for 7a, as the
+design's interface table says.
diff --git a/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md b/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md
index f7364e3..dd65399 100644
--- a/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md
+++ b/docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-design.md
@@ -1415,6 +1415,84 @@ precedent, and the failure `6a` and `6c` both walked into by numbering in isolat
design §10 item 6's, already argued and consolidated, and `7c` consumes it; the `Enumerator` rule is design
§7.1's, already argued, and `7c` obeys it.
+### As built, 2026-09-20
+
+The six rows above stand. This sub-phase was cut from `main` at `c53638b`, which holds the whole of
+phase 6, concurrently with `7a` and `7b` on the same base: nothing of theirs exists on this tree, so
+`R11`'s "if `7b` landed first" branch did not arise and its "if `7c` lands first" branch was **not
+taken either** — the manager's decision of 2026-09-20 gives the boundary-5 audit to `7b`'s Task 11
+(`tools/serde_boundary.rb`, running concurrently with a `PENDING` row for `page/**`), and `7c` builds
+no gate, no fixture and no `tasks/gates.rake` edit; the checklist's boundary-5 row cites that task, and
+`page_test.rb` scans the fifteen files (`page.rb` and the fourteen under `page/`) for the two bare tokens 7b's scan refuses, comments included,
+until that row flips to `GUARDED`. Every interpreter in the matrix was installed and every fact below
+was re-run on 3.2.11, 3.3.12, 3.4.10 and 4.0.6; the plan's "install ruby@3.2.11 and ruby@4.0.6" was
+stale. Execution added the rows below, numbered from **P7-101** as the manager fixed it — `7a`'s
+as-built rows start at P7-61 and `7b`'s at P7-81, and P7-1–P7-6 above collide with `7a`'s P7-1–P7-9,
+numbered in isolation; phase 10's consolidation into design §10 resolves the collision, and outside
+this document another lane's row is cited as "7a's P7-n". The checklist's "Deviations from the plan"
+is the itemised list against the plan's text; the rows here are the ones that touch public behaviour,
+the contract a later phase cites, or a statement this document makes.
+
+Five statements above read differently against the source, and the difference is recorded here rather
+than by rewriting the text it corrects:
+
+- **`R8`'s `$!`-reading `ensure` is not what shipped, and must not be.** The measurement under `R8` is
+ right — a bare `ensure` inverts the primary — but its prescription, `primary = $!` at the top of the
+ `ensure`, is wrong in the other direction: `$!` is dynamically scoped to the thread and is non-nil
+ inside anything called from a **caller's** `rescue` (`pipeline/7ce4431d`'s own measurement, re-run
+ here), so a walk driven from inside a consumer's rescue block would attach its close failure to an
+ unrelated in-flight error and swallow it. The shape built is 6b's: the frame that owns the walk
+ records the primary in a `rescue ::Exception => error` arm that re-raises unchanged, and its `ensure`
+ hands that local to `Closing.close_walk`. `Walk#hold`'s advance close is a bare close for the same
+ reason. Neither `$!` nor a bare `ensure` appears anywhere under `lib/dexpace/page/`, and the
+ lifetime suite scans for both and drives a walk from inside a caller's rescue (P7-105).
+- **`Info#items` is not `Model.own`'d.** `Model.own` is `Ractor.make_shareable(copy: true)`: it
+ deep-copies and freezes every element and raises on a Proc or a Mutex. The collection is owned — a
+ shallow `dup.freeze` — and the elements stay the caller's domain objects, on both `Info` and `Page`
+ (P7-101).
+- **The private constants carry `sig/` mirrors.** "`::Walk`, `::Closing` and `::LinkHeader` are
+ `private_constant` and carry no `sig/`" fails `test/gates/gem_layout_test.rb`, which maps `lib/` to
+ `sig/` one for one; the tree's rule since 4a is a mirror with `hooks.rbs`'s comment and no manifest
+ row. And the module layout's `sig/dexpace/page/strategy.rbs` cannot exist for the same reason: the
+ three interfaces live in `sig/dexpace/page.rbs`, nested in `class Page`, the way `io.rbs` holds
+ `_Source` and `http/body.rbs` holds `_ResponseHandler` (P7-106).
+- **`Pages#each` raises `Page::PageStateError`, not `Dexpace::InvalidArgumentError`.** The roadmap's
+ phase-10 inbound bullet of 2026-09-13 already calls the argument family wrong for a state violation
+ and names this sub-phase's Task 12 as the fix point (P7-103).
+- **The `Walk` is generic over a per-walk drive** rather than holding `@paginator` and
+ `@next_request` itself, so the fetcher front-end reuses it unchanged (P7-111); and
+ `Closing.parse_or_close` closes the response on a fatal-family raise too, quietly and with no trail,
+ where this document said `rescue ::Exception` "is not written" (P7-110).
+
+Things this document did not say and the build decided, each a row below: `Cancellation.none` on the
+blocking engine (P7-102), the dispatchability screen in `next_request_from` (P7-104), the strict
+shapes `Page.build`, the extractors, a strategy's answer and a fetcher's answer are held to (P7-107),
+the gate not built here (P7-108), what the walk future settles with (P7-109), the executor running the
+first dispatch too (P7-112), the single-use latch claimed on obtaining the enumerator (P7-113), the
+page-number screen's alphabet (P7-114), the link grammar's malformed-input rule (P7-115), from
+review round 0, its first-`rel`-only rule (P7-116) and, from review round 1, the fragment-only
+same-document form joining P7-5's empty one (P7-117).
+
+| # | Deviation | Touches | Why |
+|---|---|---|---|
+| P7-101 | `Page#items` and `Info#items` are the caller's Array copied **shallowly** and frozen (`items.dup.freeze`), never `Model.own`'d; the elements are not copied and not frozen | `PAGE-2`, `PAGE-4`; `HTTP-5`, `XCUT-15`; the plan's Task 4 ("`Model.own` freezing the list") | Measured on every row: `Model.own([proc {}])` raises `TypeError`, `own([Mutex.new])` raises `Ractor::Error`, and `own([Object.new])` returns a copy whose element is not `equal?` to the caller's and is frozen. The model owns its collection; a page's items are the caller's domain objects |
+| P7-102 | The blocking engine takes **no per-walk cancellation** in v1: `Paginator` and `Fetchers` have no `cancellation:` member or keyword, and the transport receives `Dexpace::Cancellation.none` on every exchange — never `nil` | `PAGE-36`; `SEAM-11`'s third positional; the plan's Task 10 fence (`nil`) | Measured: `Pipeline.standard(transport).call(request, EMPTY, nil)` dies with `NoMethodError` inside 6a's `RetryStep` (`cursor.cancellation.check!`), and nothing validates the token before the pillar. The design's object model has no member for it; the constant is the minimum that works against a standard pipeline, and a `cancellation:` keyword is a pure widening phase 8 or 10 can add without moving a signature. The manager's decision of 2026-09-20 |
+| P7-103 | `PAGE-14`'s second `#each` raises `Dexpace::Page::PageStateError < ::StandardError` (`include Dexpace::Error`), in `lib/dexpace/page/page_state_error.rb`, and never `Dexpace::InvalidArgumentError`; `Walk#buffer`'s refusal of a second staged page raises the same class | `PAGE-14`; `SEAM-29`; the roadmap's phase-10 inbound bullet of 2026-09-13 ("`PAGE-14` and `SSE-26`/`SSE-40` guard the identical single-use latch with two different error families") | A second iteration is a state violation with no argument involved; `InvalidArgumentError < ::ArgumentError` would let a caller's `rescue ArgumentError` around a page loop catch it. The bullet's first half — one family across both subsystems — is closed here; its second half, one shared core supertype both subclass, is a pure widening (a superclass added narrows no signature) and stays on phase 10's list |
+| P7-104 | `Page.next_request_from` screens the resolved target for an `http`/`https` scheme **and** a non-empty host, and answers `nil` — end-of-stream — for `mailto:`, `javascript:`, `ftp:`, a host-less `http:foo` and an empty-host `http:///p`; 6b's `Location#dispatchable?` copied, never called (a `private_constant`). No diagnostic is emitted: the engine has no `logger:` | `PAGE-19`; `REDIR-18`'s third trigger (6b's P6-95) | Every one of those is a SUCCESSFUL `URI::RFC3986_PARSER.join` (verified on every row), so without the screen each becomes a `Request` the transport then fails on — an error where `PAGE-19` wants an end. Consistent with `REDIR-18`, which the manager's decision of 2026-09-20 fixed |
+| P7-105 | The `R8` close discipline is built **without `$!`**: `Items#each` and `Pages#each` record the primary in a `rescue ::Exception => error` arm that re-raises unchanged and hand the local to `Closing.close_walk(walk, primary)` in their `ensure`; `Walk#hold`'s advance close is bare; `$!` appears nowhere under `lib/dexpace/page/` | `PAGE-13`, `PAGE-15`, `PAGE-32`; `R8`; `pipeline/7ce4431d` | `$!` is dynamically scoped and non-nil inside a method called from a caller's `rescue`; `R8`'s `primary = $!` would attach a walk's close failure to an unrelated in-flight error and swallow what `PAGE-15` says to surface. Measured on every row; `lifetime_test.rb` drives a walk from inside a caller's rescue and scans the source |
+| P7-106 | Every `private_constant` has a `sig/` mirror with `hooks.rbs`'s comment (`walk.rbs`, `closing.rbs`, `link_header.rbs`; `Paginator::Drive`, `Fetchers::Drive` and `AsyncPaginator::Pump` declared inside their owners' mirrors) and no manifest row; the three interfaces `_Strategy`, `_Extractor` and `_Executor` live in `sig/dexpace/page.rbs`, nested in `class Page`; there is no `sig/dexpace/page/strategy.rbs` | `NFR-3`, `NFR-4`; P7-2's "carry no `sig/`"; the module layout | `test/gates/gem_layout_test.rb` maps `lib/**/*.rb` to `sig/**/*.rbs` one for one, so a `sig/`-only file and a `lib/` file without a mirror both fail `test:gates`; the strict `core` Steep target types every call site of a private constant. `_Executor`'s name and `def post: () { () -> void } -> void` are as `bridge/async_over.rb:61`, `docs/deviations.md:63` and 8b's plan Task 9 already cite; only the file differs, and 8b's plan is told |
+| P7-107 | Shapes checked at the boundary and named: `Page.build` refuses anything but a `Dexpace::Response` and an `Array`, and a `next_link` or `continuation_token` that is neither nil nor a String (the rule `Info` applies to the same two members; `Page`'s half arrived in review round 1, R0-1); `CursorStrategy` refuses an extractor answer that is not `[Array, String?]` (a 2-element Array, items an Array, cursor a String or nil); `PageNumberStrategy` and `LinkStrategy` refuse a non-Array items answer; `Closing.parse_or_close` refuses a `#parse` answer that is not an `Info`; `Fetchers` refuses a fetcher answer that is neither a `Page` nor nil — every one `Dexpace::InvalidArgumentError`, none read as end-of-stream | `PAGE-4`, `PAGE-16`, `PAGE-34`; `HTTP-4`; the construction pattern | `items, cursor = "oops"` destructures silently into a bare String and a nil, which the strategy would read as a terminal page; a wrong shape is the caller's mistake and is named at the strategy, not discovered as a truncated stream |
+| P7-108 | Spec-forced boundary 5's audit is **not built here**: no `gates:serde_isolation`, no `tools/` file, no fixture, no `tasks/gates.rake` edit. `R11`'s "if `7c` lands first" branch is superseded by the manager's decision of 2026-09-20: 7b's Task 11 builds `tools/serde_boundary.rb` with a `PENDING` row for `page/**` that whichever lane rebases second flips to `GUARDED`. Until then `page_test.rb` scans the fifteen files for the tokens `Serde` and `JSON`, comments included | `R11`; spec-forced boundary 5; the plan's Task 1 Steps 2–4 and its open question 5 | Both lanes' "does it exist?" branches answer ABSENT on today's `main`, so both would have built one, under two names, in two places; the tree's rule — a gate body lives in `tools/`, tested from `test/gates/` — is 7b's shape. 7b's scan does not strip comments, which is why `R7`'s own sentence ("a caller who wants JSON …") is spelled "a caller who wants a codec" in every YARD block under `page/` |
+| P7-109 | `AsyncPaginator#walk` and `#walk_pages` settle their future with the **number of pages delivered**, an Integer, never nil; a transport-side cancellation is forwarded as a cancellation carrying its reason (`Completer#request_cancel`), a transport failure as the same error object | `PAGE-25`, `PAGE-28`; `SEAM-16`; 5a's P5-52 | `Settlement` carries exactly one of response or error, so a nil success cannot be constructed; `Async.delay` settles with `true` for the same reason, and a count costs nothing more and says something. The cancellation rule is `Future#then`'s |
+| P7-110 | `Closing.parse_or_close` rescues the fatal family too: a non-`StandardError` out of `#parse` closes the response quietly, attaches nothing, and propagates unchanged | `PAGE-13`; `RECOV-2`; this document's "`rescue ::Exception` is **not** written" | 6a's `guarded` and 6b's `closing_on_error` both close on the fatal family, and `RECOV-2`'s split is about the trail, not the close: "surfaced unchanged with no trail attached" is exactly what a quiet close followed by a bare `raise` does |
+| P7-111 | `Walk.new(drive, cap:)` is generic over a per-walk **drive** (`#call -> Page | nil`); the strategy engine's drive is the private `Paginator::Drive` (holding the next request) and the fetcher front-end's the private `Fetchers::Drive` (holding the previous page's key); `Items.new(opener)` and `Pages.new(opener)` take the engine's walk factory. The `Walk` owns the cap, the exhaustion latch, the two slots and the release discipline | `PAGE-7`, `PAGE-9`, `PAGE-12`, `PAGE-34`; this document's `Walk` sketch (`@paginator`, `@next_request`) | The design's `Fetchers` promises "a `Walk` whose drive routine calls the two fetchers instead of a transport and a strategy"; a walk holding a paginator cannot do that, and two walks would duplicate the one lifetime discipline this sub-phase exists to write once |
+| P7-112 | With an `executor:`, the async engine posts the **first dispatch** too (`submit { resume }`), not only each settlement's continuation: the whole driver runs on the executor, a queued executor fetches nothing until it runs, and a rejecting first `#post` fails the walk before any exchange | `PAGE-29`, `PAGE-30`, `PAGE-6` | "A mode running the driver — and therefore every consumer invocation — on a caller-supplied executor": the first dispatch is the driver's too, and `PAGE-30`'s "(re-)dispatch" names the first. Inline, "begins fetching immediately" holds as written |
+| P7-113 | `Pages#each` without a block claims the single-use latch on **obtaining** the enumerator (`to_enum` over a private drive), so `view.each` followed by `view.each { }` raises; the plan's `Timeout_free_value` helper is `Future#value(deadline:)`, which settles a hung future as `:deadline_expired` instead of hanging the suite | `PAGE-14`, `PAGE-32`; 5a's `deadline:` keyword | "Its iterator/stream may be obtained at most once" — obtaining is the use; a `to_enum(:each)` that re-entered the latching method would have raised on the first pull instead. `Timeout_free_value` is a naming offence and a second wait |
+| P7-114 | `PageNumberStrategy`'s numeric screen is `\A[0-9]+\z` with its own `timeout: 1.0`, a `private_constant`; a value with any non-ASCII digit, a sign, a fraction or a space falls back to `start`, and a percent-encoded digit run is read decoded and counts as numeric | `PAGE-17`, `PAGE-22`; design §4's per-pattern timeout | `\d` can admit non-ASCII decimal digits under Unicode matching; the requirement's "garbage" is anything that is not a page number this SDK would itself write |
+| P7-115 | `LinkHeader`'s malformed-input rule: a link-value that does not open with `<` or never closes its `>` stops the scan and yields what was scanned so far; an unterminated quoted value or a trailing escape drops that parameter; parameter names fold (`REL=next` is `rel=next`); a blank `<>` target comes back as `""` for `P7-5` to end | `PAGE-18`, `PAGE-20` | The grammar never raises — `PAGE-18`'s "absence of a rel=next segment" is the reading of anything it cannot parse — and RFC 8288 §3 makes parameter names case-insensitive |
+| P7-116 | `LinkHeader` reads only the **first** `rel` parameter of a link-value: `; rel="prev"; rel="next"` is a prev link and not a next one, and a second `rel` neither adds nor removes a relation (review round 0's R0-3; the first build's `params.any?` honoured a later `rel`) | `PAGE-18` | RFC 8288 §3.3: "The rel parameter MUST NOT appear more than once in a given link-value; occurrences after the first MUST be ignored by parsers." `PAGE-18`'s text does not decide the case; the RFC does, and the liberal reading could follow a link the server marked `prev` |
+| P7-117 | **P7-5 widened to the fragment-only form**: a `rel=next` target that is empty OR fragment-only (`<#>`, `<#frag>`), surrounding whitespace aside, is end-of-stream **before resolution is attempted**, read off the raw target by a private `Page.same_document?`. The screen is **syntactic**, never on the resolved URL: `>` (which empties the query), `/>` (which uri resolves to the base itself, measured on every row and under uri 0.12.5) and the current URL spelled out are next requests like any other, bounded by `PAGE-9`'s cap, and the suites pin that they are followed (review round 1's R1-2) | `PAGE-19`, `PAGE-18`; P7-5; `PAGE-9`, `PAGE-10` | Measured on every row: `join(base, "#frag")` succeeds and answers the base plus a fragment the wire never carries, so the first build re-fetched the current page until the cap, which `PAGE-10` defaults to unbounded. RFC 3986 §4.4 names the empty and the fragment-only reference together as the same-document forms whose dereference "should not result in a new retrieval action" — P7-5's own reasoning, applied to the second form. A screen on the resolved URL was considered and refused: it would silently end a walk against an endpoint that legitimately advances server-side state under one URL, with no knob to turn it off, where the cap is the caller's and documented; `PAGE-9` assigns a server that never advances to the cap, and the two spellings a browser distinguishes (a fragment-only link navigates nowhere; a spelled-out link to the current page reloads it) are the two this screen distinguishes |
+
---
## Work phase 7c postpones, and who owns it now
diff --git a/docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter.md b/docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter.md
index 0fb5e22..90eb070 100644
--- a/docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter.md
+++ b/docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter.md
@@ -2537,6 +2537,14 @@ superset."
**Files:**
- Test: `gems/dexpace-async-thread/test/dexpace/async/thread/page_executor_test.rb`
+*Correction routed here by phase 7c on 2026-09-20 (7c's checklist, Findings routed; its ledger row
+P7-106):* as built, `Dexpace::Page::_Executor` is declared in `gems/dexpace-core/sig/dexpace/page.rbs`,
+nested in `class Page` beside `_Strategy` and `_Extractor`, and no `sig/dexpace/page/strategy.rbs`
+exists — `test/gates/gem_layout_test.rb` forbids a `sig/`-only file. The interface's name and its
+signature, `def post: () { () -> void } -> void`, are exactly as this task states; only the path in
+the header comment below is stale. The real executor mode also posts the FIRST dispatch through the
+executor (7c's P7-112), so a pool that never runs its queue fetches nothing.
+
- [ ] **Step 1: Write the test**
```ruby
diff --git a/gems/dexpace-core/README.md b/gems/dexpace-core/README.md
index ddfcb84..53df5d7 100644
--- a/gems/dexpace-core/README.md
+++ b/gems/dexpace-core/README.md
@@ -80,7 +80,12 @@ and the server-sent-events layer under `Dexpace::SSE` (phase 7b): the three limi
`LineReader`, the immutable `Event`, the field machine `Reader`, the resource-owning single-pass
`Stream` (`.open`, `.owning`, `.borrowing`; `#each`, `#events`, `#typed`), the typed adapter
`TypedStream`, the two namespaced errors `LimitExceededError` and `StreamStateError`, the RBS interface
-`_ByteSource`, and the eighteenth gate, `gates:serde_boundary`.
+`_ByteSource`, and the eighteenth gate, `gates:serde_boundary`, which guards the pagination layer
+too; and the pagination layer under `Dexpace::Page` (phase 7c): the page value that is also the
+namespace, `Info`, `QueryRewriter`, the three strategies `CursorStrategy`, `PageNumberStrategy`
+and `LinkStrategy` over a caller-supplied extractor, the views `Items` and `Pages`, the engines
+`Paginator` and `AsyncPaginator`, the front-end `Fetchers`, `PageStateError`, and `URL.resolve`
+beside phase 1's `URL.parse!`.
Nothing else yet: every adapter is a later phase's, the three retry drivers are the only emitters
of the HTTP-tracer vocabulary (its per-attempt group), and no transport ships here, so nothing
talks to a socket.
@@ -90,8 +95,8 @@ The as-built pages are
`docs/sdk-documentation/recovery.md`, `docs/sdk-documentation/pipelines.md`,
`docs/sdk-documentation/configuration.md`, `docs/sdk-documentation/tracing-and-metrics.md`,
`docs/sdk-documentation/logging-and-redaction.md`, `docs/sdk-documentation/retry.md`,
-`docs/sdk-documentation/auth.md`, `docs/sdk-documentation/redirect.md` and
-`docs/sdk-documentation/sse.md`.
+`docs/sdk-documentation/auth.md`, `docs/sdk-documentation/redirect.md`,
+`docs/sdk-documentation/sse.md` and `docs/sdk-documentation/pagination.md`.
## Install
@@ -222,6 +227,10 @@ it stays that way (`SEAM-1`, `NFR-1`).
sentinels, the line machine, the event value, the field machine, the facade's three factories, two
shapes and four termination paths, the typed adapter's three outcomes, and what is deliberately
not there.
+- `docs/sdk-documentation/pagination.md` -- the pagination layer as built: the page value and its
+ latch, the strategy contract and the three built-ins over an extractor, the query splice and
+ `URL.resolve`, the two views over one walk and their close discipline, the blocking engine, the
+ async trampoline with its executor mode and cancellation race, and the fetcher front-end.
- `docs/sdk-documentation/architecture.md` -- how the gems compose and which one to install.
- `docs/sdk-design-ruby/02-gem-and-workspace-layout.md` -- the gem layout and the
zero-dependency invariant every gem here is built under.
diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb
index 29f873f..8bd43f5 100644
--- a/gems/dexpace-core/lib/dexpace.rb
+++ b/gems/dexpace-core/lib/dexpace.rb
@@ -284,6 +284,29 @@
require_relative "dexpace/sse/stream"
require_relative "dexpace/sse/typed_stream"
+# Phase 7c: the pagination layer, in dependency order -- after 6b's block, though it needs only
+# phase 2's closeable.rb, phase 1's model and the async pivot. The page value first, because it is
+# also the namespace every later file reopens; then the strategy vocabulary (the info value, the
+# query splice, the private link-header grammar, the three built-in strategies), the private
+# lifetime owner and the private close disciplines both views share, the two views, the two
+# engines, and the fetcher front-end last. `uri` is required by url.rb, which page.rb requires; it
+# was on the allowlist before phase 1, and nothing under page/ names a serializer.
+require_relative "dexpace/page"
+require_relative "dexpace/page/page_state_error"
+require_relative "dexpace/page/info"
+require_relative "dexpace/page/query_rewriter"
+require_relative "dexpace/page/link_header"
+require_relative "dexpace/page/cursor_strategy"
+require_relative "dexpace/page/page_number_strategy"
+require_relative "dexpace/page/link_strategy"
+require_relative "dexpace/page/walk"
+require_relative "dexpace/page/closing"
+require_relative "dexpace/page/items"
+require_relative "dexpace/page/pages"
+require_relative "dexpace/page/paginator"
+require_relative "dexpace/page/async_paginator"
+require_relative "dexpace/page/fetchers"
+
# The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client.
#
# This file issues explicit `require_relative`s for the whole tree rather than using an
diff --git a/gems/dexpace-core/lib/dexpace/http/url.rb b/gems/dexpace-core/lib/dexpace/http/url.rb
index 0347161..e0ceb91 100644
--- a/gems/dexpace-core/lib/dexpace/http/url.rb
+++ b/gems/dexpace-core/lib/dexpace/http/url.rb
@@ -54,6 +54,43 @@ def external_form(uri)
uri.to_s
end
+ # PAGE-19: RFC 3986 reference resolution of `reference` against `base`, or nil when the
+ # reference cannot resolve into a URI at all -- PAGE-19 treats that as end-of-stream and not
+ # as an error, and phase 7c's Dexpace::Page.next_request_from is the caller that turns the nil
+ # into one. The third function of this module, added by phase 7c (P7-3) beside .parse! so
+ # the parser pin lives in one file: .parse! cannot resolve, because it rejects the relative
+ # reference every `; rel=next` is (HTTP-47), and URI.join is what the
+ # Dexpace/NoUriDefaultParser cop refuses (url-and-query-encoding/08c54234). This is reference
+ # resolution and never SEAM-27's base-URL composition, which Dexpace::Operation does by hand
+ # because the two disagree on a query-bearing base (P2-3).
+ #
+ # Two things it deliberately does not do. It does not screen the result for a scheme this
+ # client can dispatch: `join` hands back a `mailto:`, a host-less `http:foo` and an empty-host
+ # `http:///p` as SUCCESSFUL resolutions (6b's P6-95), and whether such a target is an
+ # end-of-stream or an error is the caller's decision, made once in Dexpace::Page. And it does
+ # not accept a nil reference as "nothing to resolve": `join(base, nil)` raises ArgumentError,
+ # not URI::InvalidURIError, so a nil here is a programming error named as such rather than a
+ # silent end-of-stream.
+ #
+ # @param base [URI::Generic, String] the originating page's absolute URL
+ # @param reference [String] the raw target, absolute or relative
+ # @return [URI::Generic, nil] the resolved URI, its components frozen; nil when malformed
+ # @raise [Dexpace::InvalidArgumentError] when either argument is missing or of the wrong type
+ def resolve(base, reference)
+ Model.required!("base", base)
+ unless base.is_a?(String) || base.is_a?(::URI::Generic)
+ raise InvalidArgumentError, "base must be a String or a URI, got #{base.class}"
+ end
+ unless Model.required!("reference", reference).is_a?(String)
+ raise InvalidArgumentError, "reference must be a String, got #{reference.class}"
+ end
+
+ parser = ::URI::RFC3986_PARSER #: untyped
+ own(parser.join(base.to_s, reference)) #: URI::Generic
+ rescue ::URI::InvalidURIError
+ nil
+ end
+
private
# Freezes the URI's String components, then the URI. Only Strings: a URI also references
diff --git a/gems/dexpace-core/lib/dexpace/page.rb b/gems/dexpace-core/lib/dexpace/page.rb
new file mode 100644
index 0000000..8a9a89c
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "closeable"
+require_relative "model"
+require_relative "error/invalid_argument_error"
+require_relative "http/request"
+require_relative "http/response"
+require_relative "http/url"
+
+module Dexpace
+ # One page of a paginated walk (PAGE-2, PAGE-3), and the namespace of the whole pagination
+ # subsystem -- Info, the three built-in strategies, the two views, the two engines and the
+ # fetcher front-end all nest here (P7-2). Design §7.1 names exactly one identifier for the
+ # subsystem, `Dexpace::Page`, and appendix A calls the value "a Page"; nesting the rest inside
+ # it keeps `lib/dexpace/page/**` as one audited directory. None of the nested names shadows a
+ # Ruby core constant or a `Dexpace::` one (the smoke suite pins the list).
+ #
+ # A page owns exactly one live Dexpace::Response, and whoever holds the page owns closing it:
+ # the component that fetched the response hands ownership over the moment it builds the page
+ # (PAGE-3). The materialized items and the derived status, headers and originating request
+ # stay readable after #close, because they are read off a frozen Data that closing does not
+ # touch; only the body is invalidated (PAGE-2).
+ #
+ # A plain class including Dexpace::Closeable, deliberately NOT a Data. A Data instance is
+ # frozen and cannot hold the latch (phase 3b's finding, applied one layer up), and the latch
+ # cannot be borrowed from the response either: Response#close is a pure forward to the body,
+ # and the body slot may hold nil, a BufferBody whose #close is a documented no-op, or -- only
+ # in the transport case -- a latching ResponseBody. So PAGE-27's exactly-once close, on every
+ # path that consumes a page, is a property of the page itself: the latch is here, #release
+ # forwards to the response once, and a second #close returns without touching it.
+ class Page
+ include Dexpace::Closeable
+
+ private_class_method :new
+
+ # The validating factory every page goes through: a real Dexpace::Response (checked the way
+ # Response#initialize checks its request), an Array of items, copied shallowly and frozen
+ # -- the collection is the model's, its elements stay the caller's (HTTP-5, XCUT-15; a deep
+ # copy through Model.own would replace and freeze domain objects and raise on a Proc, P7-101)
+ # -- and the two PAGE-34 keys, each nil or a String copied frozen, exactly as Info holds them:
+ # a key of another class is named and refused here rather than discovered by the fetcher
+ # front-end as a NoMethodError when it keys the next page off it (P7-107).
+ #
+ # @param response [Dexpace::Response] the live response this page owns from now on
+ # @param items [Array] the materialized items, possibly empty, never nil (PAGE-2)
+ # @param next_link [String, nil] PAGE-34's next link, when the source carried one
+ # @param continuation_token [String, nil] PAGE-34's fallback key
+ # @return [Dexpace::Page]
+ # @raise [Dexpace::InvalidArgumentError] on a missing response, a non-Array items, or a link
+ # or token that is neither nil nor a String
+ def self.build(response:, items:, next_link: nil, continuation_token: nil)
+ unless Model.required!("response", response).is_a?(Dexpace::Response)
+ raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}"
+ end
+ unless Model.required!("items", items).is_a?(::Array)
+ raise InvalidArgumentError, "items must be an Array, got #{items.class}"
+ end
+
+ new(response, items, next_link, continuation_token)
+ end
+
+ # PAGE-19's whole answer, shipped once: the next request for a rel=next target -- or any
+ # URL a strategy found in a header, a body or elsewhere -- resolved against the originating
+ # page's response URL, or nil for end-of-stream. Public rather than private to LinkStrategy
+ # because a next-page URL in the response BODY is the one shape a generated client commonly
+ # needs that none of the three built-ins covers, and a caller-written #parse that re-derives
+ # this branch by hand gets the two end-of-stream rules quietly wrong (P7-2).
+ #
+ # Four routes to nil, in order. A nil target, or a same-document reference -- RFC 3986 §4.4's
+ # two named forms, the empty reference and the fragment-only one, `<>` and `<#…>`, surrounding
+ # whitespace aside -- is end-of-stream BEFORE resolution is attempted: `URI::RFC3986_PARSER
+ # .join(base, "")` succeeds and answers the base itself, `join(base, "#x")` the base plus a
+ # fragment the wire never carries, so either `rel=next` would otherwise re-fetch the current
+ # page until the page cap (P7-5, widened to the fragment form by P7-117). A target that cannot
+ # resolve at all is end-of-stream, never an error (PAGE-19's own text). A target that resolves
+ # to something this client cannot dispatch -- no http or https scheme, or no host: `mailto:`,
+ # `javascript:`, `http:foo`, `http:///p`, every one a SUCCESSFUL join -- is end-of-stream too,
+ # consistent with REDIR-18's screen in the redirect step (P7-104). Otherwise
+ # `template.with(url:)`, which is PAGE-23's "swap only the URL, preserving the template's
+ # method, headers and body": Model#with routes through Request.build, so the other three
+ # members travel unchanged and HTTP-7 is re-validated. What is deliberately NOT screened is
+ # the URL a target resolves to: `>`, which empties the query, `/>`, which uri resolves to
+ # the base itself, or the current URL spelled out are next requests like any other, and a
+ # server that loops through one is bounded by PAGE-9's cap -- a screen on the resolved URL
+ # would silently end a walk against an endpoint that advances server-side state under one
+ # URL, with no knob to turn it off.
+ #
+ # @param template [Dexpace::Request] the original request template
+ # @param response [Dexpace::Response] the page whose URL is the resolution base
+ # @param target [String, nil] the raw target, absolute or relative
+ # @return [Dexpace::Request, nil] nil is the end-of-stream signal (PAGE-4)
+ def self.next_request_from(template, response, target)
+ return nil if target.nil? || same_document?(target)
+
+ resolved = Dexpace::URL.resolve(response.request.url, target)
+ return nil if resolved.nil? || !dispatchable?(resolved)
+
+ template.with(url: resolved)
+ end
+
+ # The schemes this client dispatches; the same two the redirect step screens for.
+ DISPATCHABLE_SCHEMES = %w[http https].freeze
+ private_constant :DISPATCHABLE_SCHEMES
+
+ # RFC 3986 §4.4's two named same-document forms, read off the raw target before any
+ # resolution: empty (P7-5) or fragment-only (P7-117), surrounding whitespace ignored. Syntactic
+ # on purpose -- a browser reloads on a spelled-out link to the current page and only an empty
+ # or fragment-only one navigates nowhere -- so this reads the reference, never its resolution.
+ def self.same_document?(target)
+ stripped = target.strip
+ stripped.empty? || stripped.start_with?("#")
+ end
+ private_class_method :same_document?
+
+ # 6b's screen, copied rather than called: Redirect::Location is a private_constant.
+ def self.dispatchable?(target)
+ scheme = target.scheme
+ host = target.host
+ !scheme.nil? && DISPATCHABLE_SCHEMES.include?(scheme.downcase) && !host.nil? && !host.empty?
+ end
+ private_class_method :dispatchable?
+
+ def initialize(response, items, next_link, continuation_token)
+ @response = response
+ @items = items.dup.freeze
+ @next_link = optional_string("next_link", next_link)
+ @continuation_token = optional_string("continuation_token", continuation_token)
+ initialize_closeable(owned: true)
+ end
+
+ # @return [Dexpace::Response] the live response; its body is invalid once the page is closed
+ attr_reader :response
+ # @return [Array] the frozen materialized items, readable after close (PAGE-2)
+ attr_reader :items
+ # @return [String, nil] PAGE-34's next link, as the source supplied it
+ attr_reader :next_link
+ # @return [String, nil] PAGE-34's continuation token, the fallback key
+ attr_reader :continuation_token
+
+ # @return [Dexpace::Status] the response's status, readable after close (PAGE-2)
+ def status = @response.status
+
+ # @return [Dexpace::Headers] the response's headers, readable after close (PAGE-2)
+ def headers = @response.headers
+
+ # @return [Dexpace::Request] the originating (executed) request, readable after close (PAGE-2)
+ def request = @response.request
+
+ private
+
+ # nil, or a frozen copy of the String (XCUT-15); anything else is named and refused -- the same
+ # rule Info applies to the same two members, so a key travels from an Info to a Page unchanged.
+ def optional_string(name, value)
+ return nil if value.nil?
+ return Model.frozen_string(value) if value.is_a?(::String)
+
+ raise InvalidArgumentError, "#{name} must be a String or nil, got #{value.class}"
+ end
+
+ # Closeable's one obligation: forward to the response exactly once. A raising close leaves the
+ # latch flipped, so no second release is attempted and the failure propagates once (PAGE-3).
+ def release
+ @response.close
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/async_paginator.rb b/gems/dexpace-core/lib/dexpace/page/async_paginator.rb
new file mode 100644
index 0000000..f71bb04
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/async_paginator.rb
@@ -0,0 +1,372 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "../cancellation"
+require_relative "../closeable"
+require_relative "../async/completer"
+require_relative "../async/future"
+require_relative "../error/cancelled_error"
+require_relative "../error/invalid_argument_error"
+require_relative "../error/seam_error"
+require_relative "../http/request"
+require_relative "../http/request_options"
+require_relative "closing"
+
+module Dexpace
+ class Page
+ # The non-blocking pagination engine (PAGE-25 through PAGE-33): the same frozen configuration
+ # as Paginator plus an optional executor, driving fetch, parse, delivery and re-arm inside the
+ # async completion graph through phase 2's Future#on_settle, with no thread blocking on a page.
+ # Two walk methods over one pump: #walk delivers items serially, #walk_pages delivers whole
+ # live pages -- PAGE-1's two views are required of the engine, not of the blocking one, and
+ # PAGE-27 names the page-level drain in as many words.
+ #
+ # R9, as built. This engine calls no wait of any kind -- not Clock#sleep, not Async.delay --
+ # because no async PAGE requirement computes or requests a delay, so phase 5a's no-scheduler
+ # SeamError is unreachable from here. With no executor the driver is a callback pump: whoever
+ # settles the transport's future drives the next page, on that thread or fiber, which is
+ # PAGE-29's stated default; no thread is created, no pool held, no Fiber.scheduler consulted.
+ # With an executor -- a caller-supplied object answering `#post { ... }`, the one-method duck
+ # type, phase 8's dexpace-async-thread being the first real one -- the whole driver runs on it:
+ # the first dispatch and every settlement's continuation are posted, so every consumer
+ # invocation happens on the executor and a blocking consumer never ties up a transport
+ # callback thread (PAGE-29). A rejecting #post fails the walk with the rejection and closes the
+ # response it was carrying (PAGE-30); a rejecting first post fails the walk before any exchange.
+ #
+ # PAGE-31: the pump is a re-arm-flag trampoline in 6a's AsyncRetryStep image, never a callback
+ # that calls the next page: Future#on_settle runs its block INLINE on an already-settled future,
+ # so every scripted transport and every synchronously-answering adapter settles inline, and a
+ # pump that re-entered itself from the callback overflowed at ~2,600 pages through the real
+ # Completer on every interpreter. A settlement that arrives while the loop is running flips a
+ # flag under the pump's mutex -- held across the flip only, never across a dispatch, a parse, a
+ # consumer call or a close -- and the running loop picks the next page up; one that arrives
+ # later, on another thread, starts the loop afresh on a fresh stack. Every callback body is
+ # total: a StandardError fails the walk's future, a fatal-family error fails it and propagates,
+ # because a raising on_settle block would otherwise escape onto the settling thread through
+ # Hooks.notify's re-raise.
+ #
+ # The returned future settles with the number of pages delivered -- an Integer, never nil,
+ # because a Settlement carries exactly one of response or error and a nil success cannot be
+ # constructed (SEAM-16; P7-109) -- or fails with the ORIGINAL cause of a consumer throw, a
+ # transport failure, a parse failure or an eager transport raise, unwrapped (PAGE-28); a
+ # cancellation from either side settles it as a cancellation.
+ class AsyncPaginator < ::Data.define(:transport, :template, :strategy, :cap, :options,
+ :executor,)
+ include Model
+
+ private_class_method :new
+
+ # @param transport [#call] `#call(request, options, cancellation) -> Dexpace::Async::Future`
+ # @param template [Dexpace::Request] the first page's request
+ # @param strategy [_Strategy] a stateless parser answering `#parse(response, template)`
+ # @param cap [Numeric] PAGE-9's maximum number of exchanges, strictly positive
+ # @param options [Dexpace::RequestOptions] PAGE-36's per-call overrides, applied to every page
+ # @param executor [_Executor, nil] PAGE-29's executor mode; nil is the inline default
+ # @return [Dexpace::Page::AsyncPaginator] frozen
+ # @raise [Dexpace::InvalidArgumentError] when a member is missing or of the wrong shape
+ def self.build(transport:, template:, strategy:, cap: ::Float::INFINITY,
+ options: Dexpace::RequestOptions::EMPTY, executor: nil)
+ new(transport: transport, template: template, strategy: strategy, cap: cap,
+ options: options, executor: executor,)
+ end
+
+ def initialize(transport:, template:, strategy:, cap:, options:, executor:)
+ validate_engine!(transport, template, strategy)
+ unless cap.is_a?(::Numeric) && cap.positive?
+ raise InvalidArgumentError,
+ "cap must be a strictly positive number, got #{cap.inspect} (PAGE-9)"
+ end
+ unless Model.required!("options", options).is_a?(Dexpace::RequestOptions)
+ raise InvalidArgumentError,
+ "options must be a Dexpace::RequestOptions, got #{options.class}"
+ end
+ unless executor.nil? || executor.respond_to?(:post)
+ raise InvalidArgumentError,
+ "executor must be nil or respond to #post, got #{executor.class}"
+ end
+
+ super
+ end
+
+ # The item-level walk: fetches immediately (PAGE-6's carve-out for the non-blocking engine)
+ # and delivers every item, one at a time and in server order, to `consumer.call(item)`, never
+ # concurrently (PAGE-29). Each page is closed after its items are drained, whether the
+ # consumer returned or raised; a throwing close on the success path fails the future, and one
+ # after a consumer failure is swallowed so the consumer's cause stays primary (PAGE-32).
+ #
+ # Cancelling the returned future halts the walk at the next page boundary and cancels the
+ # in-flight transport future (PAGE-25, PAGE-26): items already being delivered from the
+ # settling page still reach the consumer; a page fetched and parsed but not yet drained is
+ # closed quietly and dropped. **The inherent cancellation race, documented as PAGE-33
+ # requires:** if the cancel settles the transport's future BEFORE the transport delivers its
+ # response, that response never reaches this engine's close path -- releasing it is the
+ # transport's responsibility, and for a future built on phase 2's Completer the completer does
+ # it (Completer#fulfil on a settled completer closes what it was handed, SEAM-30); conversely
+ # a page request already dispatched MAY still complete after the abort, and when it completes
+ # successfully this engine closes and discards the response at the page boundary rather than
+ # delivering it. A caller's `cancellation:` token is bridged to the same abort and released
+ # when the walk settles, so a client-lifetime token retains nothing per walk.
+ #
+ # @param consumer [#call] `#call(item)`, invoked serially
+ # @param cancellation [Dexpace::Cancellation, nil] a caller's token, passed to the transport
+ # @return [Dexpace::Async::Future] settles with the number of pages delivered
+ # @raise [Dexpace::InvalidArgumentError] when the consumer is not callable or the token is
+ # not a Dexpace::Cancellation
+ def walk(consumer, cancellation: nil)
+ start(consumer, cancellation, :items)
+ end
+
+ # The page-level walk (PAGE-1's async half): the same pump, delivering each live Dexpace::Page
+ # once to `consumer.call(page)` BEFORE it is closed, so the consumer sees the open response
+ # for the length of its own call; every other rule -- PAGE-25's abort, PAGE-26's boundary,
+ # PAGE-27's exactly-once close, PAGE-30's staged-page close, PAGE-32's drain close -- is the
+ # one #walk states. PAGE-14's single-use latch has no counterpart here: each call is its own
+ # walk, which is PAGE-8.
+ #
+ # @param consumer [#call] `#call(page)`, invoked serially
+ # @param cancellation [Dexpace::Cancellation, nil] a caller's token, passed to the transport
+ # @return [Dexpace::Async::Future] settles with the number of pages delivered
+ # @raise [Dexpace::InvalidArgumentError] as #walk
+ def walk_pages(consumer, cancellation: nil)
+ start(consumer, cancellation, :pages)
+ end
+
+ private
+
+ def validate_engine!(transport, template, strategy)
+ unless Model.required!("transport", transport).respond_to?(:call)
+ raise InvalidArgumentError, "transport must respond to #call, got #{transport.class}"
+ end
+ unless Model.required!("template", template).is_a?(Dexpace::Request)
+ raise InvalidArgumentError, "template must be a Dexpace::Request, got #{template.class}"
+ end
+ return if Model.required!("strategy", strategy).respond_to?(:parse)
+
+ raise InvalidArgumentError, "strategy must respond to #parse, got #{strategy.class}"
+ end
+
+ def start(consumer, cancellation, mode)
+ unless Model.required!("consumer", consumer).respond_to?(:call)
+ raise InvalidArgumentError, "consumer must respond to #call, got #{consumer.class}"
+ end
+ unless cancellation.nil? || cancellation.is_a?(Dexpace::Cancellation)
+ raise InvalidArgumentError,
+ "cancellation: takes a Dexpace::Cancellation, got #{cancellation.class}"
+ end
+
+ Pump.new(self, consumer, cancellation || Dexpace::Cancellation.none, mode).start
+ end
+
+ # The per-walk state and the trampoline (PAGE-31), one instance per walk method call and
+ # reachable from nothing but the callbacks it registers: the completer, the next request, the
+ # exchange and page counts, the in-flight transport future, and the re-arm protocol's two
+ # flags under one mutex. A private_constant with a sig/ mirror. Over the class-length default
+ # and deliberately one class, as 6a's Pump is: the re-arm protocol, the fence, the drain and
+ # the terminal paths share the completer and the flags, and splitting them across objects
+ # would scatter the one invariant the trampoline rests on.
+ class Pump # rubocop:disable Metrics/ClassLength -- one per-walk state machine, see above
+ # @param paginator [Dexpace::Page::AsyncPaginator]
+ # @param consumer [#call]
+ # @param cancellation [Dexpace::Cancellation] the token the transport receives
+ # @param mode [Symbol] :items or :pages
+ def initialize(paginator, consumer, cancellation, mode)
+ @paginator = paginator
+ @consumer = consumer
+ @cancellation = cancellation
+ @mode = mode
+ @completer = Dexpace::Async::Completer.new
+ @next_request = paginator.template #: Dexpace::Request?
+ @exchanges = 0
+ @pages = 0
+ @in_flight = nil #: Dexpace::Async::Future?
+ @mutex = ::Thread::Mutex.new
+ @running = false
+ @rearm = false
+ end
+
+ # Arms the two cancellation bridges, submits the first dispatch, and returns the future.
+ #
+ # @return [Dexpace::Async::Future]
+ def start
+ guarded do
+ @completer.on_cancel { |reason| @in_flight&.cancel(reason) } # PAGE-25
+ unless @cancellation.equal?(Dexpace::Cancellation.none)
+ subscription = @cancellation.on_cancel { |reason| @completer.request_cancel(reason) }
+ @completer.future.on_settle { subscription.detach }
+ end
+ submit(nil) { resume }
+ end
+ @completer.future
+ end
+
+ private
+
+ # The re-arm protocol (PAGE-31): when a loop is already running on some frame, flip the
+ # flag and return -- that loop picks the next page up when its current one returns;
+ # otherwise become the loop and keep launching until nothing re-armed it.
+ def resume
+ return unless claim(:start)
+
+ loop do
+ launch
+ break unless claim(:continue)
+ end
+ end
+
+ # :start claims the loop when none runs (else re-arms and answers false); :continue
+ # consumes a re-arm (else releases the loop and answers false). The mutex is held across
+ # the flag flip only.
+ def claim(step)
+ @mutex.synchronize do
+ if step == :start && @running
+ @rearm = true
+ false
+ elsif step == :start
+ @running = true
+ elsif @rearm
+ @rearm = false
+ true
+ else
+ @running = false
+ end
+ end
+ end
+
+ # One exchange: the same options every time (PAGE-36), the caller's token (or none), and the
+ # settlement callback, which hops to the executor when there is one. A transport that raises
+ # eagerly or answers something that is not a future is a failed walk (PAGE-28).
+ def launch
+ return if @completer.settled?
+
+ guarded do
+ request = @next_request #: Dexpace::Request
+ future = @paginator.transport.call(request, @paginator.options, @cancellation)
+ unless future.respond_to?(:on_settle)
+ raise InvalidArgumentError,
+ "an async transport must answer a Dexpace::Async::Future, got #{future.class}"
+ end
+
+ @exchanges += 1
+ @in_flight = future
+ future.on_settle { |settlement| deliver(settlement) }
+ end
+ end
+
+ # The settlement callback, total: inline by default, posted to the executor otherwise, so
+ # the driver -- parse, drain, close, re-arm -- runs where PAGE-29 puts it.
+ def deliver(settlement)
+ guarded { submit(settlement.response) { settled(settlement) } }
+ end
+
+ # PAGE-29's one submit site, and PAGE-30's: a rejecting #post fails the walk with the
+ # rejection and closes the response it was carrying, quietly (the rejection is primary).
+ def submit(response, &)
+ executor = @paginator.executor
+ return yield if executor.nil?
+
+ begin
+ executor.post(&)
+ rescue ::StandardError => error
+ Dexpace.close_quietly(response, onto: error)
+ @completer.fail(error)
+ end
+ nil
+ end
+
+ # A downstream settlement, on whichever thread it lands: a response arriving after the walk
+ # settled is closed and discarded (PAGE-33's second half, PAGE-26's drop); a failure is
+ # forwarded as the same object and a cancellation as a cancellation (PAGE-28); otherwise
+ # parse, build the page, check the boundary again, drain, and advance or finish.
+ def settled(settlement)
+ response = settlement.response
+ guarded do
+ next Dexpace.close_quietly(response) if @completer.settled?
+ next forward(settlement) unless settlement.error.nil?
+ # PAGE-28's null-success clause has a code site although Settlement's own validation
+ # makes it unreachable: a null success completion terminates the walk exceptionally.
+ next null_success if response.nil?
+
+ page = build_page(response)
+ # PAGE-26: staged, undrained, dropped
+ next Dexpace.close_quietly(page) if @completer.settled?
+
+ drain(page)
+ @pages += 1
+ advance
+ end
+ end
+
+ def null_success
+ @completer.fail(Dexpace::SeamError.new("the transport future settled with nothing"))
+ end
+
+ # A transport failure completes the walk with the ORIGINAL error object; a transport-side
+ # cancellation is forwarded as a cancellation carrying its reason (Future#then's rule).
+ def forward(settlement)
+ error = settlement.error #: Exception
+ if settlement.cancelled && error.is_a?(Dexpace::CancelledError)
+ @completer.request_cancel(error.reason)
+ else
+ @completer.fail(error)
+ end
+ end
+
+ # PAGE-13 on this path: the response is closed inline on a parse failure and the parse error
+ # is primary. The next request is recorded before the page is built.
+ def build_page(response)
+ info = Closing.parse_or_close(@paginator.strategy, response, @paginator.template)
+ @next_request = info.next_request
+ Dexpace::Page.build(response: response, items: info.items, next_link: info.next_link,
+ continuation_token: info.continuation_token,)
+ end
+
+ # PAGE-32's drain path: the page is released whether the consumer succeeds or throws. On
+ # the success path the close is bare and a throwing close propagates into the fence, which
+ # fails the future -- the walk terminates instead of hanging. When the consumer already
+ # failed the close is quiet, the consumer's cause stays primary and the close error is
+ # swallowed; PAGE-26's mid-drain rule holds because nothing here checks the completer
+ # between items. Item mode yields each item serially; page mode yields the live page once,
+ # before the close (PAGE-1, PAGE-27).
+ def drain(page)
+ if @mode == :items
+ page.items.each { |item| @consumer.call(item) }
+ else
+ @consumer.call(page)
+ end
+ rescue ::Exception # rubocop:disable Lint/RescueException -- PAGE-32: the consumer's cause stays primary
+ Dexpace.close_quietly(page)
+ raise
+ else
+ page.close
+ end
+
+ # After a drained page: terminal when the strategy said end-of-stream (PAGE-4) or the cap is
+ # reached (PAGE-9); otherwise re-arm, which the running loop picks up or a fresh one starts.
+ def advance
+ if @next_request.nil? || @exchanges >= @paginator.cap
+ @completer.fulfil(@pages)
+ else
+ resume
+ end
+ end
+
+ # The fence every callback body runs inside: a StandardError fails the future -- a raising
+ # on_settle block would otherwise escape onto the settling thread through Hooks.notify's
+ # re-raise and leave the future hanging -- and a fatal-family error fails it and then
+ # propagates unchanged (RECOV-2). Every site closes what it owns before it raises, so the
+ # fence closes nothing and cannot double-close.
+ def guarded
+ yield
+ rescue ::StandardError => error
+ @completer.fail(error)
+ rescue ::Exception => error # rubocop:disable Lint/RescueException -- fail, then re-raise
+ @completer.fail(error)
+ raise
+ end
+ end
+ private_constant :Pump
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/closing.rb b/gems/dexpace-core/lib/dexpace/page/closing.rb
new file mode 100644
index 0000000..89cb8da
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/closing.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../suppressible"
+require_relative "info"
+
+module Dexpace
+ class Page
+ # The two close disciplines every view, engine and walk in this subsystem shares, written once
+ # so PAGE-13's and PAGE-15's primary-error rules cannot drift between the four sites that need
+ # them.
+ #
+ # The trap both exist for was measured on every CI row (R8): a bare `ensure` whose close raises
+ # while a consumer exception is already in flight REPLACES the consumer's exception as the
+ # primary -- Ruby sets #cause to it, so nothing is lost, but PAGE-13's and PAGE-32's conformance
+ # clauses assert the exact opposite order. And the obvious repair, reading `$!` at the top of
+ # the `ensure`, is wrong in the other direction: `$!` is dynamically scoped to the thread and is
+ # non-nil inside anything called from a CALLER's `rescue` (pipeline/7ce4431d's measurement), so
+ # a walk driven from inside a consumer's rescue block would quietly attach its close error to an
+ # unrelated in-flight error instead of surfacing it. Neither `$!` nor a bare `ensure` appears in
+ # this subsystem (P7-105). The shape is 6b's: the frame that owns the walk records the primary
+ # in a `rescue ::Exception` arm that re-raises unchanged, and its `ensure` hands that local to
+ # .close_walk, which branches on it -- nil under a `break`, a normal return or a plain
+ # exhaustion, the consumer's error otherwise.
+ #
+ # Dexpace.close_quietly is deliberately NOT a third branch here: it is reached only where a
+ # requirement says SWALLOW -- PAGE-26's already-settled drop and PAGE-32's already-failed
+ # consumer, both in the async engine -- and using it anywhere else silently breaks PAGE-15's
+ # "surfaced, not swallowed". A private_constant with a sig/ mirror because the strict `core`
+ # Steep target types every file under lib/; asserted through the views and the lifetime suite.
+ module Closing
+ extend self
+
+ # PAGE-15 and PAGE-13/PAGE-32's primary rule, at the walk's end: with nothing in flight a
+ # close failure is SURFACED; with `primary` in flight it is attached to the primary's
+ # suppressed trail through Dexpace.attach_suppressed, and the primary stays primary. The
+ # attach is a documented no-op on a frozen primary (phase 4b's P4-13), never worked around --
+ # raising while attaching would mask the primary, which is the one thing this method exists
+ # to prevent.
+ #
+ # @param walk [Dexpace::Page::Walk] the walk to close; its #release closes both slots
+ # @param primary [Exception, nil] what the owning frame is already unwinding with, or nil
+ # @return [nil]
+ def close_walk(walk, primary)
+ walk.close
+ nil
+ rescue ::StandardError => error
+ raise if primary.nil?
+
+ Dexpace.attach_suppressed(primary, error)
+ nil
+ end
+
+ # PAGE-13: the strategy's parse, with the response closed INLINE on the exceptional path --
+ # the page is never constructed, so nothing else would close it -- and the parse error kept
+ # primary: a close failure is attached to its trail, never raised over it. A fatal-family
+ # error (not a StandardError) closes the response quietly and propagates unchanged with no
+ # trail, RECOV-2's split (P7-110). The re-raise is spelled `raise error, cause: nil`, because
+ # the error was carried across a nested rescue and a bare `raise` would hand it whatever is in
+ # flight as a #cause (pipeline/7ce4431d).
+ #
+ # @param strategy [_Strategy] the paginator's strategy
+ # @param response [Dexpace::Response] the fetched response, owned by the caller until the
+ # Info comes back and by the Page it becomes afterwards
+ # @param template [Dexpace::Request] the original request template
+ # @return [Dexpace::Page::Info]
+ def parse_or_close(strategy, response, template)
+ info = strategy.parse(response, template)
+ unless info.is_a?(Info)
+ raise InvalidArgumentError,
+ "a strategy's parse must return a Dexpace::Page::Info, got #{info.class}"
+ end
+
+ info
+ rescue ::StandardError => error
+ Dexpace.close_quietly(response, onto: error)
+ raise error, cause: nil
+ rescue ::Exception # rubocop:disable Lint/RescueException -- RECOV-2: the fatal family finds the response closed too, then propagates unchanged
+ Dexpace.close_quietly(response)
+ raise
+ end
+ end
+ private_constant :Closing
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb b/gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb
new file mode 100644
index 0000000..5bc3652
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "info"
+require_relative "query_rewriter"
+
+module Dexpace
+ class Page
+ # PAGE-16: the cursor strategy. Items and the next cursor come out of ONE call to a
+ # caller-supplied extractor, `#call(response) -> [items, cursor]`; a nil or empty cursor is
+ # end-of-stream; otherwise the next request is the template with the configurable cursor
+ # parameter (default `cursor`) spliced into its query through QueryRewriter -- replacing an
+ # earlier value in place, never appending a second (PAGE-23).
+ #
+ # An extractor, and never a codec or a witness (R7, P7-6): a keyword naming a serializer would
+ # put that word inside lib/dexpace/page/** and spec-forced boundary 5's audit would fail on the
+ # subsystem's own code. The consequence is stated rather than hidden: PAGE-16's "single read
+ # of the response body" is the EXTRACTOR's read, core performs none, and the suite asserts it
+ # against a read-counting body. An extractor that reads through Response#body_string gets the
+ # single-use rule enforced by phase 3b's body -- a second read raises Dexpace::ClosedError --
+ # and one that reads `response.body.source` directly owes the same discipline (PAGE-5). The
+ # extractor is NOT rescued: a raise is a parse failure and PAGE-13 is its path, never an
+ # end-of-stream signal (PAGE-4) -- rescuing it would turn every server-side schema change into
+ # a silently truncated stream.
+ #
+ # A frozen Data with no instance state, so PAGE-5's "immutable and safe to share concurrently"
+ # is structural: one instance serves any number of walks at once.
+ class CursorStrategy < ::Data.define(:extract, :parameter)
+ include Model
+
+ private_class_method :new
+
+ # @param extract [#call] `#call(response) -> [items, cursor_or_nil]`; a caller who wants a
+ # codec writes it into this closure, outside core
+ # @param parameter [String] the cursor query parameter's name
+ # @return [Dexpace::Page::CursorStrategy] frozen
+ # @raise [Dexpace::InvalidArgumentError] when the extractor is not callable or the parameter
+ # is not a non-empty String
+ def self.build(extract:, parameter: "cursor")
+ new(extract: extract, parameter: parameter)
+ end
+
+ def initialize(extract:, parameter:)
+ unless Model.required!("extract", extract).respond_to?(:call)
+ raise InvalidArgumentError, "extract must respond to #call, got #{extract.class}"
+ end
+ unless parameter.is_a?(::String) && !parameter.empty?
+ raise InvalidArgumentError,
+ "parameter must be a non-empty String, got #{parameter.inspect}"
+ end
+
+ super(extract: extract, parameter: Model.frozen_string(parameter))
+ end
+
+ # One extractor call, then the derivation. The cursor also travels as the Info's
+ # continuation token, so a fetcher front-end over pages this strategy produced has PAGE-34's
+ # fallback key.
+ #
+ # @param response [Dexpace::Response] read once, through the extractor, never closed here
+ # @param template [Dexpace::Request] the original request the next one is derived from
+ # @return [Dexpace::Page::Info]
+ # @raise [Dexpace::InvalidArgumentError] when the extractor's answer is not `[Array, String?]`
+ def parse(response, template)
+ items, cursor = unpack(extract.call(response))
+ return Info.terminal(items: items) if cursor.nil? || cursor.empty?
+
+ url = QueryRewriter.rewrite_url(template.url, parameter, cursor)
+ Info.build(items: items, next_request: template.with(url: url), continuation_token: cursor)
+ end
+
+ private
+
+ # The extractor's contract, checked so a wrong shape is named at the strategy and not read as
+ # a silent end-of-stream by a destructuring that would put a bare Array in `items`.
+ def unpack(answer)
+ unless answer.is_a?(::Array) && answer.size == 2
+ raise InvalidArgumentError,
+ "the cursor extractor must return [items, cursor], got #{answer.class}"
+ end
+
+ items, cursor = answer
+ unless items.is_a?(::Array)
+ raise InvalidArgumentError, "the extractor's items must be an Array, got #{items.class}"
+ end
+ unless cursor.nil? || cursor.is_a?(::String)
+ raise InvalidArgumentError,
+ "the extractor's cursor must be a String or nil, got #{cursor.class}"
+ end
+
+ [items, cursor]
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/fetchers.rb b/gems/dexpace-core/lib/dexpace/page/fetchers.rb
new file mode 100644
index 0000000..1646f42
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/fetchers.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "../registry"
+require_relative "../http/request_options"
+require_relative "walk"
+require_relative "items"
+require_relative "pages"
+
+module Dexpace
+ class Page
+ # The fetcher-based front-end (PAGE-34, PAGE-35): the caller supplies a first-page fetcher and a
+ # next-page fetcher instead of a transport and a strategy, and gets the same two views over the
+ # same private Walk -- the item view, the page view with its look-ahead, and the two scoped
+ # openers. A frozen Data holding the two callables and the options; every per-walk quantity
+ # lives on the walk (PAGE-8).
+ #
+ # PAGE-34's rules, each a branch of the private Drive: the first-page fetcher is called exactly
+ # once per walk; every later page keys the next-page fetcher off the previous page's next link,
+ # falling back to its continuation token when the link is absent or blank -- next link wins; a
+ # blank link with no fallback token, or a nil page from either fetcher, ends the stream, and a
+ # nil FIRST page is an empty stream rather than an error. **Each fetcher builds a Dexpace::Page
+ # that owns its response and MUST NOT close it** -- ownership transfers to the page and the
+ # walk releases it (PAGE-3) -- **and a fetcher that raises before building the page remains
+ # responsible for that response**: core cannot reach a response a fetcher never handed it.
+ #
+ # PAGE-35, vacuous by construction on design §12's own authority: the SHOULD is conditional on
+ # a MUTABLE paging-options object, and this port offers a frozen Dexpace::RequestOptions
+ # instead, so the clause is vacuous rather than declined. What the port does supply: the SAME
+ # frozen instance is threaded to every fetcher that takes it, by identity; a fetcher that wants
+ # per-page state has the previous page's `continuation_token` as a first-class channel
+ # (PAGE-34), and a Ruby callable carries its own binding for anything else. A fetcher's arity
+ # decides whether it is called with the options: `-> { }` and `->(key) { }` are called without
+ # them, `->(options) { }` and `->(key, options) { }` with them.
+ class Fetchers < ::Data.define(:first, :next_page, :options)
+ include Model
+
+ private_class_method :new
+
+ # @param first [#call] `#call -> Dexpace::Page | nil`, or `#call(options)`
+ # @param next_page [#call] `#call(key) -> Dexpace::Page | nil`, or `#call(key, options)`;
+ # `key` is the previous page's next link, or its continuation token when the link is blank
+ # @param options [Dexpace::RequestOptions] handed, by identity, to a fetcher that takes it
+ # @return [Dexpace::Page::Fetchers] frozen
+ # @raise [Dexpace::InvalidArgumentError] when a fetcher is not callable or the options are not
+ # a Dexpace::RequestOptions
+ def self.build(first:, next_page:, options: Dexpace::RequestOptions::EMPTY)
+ new(first: first, next_page: next_page, options: options)
+ end
+
+ def initialize(first:, next_page:, options:)
+ unless Model.required!("first", first).respond_to?(:call)
+ raise InvalidArgumentError, "first must respond to #call, got #{first.class}"
+ end
+ unless Model.required!("next_page", next_page).respond_to?(:call)
+ raise InvalidArgumentError, "next_page must respond to #call, got #{next_page.class}"
+ end
+ unless Model.required!("options", options).is_a?(Dexpace::RequestOptions)
+ raise InvalidArgumentError,
+ "options must be a Dexpace::RequestOptions, got #{options.class}"
+ end
+
+ super
+ end
+
+ # The item-level view over a fetcher-driven walk (PAGE-11's eager close, PAGE-8's fresh walk
+ # per iteration).
+ #
+ # @return [Dexpace::Page::Items]
+ def items
+ Items.new(method(:open_walk))
+ end
+
+ # The page-level view over a fetcher-driven walk (PAGE-12, PAGE-14).
+ #
+ # @return [Dexpace::Page::Pages]
+ def pages
+ Pages.new(method(:open_walk))
+ end
+
+ # The scoped opener for items; closes the walk on every exit.
+ #
+ # @yieldparam item [Object]
+ # @return [nil]
+ # @raise [Dexpace::InvalidArgumentError] without a block
+ def each_item(&block)
+ raise InvalidArgumentError, "each_item requires a block" if block.nil?
+
+ items.each(&block)
+ nil
+ end
+
+ # The scoped opener for pages; closes both held pages on every exit.
+ #
+ # @yieldparam page [Dexpace::Page]
+ # @return [nil]
+ # @raise [Dexpace::InvalidArgumentError] without a block
+ def each_page(&block)
+ raise InvalidArgumentError, "each_page requires a block" if block.nil?
+
+ pages.each(&block)
+ nil
+ end
+
+ # The fetcher front-end's per-walk exchange routine (P7-111): whether the first fetcher has
+ # run, and the previous page's key. Each call answers a Dexpace::Page or nil, exactly as the
+ # strategy engine's drive does, so the Walk is shared unchanged.
+ class Drive
+ # @param fetchers [Dexpace::Page::Fetchers]
+ def initialize(fetchers)
+ @fetchers = fetchers
+ @started = false
+ @key = nil #: String?
+ end
+
+ # @return [Dexpace::Page, nil]
+ def call
+ page = @started ? fetch_next : fetch_first
+ @started = true
+ return nil if page.nil?
+ unless page.is_a?(Dexpace::Page)
+ raise InvalidArgumentError,
+ "a fetcher must return a Dexpace::Page or nil, got #{page.class}"
+ end
+
+ @key = key_of(page)
+ page
+ end
+
+ private
+
+ def fetch_first
+ fetcher = @fetchers.first
+ takes_options = Dexpace::Registry.callable?(fetcher, arity: 1)
+ takes_options ? fetcher.call(@fetchers.options) : fetcher.call
+ end
+
+ # nil when the previous page carried neither a link nor a token: end-of-stream (PAGE-34).
+ def fetch_next
+ key = @key
+ return nil if key.nil?
+
+ fetcher = @fetchers.next_page
+ if Dexpace::Registry.callable?(fetcher, arity: 2)
+ fetcher.call(key, @fetchers.options)
+ else
+ fetcher.call(key)
+ end
+ end
+
+ # PAGE-34: the next link wins; a blank one falls back to the continuation token; both blank
+ # is nil.
+ def key_of(page)
+ link = page.next_link
+ return link unless link.nil? || link.strip.empty?
+
+ token = page.continuation_token
+ token.nil? || token.strip.empty? ? nil : token
+ end
+ end
+ private_constant :Drive
+
+ private
+
+ # A fresh Walk over a fresh Drive, uncapped: the fetchers are the caller's own code, and
+ # PAGE-9's cap bounds a server, not a callable the caller wrote.
+ def open_walk
+ Walk.new(Drive.new(self), cap: ::Float::INFINITY)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/info.rb b/gems/dexpace-core/lib/dexpace/page/info.rb
new file mode 100644
index 0000000..22a552b
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/info.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "../http/request"
+
+module Dexpace
+ class Page
+ # PAGE-4's PageInfo: what a strategy's #parse returns -- the page's items plus the fully-formed
+ # request for the next page, or nil for end-of-stream.
+ #
+ # `next_request == nil` is the single, exclusive end-of-stream signal, and there is no second
+ # one: no `terminal?` flag, no sentinel and no exception path, which is PAGE-4's prohibition
+ # made structural. .terminal is the named factory for that case so a strategy author never
+ # writes `next_request: nil` by hand and never mistakes an empty items list for termination:
+ # an empty list paired with a non-nil next request is a valid NON-terminal page, and the
+ # factory names the other one.
+ #
+ # The items Array is copied shallowly and frozen -- the collection is the model's, its
+ # elements stay the caller's domain objects (P7-101); Model.own's deep copy would replace
+ # them, freeze them and raise on a Proc. The two optional keys are PAGE-34's: a strategy that
+ # found a next link or a continuation token records it here so the Page it becomes carries
+ # them for the fetcher front-end.
+ class Info < ::Data.define(:items, :next_request, :next_link, :continuation_token)
+ include Model
+
+ private_class_method :new
+
+ # The validating factory every construction path goes through.
+ #
+ # @param items [Array] the page's items, possibly empty, never nil
+ # @param next_request [Dexpace::Request, nil] nil is the end-of-stream signal (PAGE-4)
+ # @param next_link [String, nil] PAGE-34's next link, when the source carried one
+ # @param continuation_token [String, nil] PAGE-34's fallback key
+ # @return [Dexpace::Page::Info]
+ # @raise [Dexpace::InvalidArgumentError] on a nil or non-Array items, a next_request that is
+ # not a Dexpace::Request, or a non-String link or token
+ def self.build(items:, next_request:, next_link: nil, continuation_token: nil)
+ new(items: items, next_request: next_request, next_link: next_link,
+ continuation_token: continuation_token,)
+ end
+
+ # The end-of-stream page: these items and no next request.
+ #
+ # @param items [Array] the last page's items, empty by default
+ # @return [Dexpace::Page::Info]
+ def self.terminal(items: [])
+ build(items: items, next_request: nil)
+ end
+
+ def initialize(items:, next_request:, next_link:, continuation_token:)
+ unless Model.required!("items", items).is_a?(::Array)
+ raise InvalidArgumentError, "items must be an Array, got #{items.class}"
+ end
+ unless next_request.nil? || next_request.is_a?(Dexpace::Request)
+ raise InvalidArgumentError,
+ "next_request must be a Dexpace::Request or nil, got #{next_request.class}"
+ end
+
+ super(items: items.dup.freeze, next_request: next_request,
+ next_link: optional_string("next_link", next_link),
+ continuation_token: optional_string("continuation_token", continuation_token),)
+ end
+
+ private
+
+ # nil, or a frozen copy of the String (XCUT-15); anything else is named and refused.
+ def optional_string(name, value)
+ return nil if value.nil?
+ return Model.frozen_string(value) if value.is_a?(::String)
+
+ raise InvalidArgumentError, "#{name} must be a String or nil, got #{value.class}"
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/items.rb b/gems/dexpace-core/lib/dexpace/page/items.rb
new file mode 100644
index 0000000..bf7c6ad
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/items.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../error/invalid_argument_error"
+require_relative "closing"
+require_relative "walk"
+
+module Dexpace
+ class Page
+ # The item-level view (PAGE-1): every page's items flattened into one sequence in server order
+ # across page boundaries, over a walk the view opens afresh on EVERY #each -- PAGE-8's "each
+ # independent iteration MUST restart from the initial request with its own fresh state" --
+ # with each page closed BEFORE any of its items is yielded (PAGE-11), after its items were
+ # copied into the page's frozen list.
+ #
+ # The eager close is what makes this view safe on a host where an abandoned Enumerator's
+ # ensure never runs: the only suspension points an external consumer can abandon the walk at
+ # are points where no response is live, so the residue of pagination/b2a85752 on this view is
+ # zero. It is also why #close is a documented no-op rather than an omission -- the walk is a
+ # local of #each so that overlapping iterations stay independent, so there is no walk for the
+ # view to hold, and by PAGE-11 there is nothing open to release at any point a caller could
+ # call it. A `@walk` stored on the view to give #close something to do would break PAGE-8 the
+ # moment two iterations overlap.
+ #
+ # `include ::Enumerable` gives `first`, `take`, `lazy`, `count` and the rest for free
+ # (design §7.1), each of them driving #each; `first` and `take` stop early and spend exactly
+ # the exchanges they consume (PAGE-6). Constructed by the engine through Paginator#items or
+ # Fetchers#items, never directly.
+ class Items
+ include ::Enumerable
+
+ # @param opener [#call] the engine's walk factory, `#call -> Walk`, one fresh walk per call
+ # @raise [Dexpace::InvalidArgumentError] when the opener is not callable
+ def initialize(opener)
+ unless opener.respond_to?(:call)
+ raise InvalidArgumentError, "an item view is opened by the engine, got #{opener.class}"
+ end
+
+ @opener = opener
+ end
+
+ # Yields every item in server order, fetching one page per page consumed (PAGE-6) and closing
+ # each page before its first item is yielded (PAGE-11). Without a block, an Enumerator over
+ # the same routine -- the one permitted block_given? in this subsystem, the
+ # return-an-enumerator idiom and never a guard. An Enumerator abandoned mid-#next runs no
+ # ensure, and on this view that costs nothing: PAGE-11 has already closed every page it
+ # pulled.
+ #
+ # @yieldparam item [Object]
+ # @return [self, Enumerator]
+ def each(&)
+ return to_enum(:each) unless block_given?
+
+ walk = @opener.call # PAGE-8: fresh state per iteration
+ primary = nil #: Exception?
+ begin
+ while (page = walk.fetch_next_page)
+ items = page.items # PAGE-11: already a frozen copy
+ page.close # PAGE-11: close BEFORE any item is yielded; a failure surfaces (PAGE-15)
+ items.each(&)
+ end
+ rescue ::Exception => error # rubocop:disable Lint/RescueException -- recorded for the ensure's Closing route, re-raised unchanged
+ primary = error
+ raise
+ ensure
+ Closing.close_walk(walk, primary)
+ end
+ self
+ end
+
+ # A documented no-op, kept so the two views share one lifetime vocabulary: this view holds no
+ # walk (PAGE-8) and PAGE-11 leaves nothing open at any point it could be called. Pages#close
+ # is the one that releases.
+ #
+ # @return [nil]
+ def close
+ nil
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/link_header.rb b/gems/dexpace-core/lib/dexpace/page/link_header.rb
new file mode 100644
index 0000000..b7675eb
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/link_header.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+
+module Dexpace
+ class Page
+ # PAGE-18 and PAGE-20: the RFC 8288 link-value grammar, as a character-level state machine, and
+ # the one question the Link strategy asks of it -- the first link-value whose `rel` carries the
+ # token `next`.
+ #
+ # Not a regexp, and that is spec-forced boundary 22 rather than a style preference: RFC 8288's
+ # grammar is not regular. A comma inside the angle-bracketed URI-reference and a comma inside a
+ # quoted parameter value must not split link-values, a semicolon inside a quoted value must not
+ # split parameters, and a quoted-pair escape (`\"`) must be honoured -- exactly the inputs
+ # PAGE-18's conformance clause names, and exactly where a pattern that appeared to work would
+ # fail. So the per-pattern-timeout constraint is discharged by not writing the pattern, the
+ # route design §6.3 takes for WWW-Authenticate; no Regexp is compiled in this file and the
+ # suite scans for one. The `rel` value is accepted quoted or unquoted, split on space and tab
+ # into relation types, and each token compared through the no-argument `downcase`
+ # (Dexpace/NoLocaleCaseFold) -- the one fold site in the subsystem. A parameter NAME is folded
+ # too, so `REL=next` is `rel=next` (RFC 8288 §3: parameter names are case-insensitive), and
+ # only the FIRST `rel` parameter of a link-value is read: RFC 8288 §3.3 says `rel` MUST NOT
+ # appear more than once and occurrences after the first MUST be ignored, so
+ # `; rel="prev"; rel="next"` is a prev link and never a next one (P7-116).
+ #
+ # It never raises. Malformed input -- a value with no angle brackets, a reference never closed,
+ # a quoted string never closed, an escape at the very end -- yields the link-values scanned so
+ # far and drops the broken parameter, which the strategy reads as end-of-stream (PAGE-18's
+ # "absence of a rel=next segment"); a blank target `<>` comes back as "" and is the strategy's
+ # P7-5. Several header instances are joined with ", " first, which is PAGE-20's normalisation
+ # by concatenation: the grammar is the same whether the server sent one instance or three. A
+ # private_constant, asserted at LinkStrategy's call site and through const_get in its own
+ # suite, with a sig/ mirror because the strict `core` Steep target types every file under lib/.
+ module LinkHeader
+ extend self
+
+ OWS = [" ", "\t"].freeze
+ # What separates link-values from one another, with the whitespace around them.
+ SEPARATORS = [" ", "\t", ","].freeze
+ # What ends a parameter name or an unquoted parameter value.
+ TOKEN_END = [" ", "\t", ";", ",", "="].freeze
+ private_constant :OWS, :SEPARATORS, :TOKEN_END
+
+ # @param values [Array, nil] the Link header's instances, as Headers#[] returns them
+ # @return [String, nil] the first rel=next URI-reference, raw, or nil for end-of-stream
+ def next_target(values)
+ return nil if values.nil? || values.empty?
+
+ link_values(values.join(", ")).each do |target, params|
+ rel = params.find { |name, _value| name == "rel" }
+ return target if !rel.nil? && next_token?(rel[1])
+ end
+ nil
+ end
+
+ private
+
+ # PAGE-18: `rel` lists space- or tab-separated relation types; the match is the whole token,
+ # case-insensitively. A bare `split` is awk-style whitespace splitting and compiles nothing.
+ def next_token?(value)
+ value.split.any? { |token| token.downcase == "next" }
+ end
+
+ # Every `[target, params]` pair in order, stopping at the first malformed link-value: one
+ # that does not open with `<` or never closes its `>`.
+ def link_values(text)
+ results = [] #: Array[[String, Array[[String, String]]]]
+ index = skip(text, 0, SEPARATORS)
+ while index < text.length && text[index] == "<"
+ close = text.index(">", index + 1)
+ break if close.nil?
+
+ target = text[(index + 1)...close] || ""
+ params, index = parse_params(text, close + 1)
+ results << [target, params]
+ index = skip(text, index, SEPARATORS)
+ end
+ results
+ end
+
+ # The index of the first character at or after `index` that is not in `set`.
+ def skip(text, index, set)
+ index += 1 while index < text.length && set.include?(text[index])
+ index
+ end
+
+ # The `; name=value` list after a `>`, up to the next top-level comma or the end of the text.
+ # Anything other than `;` or `,` where a separator is expected ends this link-value's
+ # parameters at the next comma.
+ #
+ # @return [Array(Array, Integer)] the pairs, and where to resume
+ def parse_params(text, index)
+ params = [] #: Array[[String, String]]
+ loop do
+ index = skip(text, index, OWS)
+ return [params, index] if index >= text.length || text[index] == ","
+ return [params, text.index(",", index) || text.length] unless text[index] == ";"
+
+ pair, index = parse_param(text, index + 1)
+ params << pair unless pair.nil?
+ end
+ end
+
+ # One `name` or `name=value` after a `;`; nil for an empty name or an unterminated quoted
+ # value, either of which is dropped rather than raised on.
+ #
+ # @return [Array(Array(String, String), Integer)] the pair (or nil), and where to resume
+ def parse_param(text, index)
+ name, index = read_token(text, skip(text, index, OWS))
+ index = skip(text, index, OWS)
+ value = "" #: String?
+ value, index = read_value(text, skip(text, index + 1, OWS)) if text[index] == "="
+ return [nil, index] if name.empty? || value.nil?
+
+ pair = [name.downcase, value] #: [String, String]
+ [pair, index]
+ end
+
+ def read_value(text, index)
+ text[index] == '"' ? read_quoted(text, index + 1) : read_token(text, index)
+ end
+
+ def read_token(text, index)
+ start = index
+ index += 1 while index < text.length && !TOKEN_END.include?(text[index])
+ [text[start...index] || "", index]
+ end
+
+ # From just after the opening quote to just after the closing one, unescaping quoted-pairs.
+ # An unterminated string, or an escape as the very last character, is nil and consumes the
+ # rest: there is no grammar left to honour.
+ def read_quoted(text, index)
+ value = +""
+ while index < text.length
+ char = text[index]
+ return [value, index + 1] if char == '"'
+
+ if char == "\\"
+ return [nil, text.length] if index + 1 >= text.length
+
+ index += 1
+ char = text[index]
+ end
+ value << char.to_s
+ index += 1
+ end
+ [nil, index]
+ end
+ end
+ private_constant :LinkHeader
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/link_strategy.rb b/gems/dexpace-core/lib/dexpace/page/link_strategy.rb
new file mode 100644
index 0000000..9e6c3fd
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/link_strategy.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "info"
+require_relative "link_header"
+
+module Dexpace
+ class Page
+ # PAGE-18, PAGE-19, PAGE-20: the Link-header strategy. The items come out of a caller-supplied
+ # `#call(response) -> items` extractor; the next request comes from the response's Link
+ # header(s), read under the fold through Headers#[] -- every instance the server sent, which
+ # LinkHeader joins and scans by RFC 8288's grammar for the first link-value whose `rel` carries
+ # the token `next` -- and resolved through Dexpace::Page.next_request_from against the
+ # originating page's response URL, which is where PAGE-19's three end-of-stream routes live: a
+ # blank target (P7-5), an unresolvable one, and one this client cannot dispatch (P7-104). No
+ # header, no rel=next segment, or any of the three, is end-of-stream and never an error. The
+ # header name is configurable and defaults to `Link`.
+ #
+ # The raw target travels as the Info's `next_link`, so the fetcher front-end's PAGE-34 key is
+ # available on pages this strategy produced. The extractor is not rescued (PAGE-4). A frozen
+ # Data with no instance state (PAGE-5).
+ class LinkStrategy < ::Data.define(:extract_items, :header)
+ include Model
+
+ private_class_method :new
+
+ # @param extract_items [#call] `#call(response) -> items`
+ # @param header [String] the header to read the link-values from
+ # @return [Dexpace::Page::LinkStrategy] frozen
+ # @raise [Dexpace::InvalidArgumentError] when the extractor is not callable or the header
+ # name is not a non-empty String
+ def self.build(extract_items:, header: "Link")
+ new(extract_items: extract_items, header: header)
+ end
+
+ def initialize(extract_items:, header:)
+ unless Model.required!("extract_items", extract_items).respond_to?(:call)
+ raise InvalidArgumentError,
+ "extract_items must respond to #call, got #{extract_items.class}"
+ end
+ unless header.is_a?(::String) && !header.empty?
+ raise InvalidArgumentError, "header must be a non-empty String, got #{header.inspect}"
+ end
+
+ super(extract_items: extract_items, header: Model.frozen_string(header))
+ end
+
+ # @param response [Dexpace::Response] read once, through the extractor, never closed here
+ # @param template [Dexpace::Request] the original request the next one is derived from
+ # @return [Dexpace::Page::Info]
+ # @raise [Dexpace::InvalidArgumentError] when the extractor's answer is not an Array
+ def parse(response, template)
+ items = extract_items.call(response)
+ unless items.is_a?(::Array)
+ raise InvalidArgumentError, "the extractor's items must be an Array, got #{items.class}"
+ end
+
+ target = LinkHeader.next_target(response.headers[header])
+ next_request = Dexpace::Page.next_request_from(template, response, target)
+ return Info.terminal(items: items) if next_request.nil?
+
+ Info.build(items: items, next_request: next_request, next_link: target)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb b/gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb
new file mode 100644
index 0000000..cc1d1c1
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "info"
+require_relative "query_rewriter"
+
+module Dexpace
+ class Page
+ # PAGE-17: the page-number strategy. An empty items list is end-of-stream, checked FIRST and
+ # before anything else is computed -- defensive against a server that returns an empty page
+ # past the end. Otherwise the current page is read from the ORIGINATING (executed) request's
+ # page parameter, which in this port is `response.request` and not the template: after a
+ # redirect chain the executed request is the final hop's, and it is the one the server
+ # answered. An absent, empty or non-numeric value falls back to the configurable start page
+ # (default 1; 0 is permitted for 0-based servers), and the next request is the template with
+ # `current + 1` spliced into its page parameter (default `page`).
+ #
+ # The numeric screen is one anchored ASCII-digit pattern compiled once with its own
+ # `Regexp.new(source, timeout:)` -- per-pattern, never the process-global `Regexp.timeout` a
+ # library must not impose on its host (design §4). `[0-9]` rather than `\d`, so a non-ASCII
+ # digit is "non-numeric" and falls back, as the requirement's garbage case asks. The extractor
+ # is `#call(response) -> items` and is not rescued (PAGE-4; see CursorStrategy). A frozen Data
+ # with no instance state (PAGE-5).
+ class PageNumberStrategy < ::Data.define(:extract_items, :parameter, :start)
+ include Model
+
+ private_class_method :new
+
+ DIGITS = ::Regexp.new("\\A[0-9]+\\z", timeout: 1.0)
+ private_constant :DIGITS
+
+ # @param extract_items [#call] `#call(response) -> items`
+ # @param parameter [String] the page-number query parameter's name
+ # @param start [Integer] the first page's number; 0 for a 0-based server
+ # @return [Dexpace::Page::PageNumberStrategy] frozen
+ # @raise [Dexpace::InvalidArgumentError] when the extractor is not callable, the parameter is
+ # not a non-empty String, or the start is not a non-negative Integer
+ def self.build(extract_items:, parameter: "page", start: 1)
+ new(extract_items: extract_items, parameter: parameter, start: start)
+ end
+
+ def initialize(extract_items:, parameter:, start:)
+ unless Model.required!("extract_items", extract_items).respond_to?(:call)
+ raise InvalidArgumentError,
+ "extract_items must respond to #call, got #{extract_items.class}"
+ end
+ unless parameter.is_a?(::String) && !parameter.empty?
+ raise InvalidArgumentError,
+ "parameter must be a non-empty String, got #{parameter.inspect}"
+ end
+ unless start.is_a?(::Integer) && !start.negative?
+ raise InvalidArgumentError, "start must be a non-negative Integer, got #{start.inspect}"
+ end
+
+ super(extract_items: extract_items, parameter: Model.frozen_string(parameter), start: start)
+ end
+
+ # @param response [Dexpace::Response] read once, through the extractor, never closed here
+ # @param template [Dexpace::Request] the original request the next one is derived from
+ # @return [Dexpace::Page::Info]
+ # @raise [Dexpace::InvalidArgumentError] when the extractor's answer is not an Array
+ def parse(response, template)
+ items = extract_items.call(response)
+ unless items.is_a?(::Array)
+ raise InvalidArgumentError, "the extractor's items must be an Array, got #{items.class}"
+ end
+ return Info.terminal(items: items) if items.empty?
+
+ url = QueryRewriter.rewrite_url(template.url, parameter, (current_page(response) + 1).to_s)
+ Info.build(items: items, next_request: template.with(url: url))
+ end
+
+ private
+
+ # The executed request's page number, or `start` when the parameter is absent, empty or
+ # not a run of ASCII digits.
+ def current_page(response)
+ value = QueryRewriter.get(response.request.url.query, parameter)
+ value.nil? || !DIGITS.match?(value) ? start : value.to_i
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/page_state_error.rb b/gems/dexpace-core/lib/dexpace/page/page_state_error.rb
new file mode 100644
index 0000000..577b35b
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/page_state_error.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../error"
+require_relative "../page"
+
+module Dexpace
+ class Page
+ # PAGE-14's "re-iteration MUST fail rather than silently restart": the error a second #each
+ # on a single-use Pages view raises. A STATE error and never Dexpace::InvalidArgumentError --
+ # no argument is involved, the object was simply used twice -- so a caller rescuing
+ # ArgumentError around a page loop does not catch it, and it sits in the same family as the
+ # SSE facade's state error for the identical single-use latch (phase 10's inbound bullet of
+ # 2026-09-13 on PAGE-14 and SSE-26; P7-103). A StandardError including Dexpace::Error, like
+ # every namespaced error since phase 6c; a shared core supertype for the two subsystems' state
+ # errors is a pure widening phase 10 can insert.
+ class PageStateError < ::StandardError
+ include Dexpace::Error
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/pages.rb b/gems/dexpace-core/lib/dexpace/page/pages.rb
new file mode 100644
index 0000000..846ed5e
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/pages.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../error/invalid_argument_error"
+require_relative "closing"
+require_relative "page_state_error"
+require_relative "walk"
+
+module Dexpace
+ class Page
+ # The page-level view (PAGE-1): whole Dexpace::Pages in order, each live -- status, headers,
+ # originating request and the open response -- for the length of the consumer's turn. One walk,
+ # allocated when the view is, and single-use: the iterator may be obtained at most once, and a
+ # second #each raises PageStateError rather than silently restarting (PAGE-14); two views from
+ # one engine are two walks and two full fetch sequences (PAGE-8).
+ #
+ # PAGE-12's auto-closing discipline, verbatim: the previous page is closed as the consumer
+ # advances, the last page at exhaustion, and a page fetched by a probe but not yet delivered is
+ # staged in storage the WALK owns -- its one-slot look-ahead, never the enumerator's closure
+ # (pagination/9bdf90fc) -- so an emptiness probe or an early break followed by #close still
+ # releases it; #close releases both the held page and the staged one. Up to two live pages can
+ # exist at once. **Wrap the view in Paginator#each_page or Fetchers#each_page, or call #close
+ # yourself**: an Enumerator abandoned mid-#next runs no ensure, so a page pulled through
+ # external iteration and then dropped stays open until #close, which is the residue design
+ # §7.1 states rather than hides.
+ #
+ # `include ::Enumerable` gives `first`, `take`, `lazy` and the rest, each of them one drive of
+ # #each -- so `view.first` is the whole single use. Constructed by the engine through
+ # Paginator#pages or Fetchers#pages, never directly.
+ class Pages
+ include ::Enumerable
+
+ # @param opener [#call] the engine's walk factory, `#call -> Walk`
+ # @raise [Dexpace::InvalidArgumentError] when the opener is not callable
+ def initialize(opener)
+ unless opener.respond_to?(:call)
+ raise InvalidArgumentError, "a page view is opened by the engine, got #{opener.class}"
+ end
+
+ @walk = opener.call
+ @viewed = false
+ end
+
+ # Yields each live page in order, closing the previous one as it advances and the last at
+ # exhaustion, and both held pages on every exit -- a return, a `break`, a raise (PAGE-12).
+ # Without a block, the Enumerator over the same routine; obtaining it IS the one use, so a
+ # later block call raises. A second call in either form raises PageStateError (PAGE-14).
+ #
+ # @yieldparam page [Dexpace::Page] live for the length of the block, closed after it
+ # @return [self, Enumerator]
+ # @raise [Dexpace::Page::PageStateError] on a second call
+ def each(&)
+ claim!
+ return to_enum(:drive) unless block_given?
+
+ drive(&)
+ self
+ end
+
+ # PAGE-12's emptiness probe. **The first call runs an HTTP exchange**: probing for the next
+ # page eagerly fetches it, and the page is staged in the walk's look-ahead slot until the
+ # consumer advances to it or the view is closed. A repeated probe reads the staged page and
+ # costs nothing (PAGE-6); a closed or exhausted walk fetches nothing and answers false.
+ #
+ # @return [Boolean] whether another page is available
+ def more?
+ return true unless @walk.buffered.nil?
+
+ page = @walk.fetch_next_page
+ return false if page.nil?
+
+ @walk.buffer(page)
+ true
+ end
+
+ # Releases both the held page and any staged one, surfacing a close failure rather than
+ # swallowing it (PAGE-15); idempotent through the walk's latch.
+ #
+ # @return [nil]
+ def close
+ @walk.close
+ end
+
+ # Whether #close has run (or #each's own ensure did).
+ #
+ # @return [Boolean]
+ def closed?
+ @walk.closed?
+ end
+
+ private
+
+ def claim!
+ if @viewed
+ raise PageStateError,
+ "a page view is single-use: its iterator was already obtained (PAGE-14)"
+ end
+
+ @viewed = true
+ end
+
+ # The one drive: a staged page first, else the next fetched one; hold it (closing the previous
+ # -- a failure there is surfaced, PAGE-15), yield it; close the walk on every exit through
+ # Closing, with the consumer's error recorded so a close failure joins its trail instead of
+ # replacing it (PAGE-13's order, R8).
+ def drive
+ primary = nil #: Exception?
+ begin
+ while (page = @walk.take_buffered || @walk.fetch_next_page)
+ @walk.hold(page)
+ yield page
+ end
+ rescue ::Exception => error # rubocop:disable Lint/RescueException -- recorded for the ensure's Closing route, re-raised unchanged
+ primary = error
+ raise
+ ensure
+ Closing.close_walk(@walk, primary)
+ end
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/paginator.rb b/gems/dexpace-core/lib/dexpace/page/paginator.rb
new file mode 100644
index 0000000..ed5894e
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/paginator.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../model"
+require_relative "../cancellation"
+require_relative "../http/request"
+require_relative "../http/request_options"
+require_relative "closing"
+require_relative "walk"
+require_relative "items"
+require_relative "pages"
+
+module Dexpace
+ class Page
+ # The blocking pagination engine (PAGE-6 through PAGE-10, PAGE-36): a frozen Data holding only
+ # immutable configuration -- the transport, the request template, the strategy, the page cap
+ # and the per-call options -- and no per-walk state at all, so one instance is safe to share
+ # across any number of concurrent iterations (PAGE-8). Every per-walk quantity lives on a
+ # private Walk that #items and #pages allocate per view; nothing here is fetched until a
+ # consumer probes (PAGE-6).
+ #
+ # The transport is anything answering `#call(request, options, cancellation)` with a
+ # Dexpace::Response -- a Pipeline, a bare adapter, a lambda. A built pipeline is a transport
+ # whose #close is a no-op (PIPE-26, PIPE-27), so the paginator wraps one without declaring it
+ # and NEVER closes it: PAGE-3's ownership transfer is about the response, not the transport it
+ # came from, and a paginator that closed its transport would break a caller who built one
+ # pipeline for a whole client. The per-call options are passed on EVERY exchange, which is
+ # what makes PAGE-36 structural rather than remembered; the default is RequestOptions::EMPTY,
+ # "no overrides". The cancellation the transport receives is always Cancellation.none: the
+ # blocking engine takes no per-walk token in v1, and a nil there would die inside the standard
+ # pipeline's retry step (P7-102).
+ #
+ # PAGE-10: the default cap is Float::INFINITY, strictly positive and comparable against an
+ # Integer exchange count, so the cap check is one expression with no nil branch. **Production
+ # callers should set a finite cap**: an unbounded walk over a server that never advances its
+ # cursor runs until the process is stopped, and the cap is the only bound this engine has.
+ class Paginator < ::Data.define(:transport, :template, :strategy, :cap, :options)
+ include Model
+
+ private_class_method :new
+
+ # @param transport [#call] `#call(request, options, cancellation) -> Dexpace::Response`
+ # @param template [Dexpace::Request] the first page's request and the shape every later one
+ # is derived from
+ # @param strategy [_Strategy] a stateless parser answering `#parse(response, template)`
+ # @param cap [Numeric] PAGE-9's maximum number of exchanges, strictly positive; set a finite
+ # one in production (PAGE-10)
+ # @param options [Dexpace::RequestOptions] PAGE-36's per-call overrides, applied to every page
+ # @return [Dexpace::Page::Paginator] frozen
+ # @raise [Dexpace::InvalidArgumentError] when a member is missing or of the wrong shape, or
+ # the cap is not strictly positive
+ def self.build(transport:, template:, strategy:, cap: ::Float::INFINITY,
+ options: Dexpace::RequestOptions::EMPTY)
+ new(transport: transport, template: template, strategy: strategy, cap: cap,
+ options: options,)
+ end
+
+ # Validation lives here so .build, #with and a forged send(:new, ...) all meet it (PAGE-9's
+ # "validated as strictly positive at construction, not lazily").
+ def initialize(transport:, template:, strategy:, cap:, options:)
+ validate_engine!(transport, template, strategy)
+ unless cap.is_a?(::Numeric) && cap.positive?
+ raise InvalidArgumentError,
+ "cap must be a strictly positive number, got #{cap.inspect} (PAGE-9)"
+ end
+ unless Model.required!("options", options).is_a?(Dexpace::RequestOptions)
+ raise InvalidArgumentError,
+ "options must be a Dexpace::RequestOptions, got #{options.class}"
+ end
+
+ super
+ end
+
+ # The item-level view: a fresh walk per iteration, every page closed before its items are
+ # yielded (PAGE-11), re-iterable (PAGE-8). Nothing is fetched until it is driven (PAGE-6).
+ #
+ # @return [Dexpace::Page::Items]
+ def items
+ Items.new(method(:open_walk))
+ end
+
+ # The page-level view: one walk, single-use (PAGE-14), auto-closing with a one-slot look-ahead
+ # (PAGE-12). Nothing is fetched until it is driven or probed (PAGE-6). Wrap it in #each_page,
+ # or call Pages#close yourself, so an early exit releases what it holds.
+ #
+ # @return [Dexpace::Page::Pages]
+ def pages
+ Pages.new(method(:open_walk))
+ end
+
+ # The scoped opener for items: yields each item in server order and closes the walk on every
+ # exit -- a return, a `break`, a raise. The recommended form (PAGE-12's "consumers MUST be
+ # told to wrap the view in a scoped/auto-close construct").
+ #
+ # @yieldparam item [Object]
+ # @return [nil]
+ # @raise [Dexpace::InvalidArgumentError] without a block
+ def each_item(&block)
+ raise InvalidArgumentError, "each_item requires a block" if block.nil?
+
+ items.each(&block)
+ nil
+ end
+
+ # The scoped opener for pages: yields each live Dexpace::Page in order, closes the previous
+ # page as the consumer advances, the last at exhaustion, and both held pages on every exit --
+ # a return, a `break`, a raise. The recommended form for the page view (PAGE-12).
+ #
+ # @yieldparam page [Dexpace::Page]
+ # @return [nil]
+ # @raise [Dexpace::InvalidArgumentError] without a block
+ def each_page(&block)
+ raise InvalidArgumentError, "each_page requires a block" if block.nil?
+
+ pages.each(&block)
+ nil
+ end
+
+ # The strategy engine's per-walk exchange routine (P7-111): the next request is its one piece
+ # of state, seeded from the template and replaced by each Info's next request; nil once the
+ # strategy said end-of-stream, with no exchange (PAGE-7). One exchange passes the paginator's
+ # options every time (PAGE-36) and Cancellation.none (P7-102), then PAGE-13's parse-or-close,
+ # then the Page that owns the response from here on (PAGE-3).
+ class Drive
+ # @param paginator [Dexpace::Page::Paginator]
+ def initialize(paginator)
+ @paginator = paginator
+ @next_request = paginator.template #: Dexpace::Request?
+ end
+
+ # @return [Dexpace::Page, nil]
+ def call
+ request = @next_request
+ return nil if request.nil?
+
+ response = @paginator.transport.call(request, @paginator.options, Dexpace::Cancellation.none)
+ info = Closing.parse_or_close(@paginator.strategy, response, @paginator.template)
+ @next_request = info.next_request
+ Dexpace::Page.build(response: response, items: info.items, next_link: info.next_link,
+ continuation_token: info.continuation_token,)
+ end
+ end
+ private_constant :Drive
+
+ private
+
+ # The three members named the way HTTP-4 / SEAM-29 name them; the async engine validates the
+ # same three the same way.
+ def validate_engine!(transport, template, strategy)
+ unless Model.required!("transport", transport).respond_to?(:call)
+ raise InvalidArgumentError, "transport must respond to #call, got #{transport.class}"
+ end
+ unless Model.required!("template", template).is_a?(Dexpace::Request)
+ raise InvalidArgumentError, "template must be a Dexpace::Request, got #{template.class}"
+ end
+ return if Model.required!("strategy", strategy).respond_to?(:parse)
+
+ raise InvalidArgumentError, "strategy must respond to #parse, got #{strategy.class}"
+ end
+
+ # A fresh Walk over a fresh Drive: PAGE-8's "each independent iteration restarts from the
+ # initial request with its own fresh state".
+ def open_walk
+ Walk.new(Drive.new(self), cap: cap)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/query_rewriter.rb b/gems/dexpace-core/lib/dexpace/page/query_rewriter.rb
new file mode 100644
index 0000000..e1dd897
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/query_rewriter.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../http/percent_encoding"
+
+module Dexpace
+ class Page
+ # PAGE-21 through PAGE-24: the verbatim query splice. Three pure functions over the RAW query
+ # substring, never over a parsed model.
+ #
+ # The whole design of this module is PAGE-21's byte-for-byte rule: it tokenises on `&` and
+ # on the FIRST `=` of each segment, copies every untargeted segment as exactly the bytes it
+ # found -- a value-less flag stays value-less, `filter=a:b` keeps its bare colon, an empty
+ # segment stays empty -- and decodes or encodes only the targeted parameter's name and value.
+ # It never round-trips the whole query. Phase 1's Dexpace::Query and Query::Builder are
+ # correct for what they are and deliberately not used here: `Query.parse(q).encode`
+ # re-encodes every parameter, rewriting `filter=a:b` to `filter=a%3Ab`, which is the
+ # canonicalisation PAGE-21 forbids in as many words. Ruby's own helpers are rejected by
+ # measurement rather than taste: URI.decode_www_form reads `a+b` as "a b" and
+ # URI.encode_www_form_component writes a space as `+`, the exact inverse of PAGE-22 in both
+ # directions. The codec is phase 1's PercentEncoding -- RFC 3986 component encoding, space to
+ # `%20`, a literal `+` to `%2B` and back to `+` -- and nothing new is encoded here (PAGE-22).
+ #
+ # Two measured facts hold the URL half up (matrix_facts_test.rb): `URI::Generic#query=` is
+ # byte-transparent for every query string the pinned parser can produce -- the canonicalising
+ # happens at PARSE time, before a Request#url ever reaches this module -- so `dup`, `query=`
+ # and the model's re-parse round-trip the splice exactly, and every other component survives
+ # untouched (PAGE-24); and `query = nil` removes the query while `query = ""` leaves a dangling
+ # `?`, which is why an empty splice is normalised to nil (P7-4).
+ module QueryRewriter
+ extend self
+
+ # PAGE-22's reading half: the first matching parameter's value, decoded with RFC 3986
+ # semantics -- `%20` reads as a space, a literal `+` as `+`, a value-less flag as "" -- or
+ # nil when the parameter is absent. The name is matched decoded, so `pa%67e` and `page` are
+ # one parameter.
+ #
+ # @param query [String, nil] the raw query substring, without the `?`
+ # @param name [String] the parameter to read
+ # @return [String, nil]
+ def get(query, name)
+ segments(query).each do |segment|
+ raw_name, raw_value = split_segment(segment)
+ next unless decode(raw_name) == name
+
+ return raw_value.nil? ? "" : decode(raw_value)
+ end
+ nil
+ end
+
+ # PAGE-23: replace the first existing occurrence in place, drop every later duplicate,
+ # append when absent, and remove entirely when `value` is nil; order is otherwise
+ # preserved and every untargeted segment is copied byte-for-byte (PAGE-21). The new
+ # name and value are RFC 3986 component-encoded (PAGE-22). An empty result is nil rather
+ # than "" so the URL it is spliced into carries no dangling `?` (P7-4).
+ #
+ # @param query [String, nil] the raw query substring, without the `?`
+ # @param name [String] the parameter to set
+ # @param value [String, nil] the new value; nil removes the parameter
+ # @return [String, nil] the spliced query, or nil when nothing is left
+ def set(query, name, value)
+ replacement = value.nil? ? nil : "#{encode(name)}=#{encode(value)}"
+ out, seen = splice(segments(query), name, replacement)
+ out << replacement if !seen && !replacement.nil?
+ out.empty? ? nil : out.join("&")
+ end
+
+ # PAGE-24: a copy of `uri` with only its query changed, through .set. Every other component
+ # -- scheme, userinfo, host, port, path, fragment -- is untouched because none is visited;
+ # the caller's URI, frozen or not, is never written to.
+ #
+ # @param uri [URI::Generic] the template's URL
+ # @param name [String] the parameter to set
+ # @param value [String, nil] the new value; nil removes the parameter
+ # @return [URI::Generic] a fresh URI, unfrozen, for Request#with to re-parse and own
+ def rewrite_url(uri, name, value)
+ copy = uri.dup
+ copy.query = set(copy.query, name, value)
+ copy
+ end
+
+ private
+
+ # One pass over the segments: the first match becomes `replacement` (or vanishes when it is
+ # nil), every later match is dropped, everything else is copied as the bytes it was.
+ #
+ # @return [Array(Array, Boolean)] the output segments, and whether a match was seen
+ def splice(segments, name, replacement)
+ out = [] #: Array[String]
+ seen = false
+ segments.each do |segment|
+ raw_name, = split_segment(segment)
+ if decode(raw_name) == name
+ out << replacement unless seen || replacement.nil?
+ seen = true
+ else
+ out << segment
+ end
+ end
+ [out, seen]
+ end
+
+ # `split("&", -1)` keeps empty segments -- `a&&b` and a trailing `&` are bytes PAGE-21
+ # preserves -- and an absent or empty query has no segments at all.
+ def segments(query)
+ return [] if query.nil? || query.empty?
+
+ query.split("&", -1)
+ end
+
+ # The first `=` only: `a=b=c` is the name `a` with the value `b=c`.
+ def split_segment(segment)
+ index = segment.index("=")
+ return [segment, nil] if index.nil?
+
+ [segment[0, index] || "", segment[(index + 1)..] || ""]
+ end
+
+ def decode(text) = Dexpace::PercentEncoding.decode_component(text)
+ def encode(text) = Dexpace::PercentEncoding.encode_component(text)
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/page/walk.rb b/gems/dexpace-core/lib/dexpace/page/walk.rb
new file mode 100644
index 0000000..740590b
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/page/walk.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../page"
+require_relative "../closeable"
+require_relative "../suppressible"
+require_relative "page_state_error"
+
+module Dexpace
+ class Page
+ # The per-iteration lifetime owner: every live page a walk holds is an instance variable on
+ # this object, which the caller can reach and #close -- never a local of an Enumerator block or
+ # of a #each method. That is design §7.1's hard rule applied literally: an Enumerator abandoned
+ # mid-#next never runs its ensure, neither does an ordinary #each, and block_given? is
+ # measurably true inside #each when reached through to_enum(:each), so no in-method guard
+ # defends anything; the only defence is where the resource lives (pagination/318ae05d,
+ # pagination/b2a85752). Dexpace::Closeable's latch is what phase 2 built for exactly this.
+ #
+ # One drive routine, shared by both views (pagination/71aed9c1) and generic over a per-walk
+ # DRIVE -- an object answering `#call -> Dexpace::Page | nil` that performs one exchange and
+ # holds whatever state the next one needs. The strategy engine's drive holds the next request;
+ # the fetcher front-end's holds the previous page's key (P7-111). The walk owns what is common:
+ # the cap counted in exchanges (PAGE-9), the exhaustion latch that makes end-of-stream probes
+ # idempotent and free (PAGE-7), the current and the one-slot look-ahead page (PAGE-12), and the
+ # release discipline for both (PAGE-15). Not shareable: one walk per iteration (PAGE-8).
+ #
+ # A private_constant, asserted through the views and reached by const_get in its own suite,
+ # with a sig/ mirror because the strict `core` Steep target types every file under lib/.
+ class Walk
+ include Dexpace::Closeable
+
+ # @param drive [#call] the per-walk exchange routine, `#call -> Dexpace::Page | nil`
+ # @param cap [Numeric] PAGE-9's page cap, counted in exchanges, validated by the engine
+ def initialize(drive, cap:)
+ @drive = drive
+ @cap = cap
+ @current = nil
+ @buffered = nil
+ @exchanges = 0
+ @exhausted = false
+ initialize_closeable(owned: true)
+ end
+
+ # @return [Dexpace::Page, nil] the page handed to the consumer and not yet advanced past
+ attr_reader :current
+ # @return [Dexpace::Page, nil] PAGE-12's one-slot look-ahead, filled by a probe
+ attr_reader :buffered
+
+ # The one drive routine. nil after the walk is closed (a closed walk owns no slot to hold a
+ # response in, so a late probe acquires nothing), nil with no exchange once exhausted
+ # (PAGE-7), nil and latched when the cap is reached -- checked BEFORE the exchange, so a cap
+ # of N produces exactly N exchanges (PAGE-9); otherwise one exchange through the drive, whose
+ # nil is the strategy's end-of-stream (PAGE-4). The returned page is the caller's to hold.
+ #
+ # @return [Dexpace::Page, nil]
+ def fetch_next_page
+ return nil if closed? || @exhausted
+ return latch_exhausted if @exchanges >= @cap
+
+ page = @drive.call
+ return latch_exhausted if page.nil?
+
+ @exchanges += 1
+ page
+ end
+
+ # PAGE-12's advance: `page` becomes the held page and the previous one is closed. The new
+ # page is written into the slot BEFORE the previous one is closed, so a close error strands
+ # nothing -- the frame that owns the walk closes it on the way out and finds the new page
+ # held. The close is bare: when the consumer has returned from the previous yield nothing is
+ # in flight, so a close error here is SURFACED and never swallowed (PAGE-15's first clause
+ # does not carve out the advance), and never read off `$!`, which may be a caller's
+ # unrelated in-flight error (P7-105).
+ #
+ # @param page [Dexpace::Page] the page about to be yielded
+ # @return [nil]
+ def hold(page)
+ previous = @current
+ @current = page
+ previous.close unless previous.nil? || previous.equal?(page)
+ nil
+ end
+
+ # PAGE-12's look-ahead. ONE slot: a staged page is never displaced, because overwriting it
+ # would spend a second exchange on one yielded page (PAGE-6) and strand the displaced
+ # response, which is the leak the buffering clause exists to prevent -- a probe that finds a
+ # page staged reads it instead (Pages#more?), so a second call here is a state error.
+ #
+ # @param page [Dexpace::Page] the fetched-but-undelivered page
+ # @return [nil]
+ # @raise [Dexpace::Page::PageStateError] when a page is already staged
+ def buffer(page)
+ unless @buffered.nil?
+ raise PageStateError,
+ "a page is already staged in the walk's look-ahead slot"
+ end
+
+ @buffered = page
+ nil
+ end
+
+ # Empties the look-ahead slot and hands its page over to be held.
+ #
+ # @return [Dexpace::Page, nil]
+ def take_buffered
+ page = @buffered
+ @buffered = nil
+ page
+ end
+
+ private
+
+ def latch_exhausted
+ @exhausted = true
+ nil
+ end
+
+ # PAGE-15's third clause and PAGE-12's "explicit close MUST release both": both slots are
+ # released, the first close failure is held, a second is attached to it as suppressed, and
+ # the first is re-raised -- `raise error, cause: nil`, because it is being carried past a
+ # later rescue (pipeline/7ce4431d). Both slots are cleared before anything can raise, so the
+ # latch and the slots agree whatever happens; the attach is a no-op on a frozen primary
+ # (phase 4b's P4-13), stated rather than worked around.
+ def release
+ pages = [@current, @buffered].compact
+ @current = nil
+ @buffered = nil
+ first = nil #: StandardError?
+ pages.each do |page|
+ page.close
+ rescue ::StandardError => error
+ first.nil? ? first = error : Dexpace.attach_suppressed(first, error)
+ end
+ raise first, cause: nil unless first.nil?
+
+ nil
+ end
+ end
+ private_constant :Walk
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/http/url.rbs b/gems/dexpace-core/sig/dexpace/http/url.rbs
index 5c0f4a4..11d80e3 100644
--- a/gems/dexpace-core/sig/dexpace/http/url.rbs
+++ b/gems/dexpace-core/sig/dexpace/http/url.rbs
@@ -2,6 +2,7 @@ module Dexpace
module URL
def self?.parse!: (String | URI::Generic input) -> URI::Generic
def self?.external_form: (URI::Generic uri) -> String
+ def self?.resolve: (String | URI::Generic base, String reference) -> URI::Generic?
private
diff --git a/gems/dexpace-core/sig/dexpace/page.rbs b/gems/dexpace-core/sig/dexpace/page.rbs
new file mode 100644
index 0000000..0508a2d
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page.rbs
@@ -0,0 +1,56 @@
+module Dexpace
+ # PAGE-2 / PAGE-3: one page of a paginated walk, owning exactly one live Response behind
+ # Dexpace::Closeable's latch; also the namespace of the pagination subsystem (P7-2). The three
+ # duck types the subsystem takes are declared here, nested, the way io.rbs holds _Source and
+ # http/body.rbs holds _ResponseHandler: validated at construction with respond_to?, never by a
+ # nominal test.
+ class Page
+ include Dexpace::Closeable
+
+ # PAGE-4 / PAGE-5: a stateless parser from a response and the original template to an Info.
+ interface _Strategy
+ def parse: (Dexpace::Response response, Dexpace::Request template) -> Dexpace::Page::Info
+ end
+
+ # R7 / P7-6: what the built-in strategies are handed instead of a codec. The cursor strategy
+ # expects `[items, cursor_or_nil]`; the page-number and Link strategies expect the items alone.
+ interface _Extractor
+ def call: (Dexpace::Response response) -> untyped
+ end
+
+ # PAGE-29's caller-supplied executor: one fire-and-forget method. Phase 8's
+ # dexpace-async-thread is the first real implementation; core spawns nothing (R9).
+ interface _Executor
+ def post: () { () -> void } -> void
+ end
+
+ @response: Dexpace::Response
+ @items: Array[untyped]
+ @next_link: String?
+ @continuation_token: String?
+
+ attr_reader response: Dexpace::Response
+ attr_reader items: Array[untyped]
+ attr_reader next_link: String?
+ attr_reader continuation_token: String?
+
+ private def self.new: (Dexpace::Response response, Array[untyped] items, String? next_link, String? continuation_token) -> instance
+ def initialize: (Dexpace::Response response, Array[untyped] items, String? next_link, String? continuation_token) -> void
+
+ def self.build: (response: Dexpace::Response, items: Array[untyped], ?next_link: String?, ?continuation_token: String?) -> Dexpace::Page
+ def self.next_request_from: (Dexpace::Request template, Dexpace::Response response, String? target) -> Dexpace::Request?
+
+ def status: () -> Dexpace::Status
+ def headers: () -> Dexpace::Headers
+ def request: () -> Dexpace::Request
+
+ private
+
+ DISPATCHABLE_SCHEMES: Array[String]
+
+ def self.same_document?: (String target) -> bool
+ def self.dispatchable?: (URI::Generic target) -> bool
+ def optional_string: (String name, untyped value) -> String?
+ def release: () -> void
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/async_paginator.rbs b/gems/dexpace-core/sig/dexpace/page/async_paginator.rbs
new file mode 100644
index 0000000..640e8d9
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/async_paginator.rbs
@@ -0,0 +1,66 @@
+module Dexpace
+ class Page
+ # PAGE-25..PAGE-33: the non-blocking engine, a frozen Data of immutable configuration plus an
+ # optional executor. `transport`, `strategy` and `executor` are untyped because a lambda is a
+ # valid transport, a strategy is any object answering #parse and an executor any answering
+ # #post (respond_to? validates all three).
+ class AsyncPaginator < Data
+ include Model
+
+ attr_reader transport: untyped
+ attr_reader template: Dexpace::Request
+ attr_reader strategy: untyped
+ attr_reader cap: Numeric
+ attr_reader options: Dexpace::RequestOptions
+ attr_reader executor: untyped
+
+ private def self.new: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions, executor: untyped) -> instance
+ def initialize: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions, executor: untyped) -> void
+
+ def self.build: (transport: untyped, template: Dexpace::Request, strategy: untyped, ?cap: Numeric, ?options: Dexpace::RequestOptions, ?executor: untyped) -> AsyncPaginator
+
+ def walk: (untyped consumer, ?cancellation: Dexpace::Cancellation?) -> Dexpace::Async::Future
+ def walk_pages: (untyped consumer, ?cancellation: Dexpace::Cancellation?) -> Dexpace::Async::Future
+
+ # The per-walk trampoline (PAGE-31): a private_constant, declared here because the strict
+ # `core` Steep target types its call sites.
+ class Pump
+ @paginator: AsyncPaginator
+ @consumer: untyped
+ @cancellation: Dexpace::Cancellation
+ @mode: Symbol
+ @completer: Dexpace::Async::Completer
+ @next_request: Dexpace::Request?
+ @exchanges: Integer
+ @pages: Integer
+ @in_flight: Dexpace::Async::Future?
+ @mutex: Thread::Mutex
+ @running: bool
+ @rearm: bool
+
+ def initialize: (AsyncPaginator paginator, untyped consumer, Dexpace::Cancellation cancellation, Symbol mode) -> void
+ def start: () -> Dexpace::Async::Future
+
+ private
+
+ def resume: () -> void
+ def claim: (Symbol step) -> bool
+ def launch: () -> void
+ def deliver: (Dexpace::Async::Settlement settlement) -> void
+ def submit: (untyped response) { () -> void } -> void
+ def settled: (Dexpace::Async::Settlement settlement) -> void
+ def null_success: () -> bool
+ def forward: (Dexpace::Async::Settlement settlement) -> void
+ def build_page: (Dexpace::Response response) -> Dexpace::Page
+ def drain: (Dexpace::Page page) -> void
+ def advance: () -> void
+ def guarded: () { () -> void } -> void
+ end
+
+ private
+
+ def validate_engine!: (untyped transport, untyped template, untyped strategy) -> void
+ def start: (untyped consumer, Dexpace::Cancellation? cancellation, Symbol mode) -> Dexpace::Async::Future
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/closing.rbs b/gems/dexpace-core/sig/dexpace/page/closing.rbs
new file mode 100644
index 0000000..2d6083e
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/closing.rbs
@@ -0,0 +1,12 @@
+# Dexpace::Page::Closing is a private_constant and not public API: this declaration exists
+# because the strict `core` Steep target checks every file under lib/ and needs the module and
+# its two functions declared to type the views', the engines' and the async pump's call sites.
+# RBS has no visibility for a constant, so the privacy lives in lib/dexpace/page/closing.rb alone.
+module Dexpace
+ class Page
+ module Closing
+ def self?.close_walk: (Dexpace::Page::Walk walk, Exception? primary) -> nil
+ def self?.parse_or_close: (untyped strategy, Dexpace::Response response, Dexpace::Request template) -> Dexpace::Page::Info
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs b/gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs
new file mode 100644
index 0000000..1acbc02
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs
@@ -0,0 +1,22 @@
+module Dexpace
+ class Page
+ # PAGE-16: the cursor strategy over a caller-supplied `#call(response) -> [items, cursor]`
+ # extractor (R7, P7-6). `extract` is untyped because a lambda is a valid value.
+ class CursorStrategy < Data
+ include Model
+
+ attr_reader extract: untyped
+ attr_reader parameter: String
+
+ private def self.new: (extract: untyped, parameter: String) -> instance
+ def initialize: (extract: untyped, parameter: String) -> void
+
+ def self.build: (extract: untyped, ?parameter: String) -> CursorStrategy
+ def parse: (Dexpace::Response response, Dexpace::Request template) -> Info
+
+ private
+
+ def unpack: (untyped answer) -> [Array[untyped], String?]
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/fetchers.rbs b/gems/dexpace-core/sig/dexpace/page/fetchers.rbs
new file mode 100644
index 0000000..48387e9
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/fetchers.rbs
@@ -0,0 +1,45 @@
+module Dexpace
+ class Page
+ # PAGE-34 / PAGE-35: the fetcher-based front-end, a frozen Data over two caller callables and
+ # the options threaded to them by identity. Both fetchers are untyped because a lambda is the
+ # ordinary value (respond_to? validates them; Registry.callable? reads their arity).
+ class Fetchers < Data
+ include Model
+
+ attr_reader first: untyped
+ attr_reader next_page: untyped
+ attr_reader options: Dexpace::RequestOptions
+
+ private def self.new: (first: untyped, next_page: untyped, options: Dexpace::RequestOptions) -> instance
+ def initialize: (first: untyped, next_page: untyped, options: Dexpace::RequestOptions) -> void
+
+ def self.build: (first: untyped, next_page: untyped, ?options: Dexpace::RequestOptions) -> Fetchers
+
+ def items: () -> Items
+ def pages: () -> Pages
+ def each_item: () { (untyped) -> void } -> nil
+ def each_page: () { (Dexpace::Page) -> void } -> nil
+
+ # The fetcher front-end's private per-walk drive (P7-111): a private_constant, declared here
+ # because the strict `core` Steep target types its call sites.
+ class Drive
+ @fetchers: Fetchers
+ @started: bool
+ @key: String?
+
+ def initialize: (Fetchers fetchers) -> void
+ def call: () -> Dexpace::Page?
+
+ private
+
+ def fetch_first: () -> untyped
+ def fetch_next: () -> untyped
+ def key_of: (Dexpace::Page page) -> String?
+ end
+
+ private
+
+ def open_walk: () -> Dexpace::Page::Walk
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/info.rbs b/gems/dexpace-core/sig/dexpace/page/info.rbs
new file mode 100644
index 0000000..a4f19ca
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/info.rbs
@@ -0,0 +1,23 @@
+module Dexpace
+ class Page
+ # PAGE-4's PageInfo: items plus the next request, whose nil is the one end-of-stream signal.
+ class Info < Data
+ include Model
+
+ attr_reader items: Array[untyped]
+ attr_reader next_request: Dexpace::Request?
+ attr_reader next_link: String?
+ attr_reader continuation_token: String?
+
+ private def self.new: (items: Array[untyped], next_request: Dexpace::Request?, next_link: String?, continuation_token: String?) -> instance
+ def initialize: (items: Array[untyped], next_request: Dexpace::Request?, next_link: String?, continuation_token: String?) -> void
+
+ def self.build: (items: Array[untyped], next_request: Dexpace::Request?, ?next_link: String?, ?continuation_token: String?) -> Info
+ def self.terminal: (?items: Array[untyped]) -> Info
+
+ private
+
+ def optional_string: (String name, untyped value) -> String?
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/items.rbs b/gems/dexpace-core/sig/dexpace/page/items.rbs
new file mode 100644
index 0000000..de68a92
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/items.rbs
@@ -0,0 +1,16 @@
+module Dexpace
+ class Page
+ # PAGE-1 / PAGE-8 / PAGE-11: the re-iterable item-level view, one fresh walk per #each, every
+ # page closed before its items are yielded. Enumerable's vocabulary drives #each.
+ class Items
+ include ::Enumerable[untyped]
+
+ @opener: untyped
+
+ def initialize: (untyped opener) -> void
+ def each: () { (untyped) -> void } -> self
+ | () -> ::Enumerator[untyped, self]
+ def close: () -> nil
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/link_header.rbs b/gems/dexpace-core/sig/dexpace/page/link_header.rbs
new file mode 100644
index 0000000..c1c1397
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/link_header.rbs
@@ -0,0 +1,26 @@
+# Dexpace::Page::LinkHeader is a private_constant and not public API: this declaration exists
+# because the strict `core` Steep target checks every file under lib/ and needs the module and
+# its one function declared to type LinkStrategy's call site. RBS has no visibility for a
+# constant, so the privacy lives in lib/dexpace/page/link_header.rb alone.
+module Dexpace
+ class Page
+ module LinkHeader
+ OWS: Array[String]
+ SEPARATORS: Array[String]
+ TOKEN_END: Array[String]
+
+ def self?.next_target: (Array[String]? values) -> String?
+
+ private
+
+ def next_token?: (String value) -> bool
+ def link_values: (String text) -> Array[[String, Array[[String, String]]]]
+ def skip: (String text, Integer index, Array[String] set) -> Integer
+ def parse_params: (String text, Integer index) -> [Array[[String, String]], Integer]
+ def parse_param: (String text, Integer index) -> [[String, String]?, Integer]
+ def read_value: (String text, Integer index) -> [String?, Integer]
+ def read_token: (String text, Integer index) -> [String, Integer]
+ def read_quoted: (String text, Integer index) -> [String?, Integer]
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/link_strategy.rbs b/gems/dexpace-core/sig/dexpace/page/link_strategy.rbs
new file mode 100644
index 0000000..28df566
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/link_strategy.rbs
@@ -0,0 +1,18 @@
+module Dexpace
+ class Page
+ # PAGE-18..PAGE-20: the Link-header strategy over a caller-supplied `#call(response) -> items`
+ # extractor; the next request comes from the header through Page.next_request_from.
+ class LinkStrategy < Data
+ include Model
+
+ attr_reader extract_items: untyped
+ attr_reader header: String
+
+ private def self.new: (extract_items: untyped, header: String) -> instance
+ def initialize: (extract_items: untyped, header: String) -> void
+
+ def self.build: (extract_items: untyped, ?header: String) -> LinkStrategy
+ def parse: (Dexpace::Response response, Dexpace::Request template) -> Info
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs b/gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs
new file mode 100644
index 0000000..8cd76dc
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs
@@ -0,0 +1,24 @@
+module Dexpace
+ class Page
+ # PAGE-17: the page-number strategy over a caller-supplied `#call(response) -> items` extractor.
+ class PageNumberStrategy < Data
+ include Model
+
+ DIGITS: Regexp
+
+ attr_reader extract_items: untyped
+ attr_reader parameter: String
+ attr_reader start: Integer
+
+ private def self.new: (extract_items: untyped, parameter: String, start: Integer) -> instance
+ def initialize: (extract_items: untyped, parameter: String, start: Integer) -> void
+
+ def self.build: (extract_items: untyped, ?parameter: String, ?start: Integer) -> PageNumberStrategy
+ def parse: (Dexpace::Response response, Dexpace::Request template) -> Info
+
+ private
+
+ def current_page: (Dexpace::Response response) -> Integer
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/page_state_error.rbs b/gems/dexpace-core/sig/dexpace/page/page_state_error.rbs
new file mode 100644
index 0000000..ace0b8e
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/page_state_error.rbs
@@ -0,0 +1,8 @@
+module Dexpace
+ class Page
+ # PAGE-14: a second #each on a single-use page view; a state error, never an ArgumentError.
+ class PageStateError < ::StandardError
+ include Dexpace::Error
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/pages.rbs b/gems/dexpace-core/sig/dexpace/page/pages.rbs
new file mode 100644
index 0000000..ffb97aa
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/pages.rbs
@@ -0,0 +1,24 @@
+module Dexpace
+ class Page
+ # PAGE-1 / PAGE-12 / PAGE-14 / PAGE-15: the single-use, auto-closing page-level view over one
+ # walk with a one-slot look-ahead. Enumerable's vocabulary drives #each, the one use.
+ class Pages
+ include ::Enumerable[Dexpace::Page]
+
+ @walk: Dexpace::Page::Walk
+ @viewed: bool
+
+ def initialize: (untyped opener) -> void
+ def each: () { (Dexpace::Page) -> void } -> self
+ | () -> ::Enumerator[Dexpace::Page, self]
+ def more?: () -> bool
+ def close: () -> nil
+ def closed?: () -> bool
+
+ private
+
+ def claim!: () -> void
+ def drive: () { (Dexpace::Page) -> void } -> void
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/paginator.rbs b/gems/dexpace-core/sig/dexpace/page/paginator.rbs
new file mode 100644
index 0000000..bdb9776
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/paginator.rbs
@@ -0,0 +1,41 @@
+module Dexpace
+ class Page
+ # PAGE-6..PAGE-10, PAGE-36: the blocking engine, a frozen Data of immutable configuration.
+ # `transport` and `strategy` are untyped because a lambda is a valid transport and a strategy is
+ # any object answering #parse (respond_to? validates both).
+ class Paginator < Data
+ include Model
+
+ attr_reader transport: untyped
+ attr_reader template: Dexpace::Request
+ attr_reader strategy: untyped
+ attr_reader cap: Numeric
+ attr_reader options: Dexpace::RequestOptions
+
+ private def self.new: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions) -> instance
+ def initialize: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions) -> void
+
+ def self.build: (transport: untyped, template: Dexpace::Request, strategy: untyped, ?cap: Numeric, ?options: Dexpace::RequestOptions) -> Paginator
+
+ def items: () -> Items
+ def pages: () -> Pages
+ def each_item: () { (untyped) -> void } -> nil
+ def each_page: () { (Dexpace::Page) -> void } -> nil
+
+ # The strategy engine's private per-walk drive (P7-111): a private_constant, declared here
+ # because the strict `core` Steep target types its call sites.
+ class Drive
+ @paginator: Paginator
+ @next_request: Dexpace::Request?
+
+ def initialize: (Paginator paginator) -> void
+ def call: () -> Dexpace::Page?
+ end
+
+ private
+
+ def validate_engine!: (untyped transport, untyped template, untyped strategy) -> void
+ def open_walk: () -> Dexpace::Page::Walk
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs b/gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs
new file mode 100644
index 0000000..279fe27
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs
@@ -0,0 +1,18 @@
+module Dexpace
+ class Page
+ # PAGE-21..PAGE-24: the verbatim query splice over the raw query substring.
+ module QueryRewriter
+ def self?.get: (String? query, String name) -> String?
+ def self?.set: (String? query, String name, String? value) -> String?
+ def self?.rewrite_url: (URI::Generic uri, String name, String? value) -> URI::Generic
+
+ private
+
+ def splice: (Array[String] segments, String name, String? replacement) -> [Array[String], bool]
+ def segments: (String? query) -> Array[String]
+ def split_segment: (String segment) -> [String, String?]
+ def decode: (String text) -> String
+ def encode: (String text) -> String
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/page/walk.rbs b/gems/dexpace-core/sig/dexpace/page/walk.rbs
new file mode 100644
index 0000000..485bdb1
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/page/walk.rbs
@@ -0,0 +1,32 @@
+# Dexpace::Page::Walk is a private_constant and not public API: this declaration exists because
+# the strict `core` Steep target checks every file under lib/ and needs the class, its ivars and
+# its readers declared to type the two views and the two engines. RBS has no visibility for a
+# constant, so the privacy lives in lib/dexpace/page/walk.rb alone.
+module Dexpace
+ class Page
+ class Walk
+ include Dexpace::Closeable
+
+ @drive: untyped
+ @cap: Numeric
+ @current: Dexpace::Page?
+ @buffered: Dexpace::Page?
+ @exchanges: Integer
+ @exhausted: bool
+
+ attr_reader current: Dexpace::Page?
+ attr_reader buffered: Dexpace::Page?
+
+ def initialize: (untyped drive, cap: Numeric) -> void
+ def fetch_next_page: () -> Dexpace::Page?
+ def hold: (Dexpace::Page page) -> nil
+ def buffer: (Dexpace::Page page) -> nil
+ def take_buffered: () -> Dexpace::Page?
+
+ private
+
+ def latch_exhausted: () -> nil
+ def release: () -> nil
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/http/url_test.rb b/gems/dexpace-core/test/dexpace/http/url_test.rb
index b59fbc7..98e3a15 100644
--- a/gems/dexpace-core/test/dexpace/http/url_test.rb
+++ b/gems/dexpace-core/test/dexpace/http/url_test.rb
@@ -84,4 +84,64 @@ class DexpaceUrlTest < DexpaceTestCase
assert_equal(url.to_s, Dexpace::URL.external_form(url))
end
+
+ # PAGE-19 (phase 7c, P7-3): RFC 3986 reference resolution against the originating page's
+ # response URL, added beside .parse! so the parser pin lives in one file. The RFC 2396
+ # behaviour -- dropping the last path segment for a query-only reference -- is the one the
+ # requirement forbids in as many words, so it gets its own assertion. A nested class rather than
+ # a second top-level one: Style/OneClassPerFile is live under NewCops.
+ class ResolveTest < DexpaceTestCase
+ BASE = Dexpace::URL.parse!("https://api.example.com/repo/issues?page=1")
+
+ test "PAGE-19: a query-only reference preserves the base's full path" do
+ assert_equal("https://api.example.com/repo/issues?page=2",
+ Dexpace::URL.external_form(Dexpace::URL.resolve(BASE, "?page=2")),)
+ end
+
+ test "PAGE-19: an absolute target is used as-is and a relative one resolves" do
+ assert_equal("https://other.example/x",
+ Dexpace::URL.external_form(Dexpace::URL.resolve(BASE, "https://other.example/x")),)
+ assert_equal("https://api.example.com/users?page=2",
+ Dexpace::URL.external_form(Dexpace::URL.resolve(BASE, "../users?page=2")),)
+ assert_equal("https://other.example/p",
+ Dexpace::URL.external_form(Dexpace::URL.resolve(BASE, "//other.example/p")),)
+ end
+
+ test "PAGE-19: a target that cannot resolve returns nil rather than raising" do
+ assert_nil(Dexpace::URL.resolve(BASE, "not a url"))
+ assert_nil(Dexpace::URL.resolve(BASE, "http://[bad"))
+ assert_nil(Dexpace::URL.resolve(BASE, " "))
+ end
+
+ test "the base may be a String or a URI, the result is frozen, and the base is untouched" do
+ from_string = Dexpace::URL.resolve("https://h/v1/x", "y")
+ from_uri = Dexpace::URL.resolve(BASE, "y")
+
+ assert_equal("https://h/v1/y", Dexpace::URL.external_form(from_string))
+ assert_equal("https://api.example.com/repo/y", Dexpace::URL.external_form(from_uri))
+ assert_predicate(from_uri, :frozen?)
+ assert_equal("https://api.example.com/repo/issues?page=1", Dexpace::URL.external_form(BASE))
+ end
+
+ test "a nil or non-String reference is the caller's mistake, named, not an end-of-stream" do
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::URL.resolve(BASE, nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::URL.resolve(BASE, :next) }
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::URL.resolve(nil, "y") }
+ end
+
+ test "a base that is neither a String nor a URI is refused by class, naming what arrived" do
+ error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::URL.resolve(:base, "y") }
+
+ assert_match(/base must be a String or a URI, got Symbol/, error.message)
+ end
+
+ test "resolution is a successful join even when the result is not dispatchable" do
+ # join hands back a mailto:, a host-less http:foo and an empty-host http:///p as SUCCESSFUL
+ # resolutions (6b's REDIR-18 finding, P6-95); the dispatchability screen is the caller's --
+ # Dexpace::Page.next_request_from's -- and never this function's.
+ refute_nil(Dexpace::URL.resolve(BASE, "mailto:a@b"))
+ refute_nil(Dexpace::URL.resolve(BASE, "http:foo"))
+ assert_nil(Dexpace::URL.resolve(BASE, "http:foo").host)
+ end
+ end
end
diff --git a/gems/dexpace-core/test/dexpace/page/async_paginator_test.rb b/gems/dexpace-core/test/dexpace/page/async_paginator_test.rb
new file mode 100644
index 0000000..01ae365
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/async_paginator_test.rb
@@ -0,0 +1,465 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+require_relative "../../support/probe_executor"
+require_relative "../../support/inline_executor"
+
+# Exercises: PAGE-25 through PAGE-33, PAGE-6's async half (R10), PAGE-1's async half (#walk_pages),
+# PAGE-13 and PAGE-27 on the async paths; R9.
+#
+# R9: this engine calls no wait of any kind -- not Clock#sleep, not Async.delay -- because not one
+# of PAGE-25..PAGE-33 computes or requests a delay. P5-9's no-scheduler SeamError is unreachable
+# from here, and the default mode needs no scheduler, no executor and no thread: the driver is a
+# callback pump on Future#on_settle, run on the settling thread, which is PAGE-29's stated default.
+# Every future here is phase 2's real Completer; every response a real Dexpace::Response.
+class DexpacePageAsyncPaginatorTest < DexpaceTestCase
+ include PageFixtures
+
+ # A bounded wait: "hangs" is PAGE-32's failure mode, and Future#value's deadline settles the
+ # future as cancelled (:deadline_expired) instead of hanging the suite.
+ def bounded_value(future, seconds: 5.0)
+ future.value(deadline: Dexpace::Clock::SYSTEM.monotonic + seconds)
+ end
+
+ def consumed(async, method = :walk)
+ seen = []
+ future = async.public_send(method, ->(item) { seen << item })
+ [future, seen]
+ end
+
+ test "PAGE-6 (non-blocking engine): construction is inert; invoking walk fetches immediately" do
+ async = async_paginator_over([[1]], deferred: true)
+
+ assert_equal(0, async.transport.calls.size)
+ future = async.walk(->(_item) {})
+
+ assert_equal(1, async.transport.calls.size)
+ refute_predicate(future, :settled?)
+ async.transport.settle_next!
+
+ assert_equal(1, bounded_value(future))
+ end
+
+ test "PAGE-6: one exchange per page consumed, and the future settles with the page count" do
+ async = async_paginator_over([[1, 2], [3], []])
+ future, seen = consumed(async)
+
+ assert_equal(3, bounded_value(future))
+ assert_equal([1, 2, 3], seen)
+ assert_equal(3, async.transport.calls.size)
+ end
+
+ test "PAGE-29: items are delivered one at a time, in server order, never concurrently" do
+ seen = []
+ depth = 0
+ max = 0
+ future = async_paginator_over([[1, 2], [3]]).walk(lambda { |item|
+ depth += 1
+ max = [max, depth].max
+ seen << item
+ depth -= 1
+ })
+ bounded_value(future)
+
+ assert_equal([1, 2, 3], seen)
+ assert_equal(1, max)
+ end
+
+ test "PAGE-29: with an executor, the driver and every consumer invocation run on it" do
+ executor = ProbeExecutor.new(mode: :queued)
+ threads = []
+ async = async_paginator_over([[1], [2]], executor: executor)
+ future = async.walk(->(_item) { threads << ::Thread.current })
+
+ assert_equal(0, async.transport.calls.size) # nothing ran yet: the driver is queued
+ executor.drain
+
+ assert_equal(2, bounded_value(future))
+ assert_operator(executor.posts, :>=, 3) # the first dispatch, then one continuation per page
+ assert_equal(2, threads.size)
+ assert(threads.all? { |thread| executor.threads.include?(thread) })
+ refute_includes(threads, ::Thread.current)
+ end
+
+ test "PAGE-1 (async): #walk_pages delivers whole live pages, with status, headers and request" do
+ # PAGE-1 is not qualified by engine, and PAGE-27 names the page-level drain in as many words
+ # ("after the page is drained to the consumer (item- or page-level)"). Same pump, same
+ # exactly-once close -- the only difference is what the consumer is handed.
+ seen = []
+ future = async_paginator_over([[1, 2], [3]]).walk_pages(lambda { |page|
+ url = Dexpace::URL.external_form(page.request.url)
+ seen << [page.items, page.status.code, page.closed?, url]
+ })
+
+ assert_equal(2, bounded_value(future))
+ assert_equal([[[1, 2], 200, false, "https://x/i"], [[3], 200, false, "https://x/i?p=1"]], seen)
+ end
+
+ test "PAGE-27 (async, page-level): the page is closed after the consumer returns, exactly once" do
+ probes = [fake_response_body, fake_response_body]
+ observed = []
+ future = async_paginator_over([[1], [2]], bodies: probes).walk_pages(lambda { |_page|
+ observed << probes.map(&:closes)
+ })
+ bounded_value(future)
+
+ assert_equal([[0, 0], [1, 0]], observed) # live while the consumer holds it
+ assert_equal([1, 1], probes.map(&:closes))
+ end
+
+ # PAGE-25, PAGE-26 and PAGE-33: the abort, the page boundary and the documented race.
+ class CancellationTest < DexpaceTestCase
+ include PageFixtures
+
+ def bounded_value(future) = future.value(deadline: Dexpace::Clock::SYSTEM.monotonic + 5.0)
+
+ def consumed(async)
+ seen = []
+ future = async.walk(->(item) { seen << item })
+ [future, seen]
+ end
+
+ test "PAGE-25: cancelling the result future halts the walk and cancels the in-flight call" do
+ async = async_paginator_over([[1]] * 5, deferred: true)
+ future = async.walk(->(_item) {})
+ in_flight = async.transport.pending.first.first
+
+ future.cancel(:test)
+
+ assert_predicate(future, :cancelled?)
+ assert_predicate(in_flight.future, :cancelled?)
+ async.transport.settle_next! # the late response meets an already-settled completer
+
+ assert_equal(1, async.transport.calls.size)
+ end
+
+ test "PAGE-25: no worker thread blocks per page -- the walk completes on the caller's thread" do
+ before = ::Thread.list.size
+ future, seen = consumed(async_paginator_over([[1], [2], [3]]))
+
+ assert_equal(3, bounded_value(future))
+ assert_equal([1, 2, 3], seen)
+ assert_equal(before, ::Thread.list.size)
+ end
+
+ test "PAGE-33: a response delivered after the abort is closed by the TRANSPORT's completer" do
+ # PAGE-33's first half, as built: Completer#fulfil on an already-cancelled completer returns
+ # false and closes the response itself (SEAM-30) -- the response never reaches this engine.
+ body = fake_response_body
+ async = async_paginator_over([[1]], bodies: [body], deferred: true)
+ future = async.walk(->(_item) { flunk("never delivered") })
+ future.cancel(:test)
+
+ async.transport.settle_next!
+
+ assert_equal(1, body.closes)
+ assert_predicate(future, :cancelled?)
+ end
+
+ test "PAGE-26: a page parsed after the walk settled is dropped undrained AND closed, quietly" do
+ # PAGE-33's second half and PAGE-26's drop, on THIS engine's path: the cancel lands between
+ # the parse and the drain -- here from inside the strategy -- so the built page is staged on
+ # an already-settled walk, closed through close_quietly, its close error swallowed, never
+ # drained.
+ body = fake_response_body(close_error: IOError.new("boom"))
+ holder = []
+ strategy = Class.new do
+ define_method(:parse) do |response, template|
+ holder.first.cancel(:mid_parse)
+ PageFixtures::ScriptedStrategy.new([[1]]).parse(response, template)
+ end
+ end.new
+ async = async_paginator_over([[1]], bodies: [body], strategy: strategy, deferred: true)
+ future = async.walk(->(_item) { flunk("dropped pages are never drained") })
+ holder << future
+ async.transport.settle_next!
+
+ assert_equal(1, body.closes)
+ assert_predicate(future, :cancelled?)
+ error = assert_raises(Dexpace::CancelledError) { bounded_value(future) }
+
+ assert_equal(:mid_parse, error.reason) # the cancellation, not the IOError, is the outcome
+ end
+
+ test "PAGE-26: a cancel from inside the drain lets the current page finish, then stops" do
+ seen = []
+ holder = []
+ async = async_paginator_over([[1, 2], [3]], deferred: true)
+ future = async.walk(lambda { |item|
+ seen << item
+ holder.first.cancel(:mid_drain) if item == 1
+ })
+ holder << future
+ async.transport.settle_next!
+
+ assert_equal([1, 2], seen) # item 2 still reached the consumer; page 2 was never fetched
+ assert_equal(1, async.transport.calls.size)
+ assert_predicate(future, :cancelled?)
+ assert_equal(:mid_drain, assert_raises(Dexpace::CancelledError) do
+ bounded_value(future)
+ end.reason,)
+ end
+ end
+
+ # PAGE-28's failure modes, each completing the future exceptionally with the ORIGINAL cause.
+ class FailureTest < DexpaceTestCase
+ include PageFixtures
+
+ def bounded_value(future) = future.value(deadline: Dexpace::Clock::SYSTEM.monotonic + 5.0)
+
+ test "PAGE-28: a consumer throw fails the walk with the original cause, unwrapped" do
+ cause = KeyError.new("k")
+ future = async_paginator_over([[1]]).walk(->(_item) { raise cause })
+
+ error = assert_raises(KeyError) { bounded_value(future) }
+
+ assert_same(cause, error)
+ end
+
+ test "PAGE-28: a transport failure settles the walk with the same error object" do
+ cause = IOError.new("wire")
+ transport = ScriptedAsyncTransport.new([cause])
+ future = async_paginator_over([[1]], transport: transport).walk(->(_item) {})
+
+ assert_same(cause, assert_raises(IOError) { bounded_value(future) })
+ end
+
+ test "PAGE-28 / PAGE-13: a parse failure fails the walk and closes the response inline" do
+ body = fake_response_body
+ cause = KeyError.new("parse")
+ future = async_paginator_over([[1]], bodies: [body], strategy: raising_strategy(cause))
+ .walk(->(_item) {})
+
+ assert_same(cause, assert_raises(KeyError) { bounded_value(future) })
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-28: a transport that eagerly throws is handled as a failed walk" do
+ transport = ->(_request, _options, _cancellation) { raise IOError, "eager" }
+ future = async_paginator_over([[1]], transport: transport).walk(->(_item) {})
+
+ assert_equal("eager", assert_raises(IOError) { bounded_value(future) }.message)
+ end
+
+ test "PAGE-28: a transport that answers a non-future is a failed walk, not a NoMethodError" do
+ transport = ->(request, _options, _cancellation) { page_response(request: request) }
+ future = async_paginator_over([[1]], transport: transport).walk(->(_item) {})
+
+ assert_raises(Dexpace::InvalidArgumentError) { bounded_value(future) }
+ end
+
+ test "PAGE-28: a cancellation settled by the transport is forwarded as a cancellation" do
+ transport = ScriptedAsyncTransport.new([->(_r, _o, _c) {}], settle_later: true)
+ future = async_paginator_over([[1]], transport: transport).walk(->(_item) {})
+ transport.pending.first.first.request_cancel(:upstream)
+
+ assert_predicate(future, :cancelled?)
+ assert_equal(:upstream, assert_raises(Dexpace::CancelledError) do
+ bounded_value(future)
+ end.reason,)
+ end
+
+ test "PAGE-28: a null success terminates the walk exceptionally, though Settlement bars it" do
+ # Settlement's own validation makes a nil response with a nil error unconstructible, so the
+ # arm is driven the only way it can be reached: the private pump handed a duck settlement.
+ pump_class = Dexpace::Page::AsyncPaginator.const_get(:Pump)
+ pump = pump_class.new(async_paginator_over([[1]]), ->(_item) {}, Dexpace::Cancellation.none,
+ :items,)
+ pump.send(:settled, Struct.new(:response, :error).new(nil, nil))
+
+ error = assert_raises(Dexpace::SeamError) do
+ bounded_value(pump.instance_variable_get(:@completer).future)
+ end
+
+ assert_match(/settled with nothing/, error.message)
+ end
+
+ test "PAGE-28 / RECOV-2: a fatal-family consumer error fails the walk AND propagates" do
+ body = fake_response_body
+ async = async_paginator_over([[1]], bodies: [body])
+ fatal = NoMemoryError.new("f")
+ future = nil
+
+ raised = assert_raises(NoMemoryError) { future = async.walk(->(_item) { raise fatal }) }
+
+ assert_same(fatal, raised)
+ assert_nil(future)
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-32: a throwing close on the SUCCESS path fails the future rather than hanging" do
+ body = fake_response_body(close_error: IOError.new("boom"))
+ future = async_paginator_over([[1]], bodies: [body]).walk(->(_item) {})
+
+ assert_equal("boom", assert_raises(IOError) { bounded_value(future) }.message)
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-32: a failed consumer's cause stays primary; the close error is swallowed" do
+ body = fake_response_body(close_error: IOError.new("close"))
+ cause = KeyError.new("consumer")
+ future = async_paginator_over([[1]], bodies: [body]).walk(->(_item) { raise cause })
+
+ assert_same(cause, assert_raises(KeyError) { bounded_value(future) })
+ assert_empty(Dexpace.suppressed(cause))
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-30: an executor rejecting a re-dispatch fails the walk and closes the staged page" do
+ executor = ProbeExecutor.new(mode: :rejecting, after: 2) # post 1: the driver; post 2: page 1
+ staged = fake_response_body
+ seen = []
+ async = async_paginator_over([[1], [2]], bodies: [fake_response_body, staged],
+ executor: executor,)
+ future = async.walk(->(item) { seen << item })
+
+ assert_raises(ProbeExecutor::Rejected) { bounded_value(future) }
+ assert_equal([1], seen)
+ assert_equal(1, staged.closes)
+ assert_equal(2, async.transport.calls.size) # page 2 fetched, then its continuation refused
+ end
+
+ test "PAGE-30: an executor that rejects the FIRST dispatch fails the walk with the rejection" do
+ executor = ProbeExecutor.new(mode: :rejecting, after: 0)
+ async = async_paginator_over([[1]], executor: executor)
+
+ assert_raises(ProbeExecutor::Rejected) { bounded_value(async.walk(->(_item) {})) }
+ assert_equal(0, async.transport.calls.size)
+ end
+ end
+
+ # PAGE-31's trampoline and PAGE-27's exactly-once across the four paths.
+ class TrampolineTest < DexpaceTestCase
+ include PageFixtures
+
+ def bounded_value(future) = future.value(deadline: Dexpace::Clock::SYSTEM.monotonic + 30.0)
+
+ test "PAGE-31: thousands of synchronously-completed pages, no stack growth, both paths" do
+ # Measured through the real Completer: a pump that re-enters itself from inside on_settle
+ # overflows at ~2,600 pages on every interpreter; the re-arm loop is flat.
+ pages = Array.new(3_000) { [1] }
+ sampled = [1, 3_000]
+ [nil, InlineExecutor.new].each do |executor|
+ depths = []
+ count = 0
+ future = async_paginator_over(pages, executor: executor).walk(lambda { |_item|
+ count += 1
+ depths << caller.size if sampled.include?(count)
+ })
+
+ assert_equal(3_000, bounded_value(future))
+ assert_equal(3_000, count)
+ assert_equal(depths.first, depths.last, "the stack grew between page 1 and page 3,000")
+ end
+ end
+
+ test "PAGE-27: each page's response closes exactly once on all four paths" do
+ normal = [fake_response_body, fake_response_body]
+ bounded_value(async_paginator_over([[1], [2]], bodies: normal).walk(->(_item) {}))
+
+ parse = fake_response_body
+ failing = async_paginator_over([[1]], bodies: [parse],
+ strategy: raising_strategy(KeyError.new("p")),)
+ assert_raises(KeyError) { bounded_value(failing.walk(->(_item) {})) }
+
+ cancelled = fake_response_body
+ holder = []
+ mid = Class.new do
+ define_method(:parse) do |response, template|
+ holder.first.cancel(:c)
+ PageFixtures::ScriptedStrategy.new([[1]]).parse(response, template)
+ end
+ end.new
+ deferred = async_paginator_over([[1]], bodies: [cancelled], strategy: mid, deferred: true)
+ holder << deferred.walk(->(_item) {})
+ deferred.transport.settle_next!
+
+ rejected = fake_response_body
+ executor = ProbeExecutor.new(mode: :rejecting, after: 2)
+ refused = async_paginator_over([[1], [2]], bodies: [fake_response_body, rejected],
+ executor: executor,)
+ assert_raises(ProbeExecutor::Rejected) { bounded_value(refused.walk(->(_item) {})) }
+
+ assert_equal([1, 1, 1, 1, 1],
+ [*normal.map(&:closes), parse.closes, cancelled.closes, rejected.closes],)
+ end
+ end
+
+ # Construction and the token bridge.
+ class ConstructionTest < DexpaceTestCase
+ include PageFixtures
+
+ def bounded_value(future) = future.value(deadline: Dexpace::Clock::SYSTEM.monotonic + 5.0)
+
+ test "HTTP-4: the executor is nil or answers #post; the consumer must be callable" do
+ assert_raises(Dexpace::InvalidArgumentError) { async_paginator_over([[1]], executor: :pool) }
+ assert_raises(Dexpace::InvalidArgumentError) { async_paginator_over([[1]], cap: 0) }
+ async = async_paginator_over([[1]])
+
+ assert_raises(Dexpace::InvalidArgumentError) { async.walk(nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { async.walk_pages(:consumer) }
+ assert_raises(Dexpace::InvalidArgumentError) { async.walk(->(_i) {}, cancellation: :token) }
+ refute_respond_to(Dexpace::Page::AsyncPaginator, :new)
+ assert_predicate(async, :frozen?)
+ assert_nil(async.executor)
+ end
+
+ test "HTTP-4 / P7-107: the engine's own shape checks name what arrived, by class" do
+ refusals = [
+ [{ transport: :none }, /transport must respond to #call, got Symbol/],
+ [{ template: "https://x/i" }, /template must be a Dexpace::Request, got String/],
+ [{ strategy: :cursor }, /strategy must respond to #parse, got Symbol/],
+ [{ options: {} }, /options must be a Dexpace::RequestOptions, got Hash/],
+ ]
+ refusals.each do |overrides, pattern|
+ error = assert_raises(Dexpace::InvalidArgumentError) do
+ async_paginator_over([[1]], **overrides)
+ end
+
+ assert_match(pattern, error.message)
+ end
+ end
+
+ test "a caller's cancellation token aborts the walk and cancels the in-flight exchange" do
+ source = Dexpace::Cancellation.source
+ async = async_paginator_over([[1]] * 3, deferred: true)
+ future = async.walk(->(_item) {}, cancellation: source.token)
+
+ assert_same(source.token, async.transport.calls.first.last)
+ source.cancel(:caller)
+
+ assert_predicate(future, :cancelled?)
+ assert_predicate(async.transport.pending.first.first.future, :cancelled?)
+ assert_equal(:caller, assert_raises(Dexpace::CancelledError) { bounded_value(future) }.reason)
+ end
+
+ test "the token subscription detaches when the walk settles; a long token holds nothing" do
+ source = Dexpace::Cancellation.source
+ async = async_paginator_over([[1]])
+
+ assert_equal(1, bounded_value(async.walk(->(_item) {}, cancellation: source.token)))
+ assert_equal(0, source.instance_variable_get(:@hooks).size)
+ end
+
+ test "PAGE-36 / PAGE-9: the options reach every exchange; the cap bounds the async walk too" do
+ options = Dexpace::RequestOptions.builder.tap { |b| b.timeout = 2.0 }.build
+ transport = ScriptedAsyncTransport.new(page_script(20))
+ async = async_paginator_over([], transport: transport, strategy: EndlessStrategy.new, cap: 3,
+ options: options,)
+
+ assert_equal(3, bounded_value(async.walk(->(_item) {})))
+ assert_equal(3, transport.calls.size)
+ transport.calls.each { |(_request, opts, _cancellation)| assert_same(options, opts) }
+ end
+
+ test "R9: the engine calls no wait -- no Clock#sleep, no Async.delay -- and no scheduler" do
+ path = File.expand_path("../../../lib/dexpace/page/async_paginator.rb", __dir__)
+ code = File.readlines(path).reject { |line| line.lstrip.start_with?("#") }.join
+
+ refute_match(/Async\.delay|\.sleep|Fiber\.scheduler|Fiber\.set_scheduler|Thread\.new/, code)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/cursor_strategy_test.rb b/gems/dexpace-core/test/dexpace/page/cursor_strategy_test.rb
new file mode 100644
index 0000000..7121ff9
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/cursor_strategy_test.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+require_relative "../../support/recording_body"
+
+# Exercises: PAGE-16 and PAGE-5 (asserted), PAGE-4's "never by throwing"; R7 / P7-6.
+#
+# The strategy takes a caller-supplied #call(response) extractor, NEVER a serializer duck type
+# and never a witness -- a keyword naming one would put that word inside lib/dexpace/page/** and
+# boundary 5's audit would fail on this sub-phase's own code. PAGE-16's "single read of the
+# response body" is therefore the EXTRACTOR's read; core performs none, and the read-counting
+# RecordingBody below is what makes that checkable. Every response is a REAL Dexpace::Response.
+class DexpacePageCursorStrategyTest < DexpaceTestCase
+ include PageFixtures
+
+ Strategy = Dexpace::Page::CursorStrategy
+
+ test "PAGE-16: cursor 'c' derives a next request with cursor=c on the template" do
+ strategy = Strategy.build(extract: ->(_r) { [[1, 2], "c"] })
+ info = strategy.parse(page_response, fake_request(url: "https://x/i?page=1"))
+
+ assert_equal([1, 2], info.items)
+ assert_equal("page=1&cursor=c", info.next_request.url.query)
+ assert_equal("c", info.continuation_token)
+ end
+
+ test "PAGE-16: a null OR empty next cursor is end-of-stream" do
+ [nil, ""].each do |cursor|
+ strategy = Strategy.build(extract: ->(_r) { [[1], cursor] })
+ info = strategy.parse(page_response, fake_request)
+
+ assert_nil(info.next_request)
+ assert_equal([1], info.items)
+ assert_nil(info.continuation_token)
+ end
+ end
+
+ test "PAGE-16: a later cursor REPLACES the earlier one in place, never appends a second" do
+ strategy = Strategy.build(extract: ->(_r) { [[], "c2"] })
+ info = strategy.parse(page_response, fake_request(url: "https://x/i?cursor=c1&sort=asc"))
+
+ assert_equal("cursor=c2&sort=asc", info.next_request.url.query)
+ end
+
+ test "PAGE-16: the configurable parameter name defaults to cursor" do
+ assert_equal("cursor", Strategy.build(extract: ->(_r) { [[], nil] }).parameter)
+ strategy = Strategy.build(extract: ->(_r) { [[], "c"] }, parameter: "after")
+ info = strategy.parse(page_response, fake_request(url: "https://x/i"))
+
+ assert_equal("after=c", info.next_request.url.query)
+ end
+
+ test "PAGE-16: core reads the body exactly zero times; the extractor reads it once" do
+ body = RecordingBody.new("payload")
+ untouched = RecordingBody.new("payload")
+ strategy = Strategy.build(extract: lambda { |r|
+ r.body.source
+ [[], nil]
+ })
+
+ strategy.parse(page_response(body: body), fake_request)
+ Strategy.build(extract: lambda { |_r|
+ [[], nil]
+ }).parse(page_response(body: untouched), fake_request)
+
+ assert_equal(1, body.source_count)
+ assert_equal(0, untouched.source_count)
+ assert_equal(0, untouched.release_count)
+ end
+
+ test "PAGE-16: an extractor reading through Response#body_string is single-use by 3b's body" do
+ # A real ResponseBody raises Dexpace::ClosedError on a second body_string (measured): the
+ # single read is enforced by the body wherever the extractor reads through the decode boundary.
+ real = Dexpace::ResponseBody.new(source: Dexpace::IO::BufferedSource.of_bytes("c".b))
+ response = page_response(body: real)
+ strategy = Strategy.build(extract: ->(r) { [[], r.body_string] })
+
+ assert_equal("cursor=c", strategy.parse(response, fake_request).next_request.url.query)
+ assert_raises(Dexpace::ClosedError) { strategy.parse(response, fake_request) }
+ end
+
+ test "PAGE-5: parse closes nothing, mutates nothing, and the strategy retains nothing" do
+ response = page_response
+ strategy = Strategy.build(extract: ->(_r) { [[], nil] })
+ strategy.parse(response, fake_request)
+
+ assert_equal(0, closes_of(response))
+ assert_empty(strategy.instance_variables) # a Data holds no ivars at all (measured)
+ assert_predicate(strategy, :frozen?)
+ end
+
+ test "PAGE-5: one instance serves two walks with different templates and responses" do
+ strategy = Strategy.build(extract: ->(r) { [[r.status.code], "c"] })
+ a = strategy.parse(page_response(status: 200), fake_request(url: "https://a/x"))
+ b = strategy.parse(page_response(status: 201), fake_request(url: "https://b/y?q=1"))
+
+ assert_equal([[200], "https://a/x?cursor=c"], [a.items, Dexpace::URL.external_form(a.next_request.url)])
+ assert_equal([[201], "https://b/y?q=1&cursor=c"],
+ [b.items, Dexpace::URL.external_form(b.next_request.url)],)
+ end
+
+ test "PAGE-4: an extractor that raises is a parse failure, not an end-of-stream signal" do
+ strategy = Strategy.build(extract: ->(_r) { raise KeyError, "no items" })
+
+ assert_raises(KeyError) { strategy.parse(page_response, fake_request) }
+ end
+
+ test "the extractor's answer is validated: a pair, items an Array, cursor a String or nil" do
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Strategy.build(extract: ->(_r) { [1] }).parse(page_response, fake_request)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Strategy.build(extract: ->(_r) { "items" }).parse(page_response, fake_request)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Strategy.build(extract: ->(_r) { [nil, "c"] }).parse(page_response, fake_request)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Strategy.build(extract: ->(_r) { [[], 42] }).parse(page_response, fake_request)
+ end
+ end
+
+ test "HTTP-3 / HTTP-4: .build validates, .new is private, #with re-validates" do
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract: nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract: :not_callable) }
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract: ->(_r) {}, parameter: "") }
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract: ->(_r) {}, parameter: 1) }
+ refute_respond_to(Strategy, :new)
+
+ strategy = Strategy.build(extract: ->(_r) { [[], nil] })
+
+ assert_equal("after", strategy.with(parameter: "after").parameter)
+ assert_raises(Dexpace::InvalidArgumentError) { strategy.with(parameter: nil) }
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/fetchers_test.rb b/gems/dexpace-core/test/dexpace/page/fetchers_test.rb
new file mode 100644
index 0000000..40a7105
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/fetchers_test.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-34, PAGE-35 (vacuous by construction, design §12's own authority), PAGE-3's
+# ownership transfer at a fetcher, and the same two views over the fetcher-driven walk.
+class DexpacePageFetchersTest < DexpaceTestCase
+ include PageFixtures
+
+ Fetchers = Dexpace::Page::Fetchers
+ NEVER = ->(_key) { raise "the next-page fetcher must not be called" }
+
+ test "PAGE-34: the first-page fetcher is called exactly once" do
+ calls = 0
+ first = lambda do
+ calls += 1
+ page_with(items: [1])
+ end
+ front = Fetchers.build(first: first, next_page: NEVER)
+
+ assert_equal([1], front.items.to_a)
+ assert_equal(1, calls)
+ end
+
+ test "PAGE-34: subsequent pages key off the previous page's next link; next link WINS" do
+ keys = []
+ next_page = lambda do |key|
+ keys << key
+ page_with(items: [2])
+ end
+ first = -> { page_with(items: [1], next_link: "L", continuation_token: "T") }
+ front = Fetchers.build(first: first, next_page: next_page)
+
+ assert_equal([1, 2], front.items.to_a)
+ assert_equal(["L"], keys)
+ end
+
+ test "PAGE-34: with no next link, the continuation token is the fallback" do
+ keys = []
+ next_page = lambda do |key|
+ keys << key
+ keys.size == 1 ? page_with(items: [2], next_link: " ", continuation_token: "T2") : nil
+ end
+ front = Fetchers.build(first: -> { page_with(items: [1], continuation_token: "T") },
+ next_page: next_page,)
+
+ assert_equal([1, 2], front.items.to_a)
+ assert_equal(%w[T T2], keys) # a blank link falls back to the token, both times
+ end
+
+ test "PAGE-34: a blank next link with no fallback token ends the stream" do
+ spaces = Fetchers.build(first: -> { page_with(items: [1], next_link: " ") }, next_page: NEVER)
+ empty = Fetchers.build(first: -> { page_with(items: [1], next_link: "") }, next_page: NEVER)
+
+ assert_equal([1], spaces.items.to_a)
+ assert_equal([1], empty.items.to_a)
+ end
+
+ test "PAGE-34: a nil page from either fetcher ends the stream; a nil FIRST page yields empty" do
+ assert_empty(Fetchers.build(first: -> {}, next_page: NEVER).items.to_a)
+ first = -> { page_with(items: [1], next_link: "L") }
+ front = Fetchers.build(first: first, next_page: ->(_key) {})
+
+ assert_equal([1], front.items.to_a)
+ end
+
+ test "PAGE-34 / PAGE-3: a fetcher's page owns its response; the front-end closes it" do
+ body = fake_response_body
+ front = Fetchers.build(first: -> { page_with(items: [1], body: body) }, next_page: ->(_key) {})
+ seen = []
+ front.each_page { |_page| seen << body.closes }
+
+ assert_equal([0], seen)
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-34: a fetcher's raise propagates through the walk, and a non-Page answer is refused" do
+ raising = Fetchers.build(first: -> { raise KeyError, "k" }, next_page: NEVER)
+ wrong = Fetchers.build(first: -> { page_response }, next_page: NEVER)
+
+ assert_raises(KeyError) { raising.items.to_a }
+ assert_raises(Dexpace::InvalidArgumentError) { wrong.items.to_a }
+ end
+
+ test "PAGE-34 / P7-107: a page built with a non-String key fails named, before it is keyed" do
+ # Page.build refuses the Integer inside the fetcher, so the walk sees the caller's mistake by
+ # name; without that check the front-end's key_of would die with NoMethodError (strip).
+ front = Fetchers.build(first: -> { page_with(items: [1], next_link: 42) }, next_page: NEVER)
+
+ error = assert_raises(Dexpace::InvalidArgumentError) { front.items.to_a }
+
+ assert_match(/next_link must be a String or nil, got Integer/, error.message)
+ end
+
+ # PAGE-35's vacuity, the views over the fetcher-driven walk, and construction.
+ class OptionsAndViewsTest < DexpaceTestCase
+ include PageFixtures
+
+ Fetchers = Dexpace::Page::Fetchers
+ NEVER = ->(_key) { raise "the next-page fetcher must not be called" }
+
+ test "PAGE-35: the SAME options instance is threaded through every fetcher call" do
+ options = Dexpace::RequestOptions.builder.tap { |b| b.timeout = 1.0 }.build
+ seen = []
+ first = lambda do |o|
+ seen << o
+ page_with(items: [1], next_link: "L")
+ end
+ next_page = lambda do |_key, o|
+ seen << o
+ page_with(items: [2])
+ end
+ front = Fetchers.build(first: first, next_page: next_page, options: options)
+
+ assert_equal([1, 2], front.items.to_a)
+ assert_equal(2, seen.size)
+ seen.each { |o| assert_same(options, o) }
+ assert_same(options, front.options)
+ end
+
+ test "PAGE-35: a fetcher that takes no options is called without them; the default is EMPTY" do
+ front = Fetchers.build(first: -> { page_with(items: [1]) }, next_page: NEVER)
+
+ assert_equal([1], front.items.to_a)
+ assert_same(Dexpace::RequestOptions::EMPTY, front.options)
+ end
+
+ test "PAGE-1 / PAGE-12: the page view works over the fetcher-driven walk, look-ahead too" do
+ bodies = [fake_response_body, fake_response_body]
+ front = Fetchers.build(first: -> { page_with(items: [1], next_link: "L", body: bodies[0]) },
+ next_page: ->(_key) { page_with(items: [2], body: bodies[1]) },)
+ view = front.pages
+
+ assert_predicate(view, :more?)
+ assert_predicate(view, :more?)
+ assert_equal([0, 0], bodies.map(&:closes))
+ view.close
+
+ assert_equal([1, 0], bodies.map(&:closes))
+ assert_equal([[1], [2]], front.pages.map(&:items))
+ end
+
+ test "PAGE-8: the front-end is a frozen Data; each iteration restarts with the first fetcher" do
+ firsts = 0
+ first = lambda do
+ firsts += 1
+ page_with(items: [firsts])
+ end
+ front = Fetchers.build(first: first, next_page: NEVER)
+
+ assert_predicate(front, :frozen?)
+ assert_equal([[1], [2]], [front.items.to_a, front.items.to_a])
+ assert_equal(2, firsts)
+ end
+
+ test "HTTP-4: callable fetchers, RequestOptions options; the openers require a block" do
+ assert_raises(Dexpace::InvalidArgumentError) { Fetchers.build(first: nil, next_page: NEVER) }
+ assert_raises(Dexpace::InvalidArgumentError) { Fetchers.build(first: :no, next_page: NEVER) }
+ assert_raises(Dexpace::InvalidArgumentError) { Fetchers.build(first: -> {}, next_page: :no) }
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Fetchers.build(first: -> {}, next_page: NEVER, options: {})
+ end
+ refute_respond_to(Fetchers, :new)
+ front = Fetchers.build(first: -> {}, next_page: NEVER)
+
+ assert_raises(Dexpace::InvalidArgumentError) { front.each_item }
+ assert_raises(Dexpace::InvalidArgumentError) { front.each_page }
+ assert_nil(front.each_item { |_item| flunk("empty") })
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/info_test.rb b/gems/dexpace-core/test/dexpace/page/info_test.rb
new file mode 100644
index 0000000..5126685
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/info_test.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-4, and HTTP-3 / HTTP-4 / SEAM-29 for the model's construction pattern. A
+# strategy's parse output MUST always be well-formed (never nil), MUST signal termination ONLY by
+# a null next-request -- never by throwing and never via a side channel -- and an empty items list
+# paired with a non-null next-request is a VALID NON-TERMINAL page.
+class DexpacePageInfoTest < DexpaceTestCase
+ include PageFixtures
+
+ Info = Dexpace::Page::Info
+
+ test "PAGE-4: items are never nil, are frozen, and the collection is owned, not the elements" do
+ element = Object.new
+ source = [element]
+ info = Info.build(items: source, next_request: nil)
+
+ assert_equal([element], info.items)
+ assert_predicate(info.items, :frozen?)
+ assert_same(element, info.items.first) # a shallow copy: the caller's object, un-frozen (P7-101)
+ refute_predicate(element, :frozen?)
+ source << Object.new
+
+ assert_equal(1, info.items.size) # the model owns its copy
+ end
+
+ test "PAGE-4: a nil next_request is the one end-of-stream signal, and .terminal names it" do
+ assert_nil(Info.terminal.next_request)
+ assert_empty(Info.terminal.items)
+ assert_equal([1], Info.terminal(items: [1]).items)
+ refute_respond_to(Info.terminal, :terminal?) # no flag, no sentinel: nil is the whole signal
+ end
+
+ test "PAGE-4: empty items with a non-nil next_request is a valid NON-terminal page" do
+ info = Info.build(items: [], next_request: fake_request)
+
+ assert_empty(info.items)
+ refute_nil(info.next_request)
+ end
+
+ test "PAGE-4: items must not be nil, and must be an Array" do
+ assert_raises(Dexpace::InvalidArgumentError) { Info.build(items: nil, next_request: nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { Info.build(items: "a", next_request: nil) }
+ end
+
+ test "HTTP-4: next_request is nil or a Request, the two PAGE-34 keys nil or a String" do
+ assert_raises(Dexpace::InvalidArgumentError) { Info.build(items: [], next_request: "https://x/") }
+ assert_raises(Dexpace::InvalidArgumentError) { Info.build(items: [], next_request: nil, next_link: 1) }
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Info.build(items: [], next_request: nil, continuation_token: :t)
+ end
+ info = Info.build(items: [], next_request: nil, next_link: "L", continuation_token: "T")
+
+ assert_equal(%w[L T], [info.next_link, info.continuation_token])
+ assert_predicate(info.next_link, :frozen?)
+ end
+
+ test "HTTP-3: a frozen Data with a private .new and a #with that re-validates" do
+ info = Info.terminal(items: [1])
+
+ assert_predicate(info, :frozen?)
+ refute_respond_to(Info, :new)
+ assert_equal([1, 2], info.with(items: [1, 2]).items)
+ assert_raises(Dexpace::InvalidArgumentError) { info.with(items: nil) }
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/items_test.rb b/gems/dexpace-core/test/dexpace/page/items_test.rb
new file mode 100644
index 0000000..c7d905a
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/items_test.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-1 (the item view), PAGE-8 (the item-view half), PAGE-11, PAGE-15 (the eager
+# close's surfaced failure).
+#
+# PAGE-11's eager close is not a convenience: it is what makes the item view safe on a host where an
+# abandoned Enumerator's ensure never runs. At every yield point the walk holds nothing open, so the
+# residue of pagination/b2a85752 on THIS view is zero.
+class DexpacePageItemsTest < DexpaceTestCase
+ include PageFixtures
+
+ test "PAGE-1: items are delivered in server order across page boundaries" do
+ assert_equal([1, 2, 3, 4, 5], paginator_over([[1, 2], [3, 4], [5]]).items.to_a)
+ assert_equal([1, 2, 3, 4, 5], paginator_over([[1, 2], [], [3, 4], [5]]).items.to_a)
+ end
+
+ test "PAGE-11: taking one item from a multi-item first page closes it; no second fetch" do
+ body = fake_response_body
+ paginator = paginator_over([[1, 2], [3]], bodies: [body, fake_response_body])
+
+ paginator.items.each { |item| break if item == 1 }
+
+ assert_equal(1, body.closes)
+ assert_equal(1, paginator.transport.calls.size)
+ end
+
+ test "PAGE-11: the page is closed BEFORE any of its items is yielded" do
+ body = fake_response_body
+ observed = paginator_over([[1, 2]], bodies: [body]).items.map { |_item| body.closes }
+
+ assert_equal([1, 1], observed)
+ end
+
+ test "PAGE-8: each independent iteration restarts from the initial request with fresh state" do
+ paginator = paginator_over([[1], [2]], sequences: 2)
+ items = paginator.items
+
+ assert_equal(items.to_a, items.to_a)
+ assert_equal(4, paginator.transport.calls.size) # two full fetch sequences
+ assert_equal(%w[https://x/i https://x/i?p=1] * 2,
+ paginator.transport.calls.map do |(request, _o, _c)|
+ Dexpace::URL.external_form(request.url)
+ end,)
+ end
+
+ test "PAGE-8: two interleaved iterations of ONE view do not share state" do
+ paginator = paginator_over([[1], [2], [3]], sequences: 2)
+ items = paginator.items
+ first = items.to_enum(:each)
+ second = items.to_enum(:each)
+
+ assert_equal([1, 1, 2, 2, 3, 3],
+ [first.next, second.next, first.next, second.next, first.next, second.next],)
+ end
+
+ test "Enumerable is included, so first, take, lazy and count work with no vocabulary of core's" do
+ items = paginator_over([[1, 2], [3, 4], [5]], sequences: 4).items
+
+ assert_kind_of(Enumerable, items)
+ assert_equal(1, items.first)
+ assert_equal([1, 2, 3], items.take(3))
+ assert_equal([2, 4], items.lazy.select(&:even?).first(2))
+ assert_equal(5, items.count)
+ end
+
+ test "Enumerable#first spends exactly one exchange and closes that page (PAGE-6, PAGE-11)" do
+ body = fake_response_body
+ paginator = paginator_over([[1, 2], [3]], bodies: [body, fake_response_body])
+
+ assert_equal(1, paginator.items.first)
+ assert_equal(1, paginator.transport.calls.size)
+ assert_equal(1, body.closes)
+ end
+
+ test "Items#close releases nothing, because PAGE-11 left nothing open, and never raises" do
+ body = fake_response_body
+ items = paginator_over([[1, 2]], bodies: [body]).items
+ items.each { |item| break if item == 1 }
+
+ items.close
+ items.close
+
+ assert_equal(1, body.closes) # PAGE-11 closed it; #close neither repeats nor adds
+ assert_nil(items.close)
+ end
+
+ test "external iteration abandoned mid-#next strands nothing: PAGE-11 already closed it" do
+ body = fake_response_body
+ enumerator = paginator_over([[1, 2]], bodies: [body]).items.to_enum(:each)
+ enumerator.next
+ GC.start
+ GC.start
+
+ assert_equal(1, body.closes)
+ end
+
+ test "#each without a block returns an Enumerator that drives nothing until pulled (PAGE-6)" do
+ paginator = paginator_over([[1], [2]])
+ enumerator = paginator.items.each
+
+ assert_kind_of(Enumerator, enumerator)
+ assert_equal(0, paginator.transport.calls.size)
+ assert_equal(1, enumerator.next)
+ assert_equal(1, paginator.transport.calls.size)
+ end
+
+ test "PAGE-15: the eager close's failure is surfaced, unwrapped, with nothing in flight" do
+ body = fake_response_body(close_error: IOError.new("eager"))
+ error = assert_raises(IOError) { paginator_over([[1]], bodies: [body]).items.to_a }
+
+ assert_equal("eager", error.message)
+ assert_instance_of(IOError, error)
+ end
+
+ test "PAGE-13: a parse failure propagates out of the item view with the response closed" do
+ body = fake_response_body
+ paginator = paginator_over([[1]], bodies: [body], strategy: raising_strategy(KeyError.new("k")))
+
+ assert_raises(KeyError) { paginator.items.to_a }
+ assert_equal(1, body.closes)
+ end
+
+ test "the view is engine-constructed: its opener must be callable" do
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Page::Items.new(nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Page::Items.new(:walk) }
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/lifetime_test.rb b/gems/dexpace-core/test/dexpace/page/lifetime_test.rb
new file mode 100644
index 0000000..6a106de
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/lifetime_test.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-13, PAGE-15, PAGE-32 -- the inversion suite; P7-1, P7-105. No lib/ mirror: it
+# asserts the cross-cutting close discipline that no single component suite covers.
+#
+# MEASURED on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 (matrix_facts_test.rb): a bare `ensure` whose close
+# raises while a consumer exception is already in flight REPLACES the consumer's exception as the
+# primary (Ruby sets #cause to it). That is the exact inverse of PAGE-13's and PAGE-32's conformance
+# clauses, and it is the shape a competent Ruby author writes by default. And the obvious repair --
+# reading `$!` at the top of the ensure -- is wrong the other way: `$!` is the CALLER's inside
+# anything called from a caller's rescue. Every test below passes under either shape EXCEPT the
+# ones marked INVERSION and CALLER'S RESCUE, which are why this file exists.
+class DexpacePageLifetimeTest < DexpaceTestCase
+ include PageFixtures
+
+ def bounded_value(future) = future.value(deadline: Dexpace::Clock::SYSTEM.monotonic + 5.0)
+
+ test "INVERSION, PAGE-13: consumer raises AND close raises -> the CONSUMER's error is primary" do
+ body = fake_response_body(close_error: IOError.new("close"))
+ cause = KeyError.new("consumer")
+
+ error = assert_raises(KeyError) do
+ paginator_over([[1]], bodies: [body]).each_page { |page| raise cause unless page.nil? }
+ end
+
+ assert_same(cause, error)
+ assert_equal(["close"], Dexpace.suppressed(error).map(&:message))
+ assert_equal(1, body.closes)
+ end
+
+ test "INVERSION, PAGE-32 (async): consumer failure stays primary; the close error is swallowed" do
+ body = fake_response_body(close_error: IOError.new("close"))
+ cause = KeyError.new("consumer")
+ future = async_paginator_over([[1]], bodies: [body]).walk(->(_item) { raise cause })
+
+ error = assert_raises(KeyError) { bounded_value(future) }
+
+ assert_same(cause, error)
+ assert_empty(Dexpace.suppressed(error)) # swallowed, not attached: PAGE-32's own word
+ end
+
+ test "CALLER'S RESCUE, PAGE-15 (P7-105): a walk driven inside a rescue surfaces its close" do
+ # `$!` is the caller's KeyError throughout the rescue arm. An ensure that read `$!` would
+ # attach the close failure to it and swallow it; the recorded-primary shape surfaces it.
+ body = fake_response_body(close_error: IOError.new("close"))
+ surfaced = nil
+ begin
+ raise KeyError, "the caller's own, unrelated"
+ rescue KeyError => error
+ surfaced = assert_raises(IOError) do
+ paginator_over([[1]], bodies: [body]).each_page { |_page| nil }
+ end
+
+ assert_empty(Dexpace.suppressed(error))
+ end
+
+ assert_equal("close", surfaced.message)
+ end
+
+ test "CALLER'S RESCUE, PAGE-15: the advance close surfaces from inside a caller's rescue too" do
+ probes = [fake_response_body(close_error: IOError.new("advance")), fake_response_body]
+ begin
+ raise KeyError, "outer"
+ rescue KeyError => error
+ assert_raises(IOError) do
+ paginator_over([[1], [2]], bodies: probes).each_page { |_page| nil }
+ end
+ assert_empty(Dexpace.suppressed(error))
+ end
+ end
+
+ test "PAGE-15: with NOTHING in flight, a close error surfaces through a short-circuit terminal" do
+ body = fake_response_body(close_error: IOError.new("close"))
+ first_only = paginator_over([[1], [2]], bodies: [body, fake_response_body])
+
+ assert_raises(IOError) { first_only.each_page { |page| break if page.items == [1] } }
+ assert_raises(IOError) do
+ paginator_over([[1], [2]], bodies: [fake_response_body(close_error: IOError.new("c")), nil])
+ .pages.first
+ end
+ end
+
+ test "P7-1: the surfaced close error is NOT wrapped: no Ruby terminal is unable to declare it" do
+ # PAGE-15's middle clause ("re-thrown wrapped") is conditional on a terminal that cannot declare
+ # the underlying I/O error type. Measured four ways on every CI row: Enumerable#first,
+ # Lazy#first(2), an explicit break, and a plain block all deliver the close error unwrapped.
+ # Vacuous antecedent, design §11.15's family. This test asserts the concrete class, so a wrapper
+ # introduced later fails loudly rather than quietly changing what a caller must rescue.
+ error = assert_raises(IOError) do
+ paginator_over([[1]], bodies: [fake_response_body(close_error: IOError.new("close"))])
+ .each_page { |_page| nil }
+ end
+
+ assert_instance_of(IOError, error)
+ assert_nil(error.cause)
+ end
+
+ # The two-slot release, the frozen-primary caveat, a mid-walk parse failure, and the source scan.
+ class ReleaseTest < DexpaceTestCase
+ include PageFixtures
+
+ test "PAGE-15: both held pages fail to close -> first primary, second suppressed" do
+ # The ONLY reachable two-slot state: probe from INSIDE the loop, so page 2 is staged while
+ # page 1 is still held, then break. `each`'s own ensure closes the walk, which releases both
+ # -- so the assertion is on that raise, not on a later #close.
+ first = fake_response_body(close_error: IOError.new("first"))
+ second = fake_response_body(close_error: IOError.new("second"))
+ view = paginator_over([[1], [2]], bodies: [first, second]).pages
+
+ error = assert_raises(IOError) do
+ view.each do |page|
+ view.more?
+ break if page.items == [1]
+ end
+ end
+
+ assert_equal("first", error.message)
+ assert_equal(["second"], Dexpace.suppressed(error).map(&:message))
+ view.close # idempotent; no second release is attempted
+
+ assert_equal([1, 1], [first.closes, second.closes])
+ end
+
+ test "the frozen-primary caveat is stated and observable, not worked around" do
+ # Phase 4b's P4-13: attach_suppressed silently no-ops on a frozen primary. Asserting the
+ # behaviour rather than pretending it does not exist.
+ body = fake_response_body(close_error: IOError.new("close"))
+ cause = KeyError.new("consumer").freeze
+
+ error = assert_raises(KeyError) do
+ paginator_over([[1]], bodies: [body]).each_page { |page| raise cause unless page.nil? }
+ end
+
+ assert_same(cause, error)
+ assert_empty(Dexpace.suppressed(error))
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-13 / PAGE-15: a parse failure on page 2 releases held page 1 and stays primary" do
+ probes = [fake_response_body(close_error: IOError.new("held")), fake_response_body]
+ strategy = Class.new do
+ define_method(:parse) do |response, template|
+ raise KeyError, "parse" if response.request.url.query == "p=1"
+
+ PageFixtures::ScriptedStrategy.new([[1], [2]]).parse(response, template)
+ end
+ end.new
+
+ error = assert_raises(KeyError) do
+ paginator_over([[1], [2]], bodies: probes, strategy: strategy).each_page { |_page| nil }
+ end
+
+ assert_equal("parse", error.message)
+ assert_equal(["held"], Dexpace.suppressed(error).map(&:message))
+ assert_equal([1, 1], probes.map(&:closes))
+ end
+
+ test "no `$!` and no bare `ensure` closes a page anywhere under lib/dexpace/page/" do
+ root = File.expand_path("../../../lib/dexpace/page", __dir__)
+ paths = Dir.glob("**/*.rb", base: root).map { |f| File.join(root, f) }
+ paths << File.expand_path("../page.rb", root)
+ code = paths.to_h { |path| [File.basename(path), uncommented(path)] }
+
+ code.each_value { |text| refute_match(/\$!/, text) }
+ with_ensure = code.select { |_name, text| text.include?("ensure") }.keys
+
+ assert_equal(%w[items.rb pages.rb], with_ensure.sort)
+ with_ensure.each { |name| assert_match(/rescue ::Exception => error/, code.fetch(name)) }
+ end
+
+ def uncommented(path)
+ File.readlines(path).reject { |line| line.lstrip.start_with?("#") }.join
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/link_header_test.rb b/gems/dexpace-core/test/dexpace/page/link_header_test.rb
new file mode 100644
index 0000000..38f1e1a
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/link_header_test.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# Exercises: PAGE-18 (the grammar), PAGE-20, P7-116 -- through the private_constant, reached the
+# way 4c's cursor_test reaches Pipeline::Cursor.
+#
+# A character-level state machine, not a regexp: RFC 8288's link-value grammar is not regular
+# (commas inside <> and inside quoted parameter values must not split link-values, and quoted-pair
+# escapes must be honoured), so the per-pattern-timeout constraint is discharged by not writing the
+# pattern -- the same route design §6.3 takes for WWW-Authenticate.
+class DexpacePageLinkHeaderTest < DexpaceTestCase
+ L = Dexpace::Page.const_get(:LinkHeader)
+
+ test "PAGE-18: a comma inside an angle-bracketed URL does not split link-values" do
+ assert_equal("https://x/p?a=1,2", L.next_target(['; rel="next"']))
+ end
+
+ test "PAGE-18: a comma inside a quoted parameter value does not split link-values" do
+ header = '; title="a,b"; rel="prev", ; rel="next"'
+
+ assert_equal("https://x/2", L.next_target([header]))
+ end
+
+ test "PAGE-18: a semicolon inside a quoted value does not split parameters" do
+ assert_equal("https://x/2", L.next_target(['; title="a;b"; rel="next"']))
+ end
+
+ test "PAGE-18: quoted-pair escapes are honoured" do
+ header = '; title="a\\"b, c"; rel="last", ; rel=next'
+
+ assert_equal("https://x/2", L.next_target([header]))
+ assert_equal("https://x/2", L.next_target(['; rel="ne\\xt"'])) # \x is x
+ end
+
+ test "PAGE-18: rel may be unquoted, multi-token, and is matched case-insensitively" do
+ assert_equal("https://x/2", L.next_target(["; rel=NEXT"]))
+ assert_equal("https://x/2", L.next_target(['; rel="prev next"']))
+ assert_equal("https://x/2", L.next_target(["; rel=\"last\tnext\""]))
+ assert_equal("https://x/2", L.next_target(["; REL=next"]))
+ end
+
+ test "PAGE-18: the token is next, not a word containing it" do
+ assert_nil(L.next_target(['; rel="nextish"']))
+ assert_nil(L.next_target(['; rel="prev-next"']))
+ assert_nil(L.next_target(["; relation=next"]))
+ end
+
+ test "PAGE-18: the FIRST link-value whose rel contains next wins" do
+ assert_equal("https://x/1", L.next_target(["; rel=next, ; rel=next"]))
+ assert_equal("https://x/1", L.next_target(['; rel="first next"; rel="prev"']))
+ end
+
+ test "P7-116: only the FIRST rel parameter of a link-value is read (RFC 8288 §3.3)" do
+ # "The rel parameter MUST NOT appear more than once in a given link-value; occurrences after
+ # the first MUST be ignored by parsers." A second rel neither adds nor removes a relation.
+ assert_nil(L.next_target(['; rel="prev"; rel="next"']))
+ assert_nil(L.next_target(["; rel=prev; REL=next"]))
+ assert_equal("https://x/1", L.next_target(['; rel="next"; rel="prev"']))
+ two = '; rel="prev"; rel="next", ; rel="next"'
+
+ assert_equal("https://x/2", L.next_target([two]))
+ end
+
+ test "PAGE-18: no rel=next segment and no header at all both mean end-of-stream" do
+ assert_nil(L.next_target(["; rel=prev, ; rel=last"]))
+ assert_nil(L.next_target([""]))
+ assert_nil(L.next_target(nil))
+ assert_nil(L.next_target([]))
+ assert_nil(L.next_target([""]))
+ end
+
+ test "PAGE-18: malformed input never raises -- it is end-of-stream or the best parse" do
+ assert_nil(L.next_target(["https://x/2; rel=next"])) # no angle brackets at all
+ assert_nil(L.next_target(["; rel="next'])) # quote never closed
+ assert_nil(L.next_target(['; rel="next\\'])) # escape at the very end
+ assert_equal("https://x/2", L.next_target([";rel=next,"]))
+ assert_equal("https://x/2", L.next_target([" , ; rel = next ; "]))
+ assert_equal("", L.next_target(["<>; rel=next"])) # blank target: the strategy's P7-5
+ end
+
+ test "PAGE-20: multiple Link header instances are normalized by concatenation" do
+ assert_equal("https://x/2",
+ L.next_target(["; rel=last", "; rel=next"]),)
+ assert_equal("https://x/2",
+ L.next_target(["; rel=next", "; rel=last"]),)
+ end
+
+ test "no Regexp is compiled anywhere in the grammar" do
+ source = File.read(File.expand_path("../../../lib/dexpace/page/link_header.rb", __dir__))
+
+ refute_match(%r{Regexp\.new|=~|\.match\?|%r\{|/\\A}, source)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/link_strategy_test.rb b/gems/dexpace-core/test/dexpace/page/link_strategy_test.rb
new file mode 100644
index 0000000..420f2cc
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/link_strategy_test.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-18, PAGE-19, PAGE-20, PAGE-23, PAGE-5 (asserted); P7-5, P7-104, P7-117. The
+# grammar itself is link_header_test.rb's; this suite is the resolution, the configurable header
+# name and the end-of-stream routes, through Dexpace::Page.next_request_from.
+class DexpacePageLinkStrategyTest < DexpaceTestCase
+ include PageFixtures
+
+ Strategy = Dexpace::Page::LinkStrategy
+ BASE = "https://api.example.com/repo/issues?page=1"
+
+ def parse(link_values, base: BASE, **keywords)
+ strategy = Strategy.build(extract_items: ->(_r) { [1] }, **keywords)
+ headers = link_values.nil? ? {} : { "Link" => link_values }
+ strategy.parse(page_response(request: fake_request(url: base), headers: headers),
+ fake_request(url: base),)
+ end
+
+ def url_of(info) = Dexpace::URL.external_form(info.next_request.url)
+
+ test "PAGE-19: a query-only rel=next preserves the base path (RFC 3986, not RFC 2396)" do
+ info = parse(["; rel=next"])
+
+ assert_equal("https://api.example.com/repo/issues?page=2", url_of(info))
+ assert_equal([1], info.items)
+ assert_equal("?page=2", info.next_link)
+ end
+
+ test "PAGE-19: an absolute target is used as-is; a relative one resolves against the base" do
+ assert_equal("https://other.example/x", url_of(parse(["; rel=next"])))
+ assert_equal("https://api.example.com/repo/next", url_of(parse(["; rel=next"])))
+ end
+
+ test "PAGE-19: an unresolvable target is end-of-stream, not an exception" do
+ assert_nil(parse(["; rel=next"]).next_request)
+ assert_nil(parse(["; rel=next"]).next_request)
+ end
+
+ test "P7-104: a target this client cannot dispatch is end-of-stream, never a request" do
+ %w[mailto:a@b javascript:alert(1) http:foo http:///p].each do |target|
+ assert_nil(parse(["<#{target}>; rel=next"]).next_request, target)
+ end
+ end
+
+ test "P7-5: a blank rel=next target is end-of-stream, not a resolution to the base" do
+ # Verified: URI::RFC3986_PARSER.join(base, "") returns the base UNCHANGED, so resolving a blank
+ # target would produce a next request identical to the current one and loop until the page cap.
+ assert_nil(parse(["<>; rel=next"]).next_request)
+ assert_nil(parse(["< >; rel=next"]).next_request)
+ end
+
+ test "P7-117: a fragment-only rel=next target is end-of-stream too; the check is syntactic" do
+ # Verified: join(base, "#top") is the base plus a fragment the wire never carries -- RFC 3986
+ # §4.4's other same-document form. A target that reaches the current page by another spelling
+ # (>, />, the URL spelled out) is followed and bounded by the cap, never screened.
+ assert_nil(parse(["<#>; rel=next"]).next_request)
+ assert_nil(parse(["<#top>; rel=next"]).next_request)
+ assert_equal(BASE, url_of(parse(["<#{BASE}>; rel=next"])))
+ end
+
+ test "PAGE-18: absence of a Link header, or of a rel=next segment, means end-of-stream" do
+ assert_nil(parse(nil).next_request)
+ assert_nil(parse(["; rel=last"]).next_request)
+ assert_nil(parse(nil).next_link)
+ end
+
+ test "PAGE-20: two separate Link headers, one next one last -> next is followed" do
+ assert_equal("https://x/2",
+ url_of(parse(["; rel=last", "; rel=next"])),)
+ end
+
+ test "PAGE-18: the header name is configurable and defaults to Link, matched under the fold" do
+ assert_equal("Link", Strategy.build(extract_items: ->(_r) { [1] }).header)
+ strategy = Strategy.build(extract_items: ->(_r) { [1] }, header: "X-Links")
+ response = page_response(request: fake_request,
+ headers: { "x-links" => ["; rel=next"] },)
+
+ assert_equal("https://x/2", url_of(strategy.parse(response, fake_request)))
+ assert_nil(Strategy.build(extract_items: lambda { |_r|
+ [1]
+ }).parse(response, fake_request).next_request)
+ end
+
+ test "PAGE-23: following a whole next URL swaps only the URL, preserving method/headers/body" do
+ template = fake_request(url: BASE, method: "POST", headers: { "x-a" => "1" }, body: fake_body)
+ response = page_response(request: template, headers: { "Link" => ["; rel=next"] })
+ nxt = Strategy.build(extract_items: ->(_r) { [1] }).parse(response, template).next_request
+
+ assert_equal(template.method, nxt.method)
+ assert_equal(template.headers, nxt.headers)
+ assert_same(template.body, nxt.body)
+ assert_equal("https://x/2", Dexpace::URL.external_form(nxt.url))
+ end
+
+ test "PAGE-5: parse closes nothing, mutates nothing, retains nothing; the strategy is frozen" do
+ response = page_response(headers: { "Link" => ["; rel=next"] })
+ strategy = Strategy.build(extract_items: ->(_r) { [1] })
+ strategy.parse(response, fake_request)
+
+ assert_equal(0, closes_of(response))
+ assert_empty(strategy.instance_variables)
+ assert_predicate(strategy, :frozen?)
+ end
+
+ test "PAGE-4: an extractor that raises is a parse failure; a non-Array answer is refused" do
+ assert_raises(KeyError) do
+ Strategy.build(extract_items: lambda { |_r|
+ raise KeyError, "k"
+ }).parse(page_response, fake_request)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Strategy.build(extract_items: ->(_r) { "x" }).parse(page_response, fake_request)
+ end
+ end
+
+ test "HTTP-4: the extractor must be callable and the header name a non-empty String" do
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract_items: nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract_items: ->(_r) { [] }, header: "") }
+ refute_respond_to(Strategy, :new)
+ end
+
+ test "HTTP-4 / P7-107: a present but non-callable extractor is refused by class, not by nil" do
+ error = assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract_items: :items) }
+
+ assert_match(/extract_items must respond to #call, got Symbol/, error.message)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/matrix_facts_test.rb b/gems/dexpace-core/test/dexpace/page/matrix_facts_test.rb
new file mode 100644
index 0000000..1e557ba
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/matrix_facts_test.rb
@@ -0,0 +1,209 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require "uri"
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-13, PAGE-15, PAGE-19, PAGE-21, PAGE-22, PAGE-23, PAGE-24, PAGE-31 (the Ruby facts
+# they rest on) -- the phase-7c design's eight verified facts plus the ones the cross-check and the
+# build found, re-run as a standing test on every CI row rather than once in a scratch script
+# (5a's, 5b's, 5c's, 6a's, 6b's and 6c's precedent). Run on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 on
+# 2026-09-20: every fact holds identically on every row, uri 0.12.5 / 0.13.3 / 1.0.4 / 1.1.1
+# included. 6b's redirect/matrix_facts_test.rb already pins the join facts (a query-only reference
+# preserves the path; join(base, "") answers the base; join(base, nil) raises ArgumentError; join
+# raises on a space and on nothing else -- mailto:, javascript:, http:foo all RESOLVE); they are
+# cited, not re-asserted. No lib/ mirror: it asserts the interpreter, not a file.
+class DexpacePageMatrixFactsTest < DexpaceTestCase
+ include PageFixtures
+
+ PARSER = ::URI::RFC3986_PARSER
+
+ # A scoped resource whose close raises, driven the four ways PAGE-15's clause could be reached.
+ class Scoped
+ include Enumerable
+
+ def each
+ yield 1
+ yield 2
+ yield 3
+ ensure
+ raise IOError, "close"
+ end
+ end
+
+ test "fact 1 (R8, P7-1): a close error from an ensure reaches the caller UNWRAPPED, four ways" do
+ assert_instance_of(IOError, assert_raises(IOError) { Scoped.new.first })
+ assert_instance_of(IOError, assert_raises(IOError) { Scoped.new.lazy.map { |x| x }.first(2) })
+ assert_instance_of(IOError, assert_raises(IOError) { Scoped.new.each { |x| break x if x > 1 } })
+ assert_instance_of(IOError, assert_raises(IOError) { Scoped.new.each { |_x| :v } })
+ end
+
+ test "fact 1 (R8): a raising ensure REPLACES an in-flight consumer error as the primary" do
+ error = assert_raises(IOError) { Scoped.new.each { |x| raise ArgumentError, "consumer" if x } }
+
+ assert_instance_of(ArgumentError, error.cause) # nothing lost; the ORDER is PAGE-13's inverse
+ end
+
+ test "fact 2 (P7-105): $! is the CALLER's inside a method called from the caller's rescue" do
+ reader = -> { $! } # rubocop:disable Style/SpecialGlobalVars -- the bare global IS the fact
+ begin
+ raise KeyError, "caller"
+ rescue KeyError
+ assert_instance_of(KeyError, reader.call)
+ end
+
+ assert_nil(reader.call)
+ end
+
+ test "fact 3 (PAGE-31): a re-arm loop over inline-settled futures is flat; recursion is not" do
+ # The recursive shape overflows at ~2,600 pages through the real Completer on every row
+ # (measured); asserting the overflow itself would take seconds per row, so the standing test
+ # asserts the flat loop over 20,000 and async_paginator_test.rb the engine's own depth.
+ count = 0
+ rearm = true
+ while rearm
+ rearm = false
+ completer = Dexpace::Async::Completer.new
+ completer.fulfil(1)
+ completer.future.on_settle do
+ count += 1
+ rearm = count < 20_000
+ end
+ end
+
+ assert_equal(20_000, count)
+ end
+
+ test "fact 5 (PAGE-22): Ruby's www-form helpers are the exact inverse of RFC 3986 coding" do
+ assert_equal([["q", "a b"]], ::URI.decode_www_form("q=a+b"))
+ assert_equal("a+b", ::URI.encode_www_form_component("a b"))
+ assert_equal("a%20b%2A~%2B%2F%21%28%29%27",
+ Dexpace::PercentEncoding.encode_component("a b*~+/!()'"),)
+ assert_equal("a+b", Dexpace::PercentEncoding.decode_component("a+b"))
+ end
+
+ test "fact 6 (PAGE-19, P7-104): join resolves what this client cannot dispatch, successfully" do
+ base = "https://api.example.com/repo/issues?page=1"
+ resolved = { "mailto:a@b" => ["mailto", nil], "javascript:alert(1)" => ["javascript", nil],
+ "http:foo" => ["http", nil], "http:///p" => ["http", ""], }
+
+ resolved.each do |reference, (scheme, host)|
+ target = PARSER.join(base, reference)
+
+ assert_equal([scheme, host], [target.scheme, target.host], reference)
+ end
+ end
+
+ test "fact 7 (P7-4): query = nil removes the query; query = \"\" leaves a dangling ?" do
+ removed = PARSER.parse("https://x/a?page=1")
+ removed.query = nil
+ dangling = PARSER.parse("https://x/a?page=1")
+ dangling.query = ""
+
+ assert_equal(["https://x/a", "https://x/a?"], [removed.to_s, dangling.to_s])
+ assert_predicate(Float::INFINITY, :positive?)
+ assert_operator(3, :<, Float::INFINITY)
+ end
+
+ # The splice facts (PAGE-21, PAGE-24), the fiber-storage facts, and the cross-check's.
+ class SpliceAndFiberTest < DexpaceTestCase
+ include PageFixtures
+
+ PARSER = ::URI::RFC3986_PARSER
+
+ test "fact 4 (PAGE-21, PAGE-24): query= round-trips every parser-produced query via Request" do
+ disagreements = []
+ (0x20..0x7e).each do |byte|
+ char = byte.chr
+ next if char == "%" # a bare % is not a pct-encoding and does not parse: not a splice input
+
+ produced = PARSER.parse("https://h/p?k=#{char}&x=1").query
+ copy = PARSER.parse("https://h/p?k=#{char}&x=1").dup
+ copy.query = produced
+ disagreements << char unless fake_request(url: copy).url.query == produced
+ end
+
+ assert_empty(disagreements)
+ full = Dexpace::URL.parse!("https://user:pw@h:8443/a/b?flag&filter=a:b&page=2#frag")
+ copy = full.dup
+ query = copy.query
+ copy.query = query
+
+ assert_equal("https://user:pw@h:8443/a/b?flag&filter=a:b&page=2#frag",
+ Dexpace::URL.external_form(fake_request(url: copy).url),)
+ end
+
+ test "fact 4: the canonicalising happens at PARSE time, on exactly seven bytes" do
+ canonicalised = (0x20..0x7e).map(&:chr).reject do |char|
+ PARSER.parse("https://h/p?k=#{char}").query == "k=#{char}"
+ rescue ::URI::InvalidURIError
+ false
+ end
+
+ assert_equal([" ", '"', "#", "'", "<", ">", "`"], canonicalised - ["%"])
+ end
+
+ test "fact 8: Fiber[] is visible inside an Enumerator's fiber, created at the FIRST pull" do
+ Fiber[:dexpace_page_probe] = :before
+ ::Thread.current[:dexpace_page_local] = :before
+ enumerator = Enumerator.new do |y|
+ loop { y << [Fiber[:dexpace_page_probe], ::Thread.current[:dexpace_page_local]] }
+ end
+ Fiber[:dexpace_page_probe] = :after_new
+ first = enumerator.next
+ Fiber[:dexpace_page_probe] = :after_first_pull
+ second = enumerator.next
+
+ assert_equal([[:after_new, nil], [:after_new, nil]], [first, second])
+ ensure
+ Fiber[:dexpace_page_probe] = nil
+ ::Thread.current[:dexpace_page_local] = nil
+ end
+
+ test "fact 8 (applied): a correlation value set before a view is first driven IS seen" do
+ seen = []
+ strategy = Class.new do
+ define_method(:parse) do |_response, _template|
+ seen << Fiber[:dexpace_page_correlation]
+ Dexpace::Page::Info.terminal(items: [1])
+ end
+ end.new
+ Fiber[:dexpace_page_correlation] = :set_before
+ enumerator = paginator_over([[1]], strategy: strategy).items.to_enum(:each)
+ Fiber[:dexpace_page_correlation] = :set_after_construction
+ enumerator.next
+
+ assert_equal([:set_after_construction], seen)
+ ensure
+ Fiber[:dexpace_page_correlation] = nil
+ end
+
+ test "cross-check facts: Model.own deep-copies and freezes elements; a Data holds no ivars" do
+ element = Object.new
+ owned = Dexpace::Model.own([element])
+
+ refute_same(element, owned.first)
+ assert_predicate(owned.first, :frozen?)
+ assert_raises(TypeError) { Dexpace::Model.own([proc {}]) }
+ assert_empty(Data.define(:a).new(a: 1).instance_variables)
+ end
+
+ test "cross-check facts: fulfil after cancel closes the response; a nil token dies in retry" do
+ closes = 0
+ resource = Object.new
+ resource.define_singleton_method(:close) { closes += 1 }
+ completer = Dexpace::Async::Completer.new
+ completer.future.cancel(:test)
+
+ refute(completer.fulfil(resource))
+ assert_equal(1, closes)
+
+ pipeline = Dexpace::Pipeline.standard(->(_r, _o, _c) { raise "unreached" })
+ assert_raises(NoMethodError) do
+ pipeline.call(fake_request, Dexpace::RequestOptions::EMPTY, nil)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/page_number_strategy_test.rb b/gems/dexpace-core/test/dexpace/page/page_number_strategy_test.rb
new file mode 100644
index 0000000..7a80506
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/page_number_strategy_test.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-17 and PAGE-5 (asserted). The empty-items check runs FIRST and is defensive
+# against servers that return an empty page past the end; the current page is inferred from the
+# ORIGINATING (executed) request, which in this port is response.request -- not the template.
+class DexpacePagePageNumberStrategyTest < DexpaceTestCase
+ include PageFixtures
+
+ Strategy = Dexpace::Page::PageNumberStrategy
+
+ def strategy(items, **keywords)
+ Strategy.build(extract_items: ->(_r) { items }, **keywords)
+ end
+
+ def executed(url) = page_response(request: fake_request(url: url))
+
+ test "PAGE-17: first page with no param and non-empty items -> next request page=start+1" do
+ info = strategy([1]).parse(executed("https://x/i"), fake_request(url: "https://x/i"))
+
+ assert_equal("page=2", info.next_request.url.query)
+ assert_equal([1], info.items)
+ end
+
+ test "PAGE-17: an empty items list is end-of-stream, checked before anything else" do
+ info = strategy([]).parse(executed("https://x/i?page=4"), fake_request)
+
+ assert_nil(info.next_request)
+ assert_empty(info.items)
+ end
+
+ test "PAGE-17: a garbage, empty, negative, fractional or absent page value computes from start" do
+ # %D9%A3 is the Arabic-Indic three, a non-ASCII numeral: [0-9] refuses it where \d might not.
+ ["page=abc", "page=", "", "page=-2", "page=1.5", "page=1e3", "page=%D9%A3",
+ "page= 1",].each do |query|
+ info = strategy([1], start: 7).parse(executed("https://x/i?#{query}"), fake_request(url: "https://x/i"))
+
+ assert_equal("page=8", info.next_request.url.query, query)
+ end
+ end
+
+ test "PAGE-22: the page value is read DECODED, so a percent-encoded number is a number" do
+ info = strategy([1], start: 7).parse(executed("https://x/i?page=%31%32"), fake_request(url: "https://x/i"))
+
+ assert_equal("page=13", info.next_request.url.query)
+ end
+
+ test "PAGE-17: the parameter name and the start page are both configurable, 0-based allowed" do
+ info = strategy([1], parameter: "pageNumber", start: 0)
+ .parse(executed("https://x/i"), fake_request(url: "https://x/i"))
+
+ assert_equal("pageNumber=1", info.next_request.url.query)
+ assert_equal(["page", 1], [strategy([1]).parameter, strategy([1]).start])
+ end
+
+ test "PAGE-17: the current page is read from the EXECUTED request, not the template" do
+ info = strategy([1]).parse(executed("https://x/i?page=5"), fake_request(url: "https://x/i?page=1"))
+
+ assert_equal("page=6", info.next_request.url.query)
+ end
+
+ test "PAGE-17: the next request is the TEMPLATE with its page spliced; the rest survives" do
+ template = fake_request(url: "https://x/i?filter=a:b&page=1&sort=asc", method: "POST",
+ body: fake_body,)
+ info = strategy([1]).parse(executed("https://x/i?filter=a:b&page=1&sort=asc"), template)
+
+ assert_equal("filter=a:b&page=2&sort=asc", info.next_request.url.query)
+ assert_same(template.body, info.next_request.body)
+ assert_equal(template.method, info.next_request.method)
+ end
+
+ test "PAGE-5: parse closes nothing, mutates nothing, retains nothing; the strategy is frozen" do
+ response = executed("https://x/i")
+ subject = strategy([1])
+ subject.parse(response, fake_request)
+
+ assert_equal(0, closes_of(response))
+ assert_empty(subject.instance_variables)
+ assert_predicate(subject, :frozen?)
+ end
+
+ test "PAGE-4: an extractor that raises is a parse failure; a non-Array answer is refused" do
+ assert_raises(KeyError) do
+ Strategy.build(extract_items: lambda { |_r|
+ raise KeyError, "k"
+ }).parse(executed("https://x/i"), fake_request)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Strategy.build(extract_items: ->(_r) {}).parse(executed("https://x/i"), fake_request)
+ end
+ end
+
+ test "HTTP-4: start a non-negative Integer, parameter a non-empty String, extractor callable" do
+ assert_raises(Dexpace::InvalidArgumentError) { strategy([1], start: -1) }
+ assert_raises(Dexpace::InvalidArgumentError) { strategy([1], start: 1.0) }
+ assert_raises(Dexpace::InvalidArgumentError) { strategy([1], parameter: "") }
+ assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract_items: nil) }
+ refute_respond_to(Strategy, :new)
+ end
+
+ test "HTTP-4 / P7-107: a present but non-callable extractor is refused by class, not by nil" do
+ error = assert_raises(Dexpace::InvalidArgumentError) { Strategy.build(extract_items: :items) }
+
+ assert_match(/extract_items must respond to #call, got Symbol/, error.message)
+ end
+
+ test "the digit screen is anchored and carries its own per-pattern timeout" do
+ assert_in_delta(1.0, Strategy.const_get(:DIGITS).timeout)
+ assert_raises(::NameError) { Strategy::DIGITS }
+ assert_equal("page=13",
+ strategy([1]).parse(executed("https://x/i?page=12"),
+ fake_request,).next_request.url.query,)
+ assert_equal("page=8",
+ strategy([1], start: 7).parse(executed("https://x/i?page=12x"),
+ fake_request,).next_request.url.query,)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/page_state_error_test.rb b/gems/dexpace-core/test/dexpace/page/page_state_error_test.rb
new file mode 100644
index 0000000..7eea3ec
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/page_state_error_test.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# Exercises: PAGE-14 (the error family), SEAM-29 -- the state error a second #each on a single-use
+# page view raises: a StandardError in the Dexpace::Error family and NOT an ArgumentError, so a
+# caller rescuing ArgumentError around a page loop does not catch a state violation (phase 10's
+# inbound bullet of 2026-09-13 on PAGE-14 / SSE-26; P7-103). The raise site is pages_test.rb.
+class DexpacePagePageStateErrorTest < DexpaceTestCase
+ test "PAGE-14: a Dexpace::Error, a StandardError, and never an ArgumentError" do
+ error = Dexpace::Page::PageStateError.new("used twice")
+
+ assert_kind_of(Dexpace::Error, error)
+ assert_kind_of(::StandardError, error)
+ refute_kind_of(::ArgumentError, error)
+ refute_kind_of(Dexpace::InvalidArgumentError, error)
+ assert_equal("used twice", error.message)
+ end
+
+ test "rescue Dexpace::Error catches it, through Module#===" do
+ caught = begin
+ raise Dexpace::Page::PageStateError, "state"
+ rescue Dexpace::Error => error
+ error
+ end
+
+ assert_instance_of(Dexpace::Page::PageStateError, caught)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/pages_test.rb b/gems/dexpace-core/test/dexpace/page/pages_test.rb
new file mode 100644
index 0000000..c370c08
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/pages_test.rb
@@ -0,0 +1,202 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-1 (the page view), PAGE-12, PAGE-14, PAGE-15, PAGE-6's probe half, PAGE-8's
+# two-views half. Up to two live pages can exist at once, which is why the look-ahead slot lives on
+# the Walk and not in the enumerator's closure (pagination/9bdf90fc).
+class DexpacePagePagesTest < DexpaceTestCase
+ include PageFixtures
+
+ def bodies(count) = Array.new(count) { fake_response_body }
+
+ test "PAGE-1: the page view yields exactly one page object per page, with status and headers" do
+ pages = paginator_over([[1], [2], [3]]).pages.to_a
+
+ assert_equal(3, pages.size)
+ assert_equal([[1], [2], [3]], pages.map(&:items))
+ assert_equal(200, pages.first.status.code)
+ assert_equal("https://x/i?p=2", Dexpace::URL.external_form(pages.last.request.url))
+ assert(pages.all?(&:closed?)) # to_a ran the walk to exhaustion; every page is released
+ end
+
+ test "PAGE-12: the previous page is closed as the consumer advances" do
+ probes = bodies(3)
+ seen = []
+ paginator_over([[1], [2], [3]], bodies: probes).each_page do |_page|
+ seen << probes.map(&:closes)
+ end
+
+ assert_equal([[0, 0, 0], [1, 0, 0], [1, 1, 0]], seen)
+ end
+
+ test "PAGE-12: the last page is closed at exhaustion" do
+ probes = bodies(2)
+
+ paginator_over([[1], [2]], bodies: probes).each_page { |page| refute_predicate(page, :closed?) }
+
+ assert_equal([1, 1], probes.map(&:closes))
+ end
+
+ test "PAGE-12: probing without advancing, then closing, releases the prefetched page" do
+ probes = bodies(2)
+ view = paginator_over([[1], [2]], bodies: probes).pages
+
+ assert_predicate(view, :more?)
+ view.close
+
+ assert_equal([1, 0], probes.map(&:closes))
+ end
+
+ test "PAGE-12: breaking out of a page loop inside a scoped close releases the held page" do
+ probes = bodies(2)
+ paginator_over([[1], [2]], bodies: probes).each_page { |page| break if page.items == [1] }
+
+ assert_equal([1, 0], probes.map(&:closes))
+ end
+
+ test "PAGE-6 / PAGE-12: a second probe costs no exchange and strands no page" do
+ probes = bodies(3)
+ paginator = paginator_over([[1], [2], [3]], bodies: probes)
+ view = paginator.pages
+
+ assert_predicate(view, :more?)
+ assert_predicate(view, :more?) # reads the staged page; MUST NOT fetch a second one
+
+ assert_equal(1, paginator.transport.calls.size)
+ view.close
+
+ assert_equal([1, 0, 0], probes.map(&:closes)) # nothing was displaced unclosed
+ end
+
+ test "PAGE-12: a probed page is the one then yielded; a probe inside the loop stages the next" do
+ probes = bodies(3)
+ view = paginator_over([[1], [2], [3]], bodies: probes).pages
+ view.more?
+ yielded = []
+ view.each do |page|
+ yielded << page.items
+ view.more?
+ end
+
+ assert_equal([[1], [2], [3]], yielded)
+ assert_equal([1, 1, 1], probes.map(&:closes))
+ end
+
+ test "PAGE-12: probing an exhausted-and-closed view fetches nothing and strands nothing" do
+ probes = bodies(2)
+ paginator = paginator_over([[1]], bodies: probes)
+ view = paginator.pages
+ view.each { |_page| :consumed } # drives to exhaustion; the ensure closes the walk
+
+ refute_predicate(view, :more?)
+ assert_equal(1, paginator.transport.calls.size)
+ assert_equal([1, 0], probes.map(&:closes))
+ end
+
+ test "PAGE-6: constructing the view fetches nothing; more? on an empty stream answers false" do
+ paginator = paginator_over([[]], strategy: Class.new do
+ def parse(_response, _template) = Dexpace::Page::Info.terminal
+ end.new,)
+ view = paginator.pages
+
+ assert_equal(0, paginator.transport.calls.size)
+ assert_predicate(view, :more?) # the empty terminal page is still one page
+ assert_equal(1, paginator.transport.calls.size)
+ end
+
+ # PAGE-15's surfaced close failures, on the advance and on the explicit close.
+ class SurfacedCloseTest < DexpaceTestCase
+ include PageFixtures
+
+ def bodies(count) = Array.new(count) { fake_response_body }
+
+ test "PAGE-15: a close error while ADVANCING is surfaced, not swallowed" do
+ probes = [fake_response_body(close_error: IOError.new("advance")), fake_response_body]
+ seen = []
+
+ error = assert_raises(IOError) do
+ paginator_over([[1], [2]], bodies: probes).each_page { |page| seen << page.items }
+ end
+
+ assert_equal("advance", error.message)
+ assert_equal([[1]], seen)
+ assert_equal([1, 1], probes.map(&:closes)) # the ensure released the page held at the raise
+ end
+
+ test "PAGE-15: a close error while releasing a held page is SURFACED, not swallowed" do
+ body = fake_response_body(close_error: IOError.new("boom"))
+ view = paginator_over([[1]], bodies: [body]).pages
+
+ assert_predicate(view, :more?)
+ assert_raises(IOError) { view.close }
+ assert_predicate(view, :closed?)
+ # The walk cleared its slots BEFORE the raise (R1-1): the closed view neither reports the
+ # page whose close failed nor hands it out; a drive over the closed walk yields nothing.
+ refute_predicate(view, :more?)
+ view.each { |page| flunk("a closed view yielded a page: #{page.items.inspect}") }
+ end
+ end
+
+ # PAGE-14's single-use latch, PAGE-8's two-views rule, PAGE-13 through the view, construction.
+ class SingleUseTest < DexpaceTestCase
+ include PageFixtures
+
+ def bodies(count) = Array.new(count) { fake_response_body }
+
+ test "PAGE-14: the iterator may be obtained at most once; re-iteration fails" do
+ view = paginator_over([[1]]).pages
+ view.each { |_page| :consumed }
+
+ error = assert_raises(Dexpace::Page::PageStateError) { view.each { |_page| :again } }
+
+ assert_match(/single-use/, error.message)
+ assert_raises(Dexpace::Page::PageStateError) { view.each }
+ assert_raises(Dexpace::Page::PageStateError) { view.to_a }
+ end
+
+ test "PAGE-14: a block-less #each latches too -- the Enumerator IS the one iteration" do
+ view = paginator_over([[1]]).pages
+ enumerator = view.each
+
+ assert_kind_of(Enumerator, enumerator)
+ assert_raises(Dexpace::Page::PageStateError) { view.each { |_page| :again } }
+ assert_equal([1], enumerator.next.items)
+ end
+
+ test "PAGE-8: two separate views from one paginator each drive a full fetch sequence" do
+ paginator = paginator_over([[1], [2]], sequences: 2)
+ paginator.pages.to_a
+ paginator.pages.to_a
+
+ assert_equal(4, paginator.transport.calls.size)
+ end
+
+ test "PAGE-13: a parse failure propagates out of the page view, the held page released" do
+ probes = bodies(2)
+ strategy = Class.new do
+ define_method(:parse) do |response, template|
+ raise KeyError, "second" if response.request.url.query == "p=1"
+
+ PageFixtures::ScriptedStrategy.new([[1], [2]]).parse(response, template)
+ end
+ end.new
+ paginator = paginator_over([[1], [2]], bodies: probes, strategy: strategy)
+
+ assert_raises(KeyError) { paginator.each_page { |_page| :consumed } }
+ assert_equal([1, 1], probes.map(&:closes))
+ end
+
+ test "the view is engine-constructed and closes idempotently" do
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Page::Pages.new(nil) }
+ view = paginator_over([[1]]).pages
+
+ assert_nil(view.close)
+ assert_nil(view.close)
+ assert_predicate(view, :closed?)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/paginator_test.rb b/gems/dexpace-core/test/dexpace/page/paginator_test.rb
new file mode 100644
index 0000000..cc573e5
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/paginator_test.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-6 (the blocking engine's half, R10), PAGE-8 (the engine half), PAGE-9, PAGE-10,
+# PAGE-36, PIPE-26 / PIPE-27 (a pipeline is a transport the paginator never closes); HTTP-3 /
+# HTTP-4 for the construction pattern.
+class DexpacePagePaginatorTest < DexpaceTestCase
+ include PageFixtures
+
+ test "PAGE-6 (blocking engine): construction and obtaining a view trigger ZERO exchanges" do
+ paginator = paginator_over([[1]])
+
+ assert_equal(0, paginator.transport.calls.size)
+ paginator.items
+
+ assert_equal(0, paginator.transport.calls.size)
+ paginator.items.to_enum(:each)
+
+ assert_equal(0, paginator.transport.calls.size)
+ paginator.pages
+
+ assert_equal(0, paginator.transport.calls.size)
+ paginator.pages.each
+
+ assert_equal(0, paginator.transport.calls.size)
+ end
+
+ test "PAGE-6: exactly one exchange per page actually consumed" do
+ paginator = paginator_over([[1], [2], [3]])
+
+ assert_equal([1], paginator.items.first(1))
+ assert_equal(1, paginator.transport.calls.size)
+ assert_equal([1, 2], paginator.items.first(2))
+ assert_equal(3, paginator.transport.calls.size)
+ end
+
+ test "PAGE-9: the cap is validated strictly positive AT CONSTRUCTION, not lazily" do
+ [0, -1, 0.0, -0.5, Float::NAN, nil, "3", :three].each do |cap|
+ error = assert_raises(Dexpace::InvalidArgumentError) { paginator_over([[1]], cap: cap) }
+
+ assert_match(/PAGE-9/, error.message, cap.inspect)
+ end
+ assert_equal(3, paginator_over([[1]], cap: 3).cap)
+ assert_in_delta(2.5, paginator_over([[1]], cap: 2.5).cap)
+ end
+
+ test "PAGE-9: the cap bounds a server that never advances, counted in exchanges, not items" do
+ transport = ScriptedTransport.new(page_script(50))
+ paginator = paginator_over([], transport: transport, strategy: EndlessStrategy.new, cap: 3)
+
+ assert_equal([1, 1, 1], paginator.items.to_a)
+ assert_equal(3, transport.calls.size)
+ assert_equal(3, paginator.pages.count)
+ assert_equal(6, transport.calls.size)
+ end
+
+ test "PAGE-9: #with re-validates the cap on every interpreter, not only Data#with's" do
+ paginator = paginator_over([[1]])
+
+ assert_equal(5, paginator.with(cap: 5).cap)
+ assert_raises(Dexpace::InvalidArgumentError) { paginator.with(cap: 0) }
+ end
+
+ test "PAGE-10: the default cap is effectively unbounded" do
+ assert_equal(Float::INFINITY, paginator_over([[1]]).cap)
+ # 600 single-item pages, no cap set: every one is walked. The number is the fixture's own and
+ # the two must match, or the assertion is testing the fixture rather than the cap.
+ assert_equal(600, paginator_over(Array.new(600) { [1] }).items.count)
+ end
+
+ test "PAGE-8: the engine holds only immutable configuration and is safe to share" do
+ paginator = paginator_over([[1], [2]], sequences: 2)
+
+ assert_predicate(paginator, :frozen?)
+ assert_empty(paginator.instance_variables)
+ assert_equal(%i[transport template strategy cap options], paginator.members)
+ assert_equal([[1, 2], [1, 2]], [paginator.items.to_a, paginator.items.to_a])
+ end
+
+ test "PAGE-8: two threads share one engine and each drives its own full sequence" do
+ paginator = paginator_over([[1], [2]], sequences: 2)
+ results = Array.new(2) { ::Thread.new { paginator.items.to_a } }.map(&:value)
+
+ assert_equal([[1, 2], [1, 2]], results)
+ assert_equal(4, paginator.transport.calls.size)
+ end
+
+ test "PAGE-36: the default is no overrides, and a configured override reaches every exchange" do
+ assert_same(Dexpace::RequestOptions::EMPTY, paginator_over([[1]]).options)
+ options = Dexpace::RequestOptions.builder.tap { |b| b.timeout = 1.5 }.build
+ paginator = paginator_over([[1], [2], [3]], options: options)
+ paginator.items.to_a
+
+ assert_equal(3, paginator.transport.calls.size)
+ paginator.transport.calls.each { |(_request, opts, _cancellation)| assert_same(options, opts) }
+ end
+
+ test "PIPE-26 / PIPE-27: a standard pipeline is a transport, and the paginator never closes it" do
+ # The object a generated client hands in: Pipeline.standard over a scripted transport, end to
+ # end through the redirect, retry and logging steps -- which is also where a nil cancellation
+ # would die inside the retry step (P7-102).
+ downstream = scripted_transport([[1], [2]])
+ pipeline = Dexpace::Pipeline.standard(downstream)
+ paginator = paginator_over([[1], [2]], transport: pipeline)
+
+ assert_equal([1, 2], paginator.items.to_a)
+ assert_equal(2, downstream.calls.size)
+ refute_predicate(pipeline, :closed?)
+ downstream.calls.each do |(_request, options, cancellation)|
+ assert_equal([Dexpace::RequestOptions::EMPTY, Dexpace::Cancellation.none],
+ [options, cancellation],)
+ end
+ end
+
+ test "HTTP-4: every member is validated and named" do
+ assert_raises(Dexpace::InvalidArgumentError) { paginator_over([[1]], transport: :not_callable) }
+ assert_raises(Dexpace::InvalidArgumentError) { paginator_over([[1]], template: "https://x/") }
+ assert_raises(Dexpace::InvalidArgumentError) { paginator_over([[1]], strategy: Object.new) }
+ assert_raises(Dexpace::InvalidArgumentError) { paginator_over([[1]], options: { timeout: 1 }) }
+ refute_respond_to(Dexpace::Page::Paginator, :new)
+ error = assert_raises(Dexpace::InvalidArgumentError) do
+ Dexpace::Page::Paginator.build(transport: scripted_transport([]), template: nil,
+ strategy: EndlessStrategy.new,)
+ end
+
+ assert_equal("template is required", error.message)
+ end
+
+ test "the scoped openers require a block and close the walk on every exit" do
+ paginator = paginator_over([[1], [2]])
+
+ assert_raises(Dexpace::InvalidArgumentError) { paginator.each_item }
+ assert_raises(Dexpace::InvalidArgumentError) { paginator.each_page }
+ items = []
+
+ assert_nil(paginator.each_item { |item| items << item })
+ assert_equal([1, 2], items)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/query_rewriter_test.rb b/gems/dexpace-core/test/dexpace/page/query_rewriter_test.rb
new file mode 100644
index 0000000..7aa592a
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/query_rewriter_test.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# Exercises: PAGE-21, PAGE-22, PAGE-23, PAGE-24, and phase 7c's P7-4.
+#
+# This module exists because Ruby's query helpers are the exact inverse of PAGE-22 in both
+# directions (measured on every CI row: URI.decode_www_form("q=a+b") -> [["q", "a b"]] and
+# URI.encode_www_form_component("a b") -> "a+b"), and because Dexpace::Query.parse(q).encode
+# re-encodes every parameter, which is the canonicalisation PAGE-21 forbids in as many words. The
+# codec it uses is phase 1's PercentEncoding, already shipped and tested; nothing new is encoded.
+class DexpacePageQueryRewriterTest < DexpaceTestCase
+ R = Dexpace::Page::QueryRewriter
+
+ test "PAGE-21: untargeted parameters are copied byte-for-byte, order preserved" do
+ result = R.set("flag&filter=a:b&page=1", "page", "2")
+
+ assert_equal("flag&filter=a:b&page=2", result)
+ # The requirement's own conformance clause asks for byte identity of the untargeted parts, not
+ # for URL equality -- so assert the segments, not the whole string.
+ assert_equal(%w[flag filter=a:b], result.split("&")[0, 2])
+ end
+
+ test "PAGE-21: reserved characters, empty segments and a value with an = survive untouched" do
+ assert_equal("a=b=c&&x=%zz&page=2", R.set("a=b=c&&x=%zz&page=1", "page", "2"))
+ assert_equal("q=a+b&page=2", R.set("q=a+b", "page", "2"))
+ end
+
+ test "PAGE-22: encoding is RFC 3986 component encoding" do
+ assert_equal("q=a%20b", R.set("", "q", "a b"))
+ assert_equal("token=a%2Bb%2Fc%3D", R.set("", "token", "a+b/c="))
+ assert_equal("na%20me=v", R.set("", "na me", "v"))
+ end
+
+ test "PAGE-22: reading decodes with the same semantics -- a literal + reads back as +" do
+ assert_equal("a+b", R.get("q=a+b", "q"))
+ assert_equal("a b", R.get("q=a%20b", "q"))
+ assert_equal("", R.get("flag", "flag")) # value-less flag -> empty string
+ assert_equal("", R.get("flag=", "flag")) # an empty value reads as empty too
+ assert_equal("1", R.get("p=1&p=2", "p")) # first match wins
+ assert_equal("b=c", R.get("a=b=c", "a")) # split on the FIRST = only
+ assert_nil(R.get("p=1", "absent"))
+ assert_nil(R.get(nil, "p"))
+ assert_nil(R.get("", "p"))
+ end
+
+ test "PAGE-22: the name is matched decoded, so pa%67e and page are one parameter" do
+ assert_equal("1", R.get("pa%67e=1", "page"))
+ assert_equal("page=2", R.set("pa%67e=1", "page", "2"))
+ end
+
+ test "PAGE-23: replace first in place, drop duplicates, append if absent, remove when nil" do
+ assert_equal("page=2&sort=asc", R.set("page=1&sort=asc", "page", "2"))
+ assert_equal("sort=asc", R.set("page=1&sort=asc", "page", nil))
+ assert_equal("p=1&q=2", R.set(R.set("p=1&p=9", "p", "1"), "q", "2"))
+ assert_equal("sort=asc&page=2", R.set("sort=asc", "page", "2"))
+ assert_equal("a=1&p=3&b=2", R.set("a=1&p=1&b=2&p=2", "p", "3"))
+ assert_equal("a=1&b=2", R.set("a=1&p=1&b=2&p=2", "p", nil))
+ end
+
+ test "P7-4: removing the only parameter yields no query at all, not a dangling ?" do
+ url = Dexpace::URL.parse!("https://x/a?page=1")
+
+ assert_nil(R.set("page=1", "page", nil))
+ assert_equal("https://x/a", Dexpace::URL.external_form(R.rewrite_url(url, "page", nil)))
+ end
+
+ test "PAGE-24: every non-query component survives exactly" do
+ url = Dexpace::URL.parse!("https://user:pw@api.example.com:8443/a/b?flag&page=1#frag")
+ out = R.rewrite_url(url, "page", "2")
+
+ assert_equal("https://user:pw@api.example.com:8443/a/b?flag&page=2#frag",
+ Dexpace::URL.external_form(out),)
+ assert_equal("https", out.scheme)
+ assert_equal("user:pw", out.userinfo)
+ assert_equal(8443, out.port)
+ assert_equal("/a/b", out.path)
+ assert_equal("frag", out.fragment)
+ assert_equal("flag&page=1", url.query) # the caller's frozen URI is untouched
+ end
+
+ test "PAGE-24: a URL with no query at all gains one, and nothing else moves" do
+ url = Dexpace::URL.parse!("https://h/p#f")
+
+ assert_equal("https://h/p?cursor=c#f",
+ Dexpace::URL.external_form(R.rewrite_url(url, "cursor", "c")),)
+ end
+
+ test "PAGE-21 (property): every other segment stays byte-identical, and get reads back set" do
+ pieces = ["flag", "filter=a:b", "x=%20", "q=a+b", "p=1", "p=2", "", "k==v", "%zz=1", "r=/;,@"]
+ names = %w[p page cursor]
+ values = ["2", "a b", "a+b/c=", "", "%", "é"]
+ sample(count: 128) do |rng|
+ query = Array.new(rng.rand(0..6)) { pieces[rng.rand(pieces.size)] }.join("&")
+ name = names[rng.rand(names.size)]
+ value = values[rng.rand(values.size)]
+ out = R.set(query, name, value)
+
+ assert_equal(value, R.get(out, name), "get(set(#{query.inspect}, #{name}, #{value.inspect}))")
+ untargeted = ->(q) { q.to_s.split("&", -1).reject { |seg| seg.split("=", 2).first == name } }
+
+ assert_equal(untargeted.call(query), untargeted.call(out),
+ "untargeted segments of #{query.inspect}",)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page/walk_test.rb b/gems/dexpace-core/test/dexpace/page/walk_test.rb
new file mode 100644
index 0000000..c5f8d2e
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page/walk_test.rb
@@ -0,0 +1,209 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require_relative "../../support/page_fixtures"
+
+# Exercises: PAGE-5 (enforced), PAGE-7, PAGE-9, PAGE-13, PAGE-15, PAGE-36 -- through the
+# private_constant Walk over the Paginator's private per-walk Drive, reached the way 4c's
+# cursor_test reaches its private cursor.
+#
+# The Walk is the object phase 3's Enumerator rule forces into existence: an Enumerator abandoned
+# mid-#next never runs its ensure, and neither does an ordinary #each -- and block_given? is TRUE
+# inside #each when reached through to_enum(:each), so no in-method guard helps. The only defence is
+# where the resource lives, and it lives here (pagination/318ae05d, pagination/b2a85752).
+class DexpacePageWalkTest < DexpaceTestCase
+ include PageFixtures
+
+ test "PAGE-7: after a terminal page, repeated probes perform no further exchange" do
+ paginator = paginator_over([[1], [2]])
+ walk = walk_over(paginator)
+
+ 2.times { walk.fetch_next_page.close }
+
+ 3.times { assert_nil(walk.fetch_next_page) }
+
+ assert_equal(2, paginator.transport.calls.size)
+ end
+
+ test "PAGE-9: with a server echoing one cursor forever and cap=N, exactly N exchanges" do
+ transport = ScriptedTransport.new(page_script(20))
+ paginator = paginator_over([], transport: transport, strategy: EndlessStrategy.new, cap: 3)
+ walk = walk_over(paginator)
+
+ pages = Array.new(4) { walk.fetch_next_page }
+
+ assert_equal(3, transport.calls.size)
+ assert_equal(3, pages.compact.size)
+ assert_nil(pages.last)
+ pages.compact.each(&:close)
+ end
+
+ test "PAGE-36: the per-call overrides reach EVERY page request, not just the first" do
+ options = Dexpace::RequestOptions.build(timeout: 1.5, max_retries: 0, tags: { "t" => "v" })
+ paginator = paginator_over([[1], [2], [3]], options: options)
+ walk = walk_over(paginator)
+
+ 3.times { walk.fetch_next_page.close }
+
+ assert_equal(3, paginator.transport.calls.size)
+ paginator.transport.calls.each { |(_request, opts, _cancellation)| assert_same(options, opts) }
+ end
+
+ test "P7-102: the sync engine passes Cancellation.none to the transport, never nil" do
+ paginator = paginator_over([[1]])
+ walk_over(paginator).fetch_next_page.close
+
+ assert_same(Dexpace::Cancellation.none, paginator.transport.calls.first.last)
+ end
+
+ test "PAGE-13: a throwing parse closes the response inline exactly once and propagates" do
+ body = fake_response_body
+ paginator = paginator_over([[1]], bodies: [body], strategy: raising_strategy(KeyError.new("k")))
+ walk = walk_over(paginator)
+
+ assert_raises(KeyError) { walk.fetch_next_page }
+ assert_equal(1, body.closes)
+ assert_nil(walk.current)
+ assert_nil(walk.buffered)
+ end
+
+ test "PAGE-13: a close failure does NOT mask the parse failure; it is attached as suppressed" do
+ body = fake_response_body(close_error: IOError.new("close boom"))
+ paginator = paginator_over([[1]], bodies: [body], strategy: raising_strategy(KeyError.new("k")))
+ walk = walk_over(paginator)
+
+ error = assert_raises(KeyError) { walk.fetch_next_page }
+
+ assert_equal("k", error.message)
+ assert_equal([IOError], Dexpace.suppressed(error).map(&:class))
+ assert_equal(1, body.closes)
+ end
+
+ test "PAGE-13: a fatal-family parse failure still closes the response, and carries no trail" do
+ body = fake_response_body(close_error: IOError.new("close boom"))
+ paginator = paginator_over([[1]], bodies: [body],
+ strategy: raising_strategy(NoMemoryError.new("f")),)
+ walk = walk_over(paginator)
+
+ error = assert_raises(NoMemoryError) { walk.fetch_next_page }
+
+ assert_equal(1, body.closes)
+ assert_empty(Dexpace.suppressed(error))
+ end
+
+ test "PAGE-13 / P7-107: a parse answering something other than an Info is refused and closed" do
+ body = fake_response_body
+ not_info = Class.new { def parse(_response, _template) = [[1], nil] }.new
+ walk = walk_over(paginator_over([[1]], bodies: [body], strategy: not_info))
+
+ error = assert_raises(Dexpace::InvalidArgumentError) { walk.fetch_next_page }
+
+ assert_match(/parse must return a Dexpace::Page::Info, got Array/, error.message)
+ assert_equal(1, body.closes)
+ end
+
+ # The two slots, their release discipline and the walk's own latch (PAGE-12, PAGE-15).
+ class SlotsTest < DexpaceTestCase
+ include PageFixtures
+
+ test "PAGE-15: #close releases BOTH the held page and the buffered page" do
+ a = fake_response_body
+ b = fake_response_body
+ walk = walk_over(paginator_over([[1], [2]], bodies: [a, b]))
+ walk.hold(walk.fetch_next_page)
+ walk.buffer(walk.fetch_next_page)
+
+ walk.close
+
+ assert_equal([1, 1], [a.closes, b.closes])
+ assert_nil(walk.current)
+ assert_nil(walk.buffered)
+ assert_predicate(walk, :closed?)
+ end
+
+ test "PAGE-15: both held pages fail to close -> the first propagates, the second suppressed" do
+ first = fake_response_body(close_error: IOError.new("first"))
+ second = fake_response_body(close_error: IOError.new("second"))
+ walk = walk_over(paginator_over([[1], [2]], bodies: [first, second]))
+ walk.hold(walk.fetch_next_page)
+ walk.buffer(walk.fetch_next_page)
+
+ error = assert_raises(IOError) { walk.close }
+
+ assert_equal("first", error.message)
+ assert_equal(["second"], Dexpace.suppressed(error).map(&:message))
+ assert_equal([1, 1], [first.closes, second.closes])
+ assert_nil(walk.current) # both slots cleared BEFORE the raise, so the latch and the slots
+ assert_nil(walk.buffered) # agree: nothing still references a page whose close failed (R1-1)
+ walk.close # the latch is flipped; no second release
+
+ assert_equal([1, 1], [first.closes, second.closes])
+ end
+
+ test "PAGE-15 / PAGE-12: the advance close in #hold is SURFACED; the new page is held first" do
+ first = fake_response_body(close_error: IOError.new("advance"))
+ second = fake_response_body
+ walk = walk_over(paginator_over([[1], [2]], bodies: [first, second]))
+ walk.hold(walk.fetch_next_page)
+ page_two = walk.fetch_next_page
+
+ assert_raises(IOError) { walk.hold(page_two) }
+ assert_same(page_two, walk.current) # held BEFORE the previous close: a raise strands nothing
+ walk.close
+
+ assert_equal([1, 1], [first.closes, second.closes])
+ end
+
+ test "PAGE-12 / PAGE-6: the look-ahead is one slot and a staged page is never displaced" do
+ a = fake_response_body
+ b = fake_response_body
+ walk = walk_over(paginator_over([[1], [2]], bodies: [a, b]))
+ staged = walk.fetch_next_page
+ walk.buffer(staged)
+ extra = walk.fetch_next_page
+
+ assert_raises(Dexpace::Page::PageStateError) { walk.buffer(extra) }
+ assert_same(staged, walk.buffered)
+ assert_same(staged, walk.take_buffered)
+ assert_nil(walk.buffered)
+ walk.close
+ extra.close
+ end
+
+ test "a closed walk fetches nothing, so a late probe acquires no response nothing will close" do
+ paginator = paginator_over([[1], [2]])
+ walk = walk_over(paginator)
+ walk.fetch_next_page.close
+ walk.close
+
+ assert_nil(walk.fetch_next_page)
+ assert_equal(1, paginator.transport.calls.size)
+ end
+
+ test "PAGE-5: the strategy never sees a closed or mutated response" do
+ body = fake_response_body
+ seen = []
+ strategy = Class.new do
+ define_method(:parse) do |response, _template|
+ seen << response.body.closes
+ Dexpace::Page::Info.terminal(items: [])
+ end
+ end.new
+ walk_over(paginator_over([[1]], bodies: [body], strategy: strategy)).fetch_next_page.close
+
+ assert_equal([0], seen)
+ end
+
+ test "the walk owns its slots and is a private_constant; Drive is Paginator's, private" do
+ assert_raises(::NameError) { Dexpace::Page::Walk }
+ assert_raises(::NameError) { Dexpace::Page::Paginator::Drive }
+ walk = walk_over(paginator_over([[1]]))
+
+ assert_kind_of(Dexpace::Closeable, walk)
+ assert_predicate(walk, :owned?)
+ refute_predicate(walk, :closed?)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/page_test.rb b/gems/dexpace-core/test/dexpace/page_test.rb
new file mode 100644
index 0000000..89d4980
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/page_test.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../test_helper"
+require "dexpace"
+require_relative "../support/page_fixtures"
+
+# Exercises: PAGE-2, PAGE-3, PAGE-19 (.next_request_from, the reusable branch), PAGE-23, PAGE-34
+# (the two keys' shape); P7-2, P7-5, P7-101, P7-104, P7-107, P7-117.
+#
+# A Page is a plain class including Dexpace::Closeable and NOT a Data, because a Data instance is
+# frozen and cannot hold the latch (phase 3b's finding, applied one layer up). Response#close is a
+# pure forward with no latch of its own, and Response#body may be nil or a BufferBody whose #close
+# is a documented no-op -- so PAGE-27's exactly-once cannot be delegated to the response. Every
+# response below is a REAL Dexpace::Response over a FakeResponseBody, whose UN-latched #closes
+# counter is what makes the page's own latch observable.
+class DexpacePageTest < DexpaceTestCase
+ include PageFixtures
+
+ test "PAGE-3: closing the page releases the response exactly once, however many times called" do
+ response = page_response
+ page = Dexpace::Page.build(response: response, items: [1, 2])
+
+ page.close
+ page.close
+ page.close
+
+ assert_equal(1, closes_of(response))
+ assert_predicate(page, :closed?)
+ assert_predicate(page, :owned?)
+ end
+
+ test "PAGE-2: items, status, headers and request stay readable after close" do
+ request = fake_request(url: "https://x/i?p=3")
+ response = page_response(request: request, status: 201, headers: { "x-a" => "1" })
+ page = Dexpace::Page.build(response: response, items: [1, 2])
+ page.close
+
+ assert_equal([1, 2], page.items)
+ assert_equal(201, page.status.code)
+ assert_equal(["1"], page.headers["x-a"])
+ assert_same(request, page.request)
+ assert_same(response, page.response)
+ end
+
+ test "PAGE-2: items are never nil, MAY be empty, are frozen; the elements stay the caller's" do
+ element = Object.new
+ source = [element]
+ page = Dexpace::Page.build(response: page_response, items: source)
+ source << Object.new
+
+ assert_equal([element], page.items)
+ assert_predicate(page.items, :frozen?)
+ assert_same(element, page.items.first) # shallow: never Model.own's deep copy (P7-101)
+ refute_predicate(element, :frozen?)
+ assert_empty(Dexpace::Page.build(response: page_response, items: []).items)
+ assert_raises(Dexpace::InvalidArgumentError) do
+ Dexpace::Page.build(response: page_response, items: nil)
+ end
+ end
+
+ test "PAGE-3: a close error propagates once and the latch stays flipped" do
+ response = page_response(close_error: RuntimeError.new("boom"))
+ page = Dexpace::Page.build(response: response, items: [])
+
+ assert_raises(RuntimeError) { page.close }
+ assert_predicate(page, :closed?)
+ page.close # no second release attempted
+
+ assert_equal(1, closes_of(response))
+ end
+
+ test "the page takes a real Response and nothing shaped like one; .new is private" do
+ duck = Struct.new(:status, :headers, :request, :close).new
+
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Page.build(response: duck, items: []) }
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Page.build(response: nil, items: []) }
+ refute_respond_to(Dexpace::Page, :new)
+ end
+
+ test "PAGE-2 / P7-107: items must be an Array; an Enumerable that is not one is refused" do
+ error = assert_raises(Dexpace::InvalidArgumentError) do
+ Dexpace::Page.build(response: page_response, items: (1..3))
+ end
+
+ assert_match(/items must be an Array, got Range/, error.message)
+ end
+
+ test "spec-forced boundary 5: no serializer token under lib/dexpace/page, comments included" do
+ # 7b's serde-boundary scan (its Task 11) does not strip comments, so the two bare tokens it
+ # refuses must appear nowhere -- not in YARD, not in a string. This is 7c's own guard until
+ # that gate moves the page/ row from PENDING to GUARDED (P7-108).
+ root = File.expand_path("../../lib/dexpace", __dir__)
+ files = Dir.glob("page/**/*.rb", base: root) + ["page.rb"]
+ banned = /(?; rel=next would be re-fetched until the page cap (review round 1's R1-2).
+ assert_nil(next_request("#"))
+ assert_nil(next_request("#top"))
+ assert_nil(next_request(" #x "))
+ # Deliberately NOT screened -- the check is syntactic, never on the resolved URL: a target
+ # that reaches the current page by another spelling is a next request PAGE-9's cap bounds.
+ assert_equal("https://x/v1/items?", Dexpace::URL.external_form(next_request("?").url))
+ assert_equal(BASE, Dexpace::URL.external_form(next_request("//").url))
+ assert_equal(BASE, Dexpace::URL.external_form(next_request(BASE).url))
+ end
+
+ test "P7-104: a target this client cannot dispatch is end-of-stream, as REDIR-18 screens" do
+ # Every one of these is a SUCCESSFUL URI::RFC3986_PARSER.join (6b's P6-95): a mailto:, a
+ # javascript:, a host-less http:foo and an empty-host http:///p. Without the screen each would
+ # become a Request the transport then fails on, which is an error where PAGE-19 wants an end.
+ assert_nil(next_request("mailto:a@b"))
+ assert_nil(next_request("javascript:alert(1)"))
+ assert_nil(next_request("http:foo"))
+ assert_nil(next_request("http:///p"))
+ assert_nil(next_request("ftp://h/z"))
+ refute_nil(next_request("HTTPS://h/z")) # the scheme screen folds without a locale
+ end
+
+ test "PAGE-23: the template's method, headers and body travel; only the URL changes" do
+ template = fake_request(url: BASE, method: "POST", headers: { "x-a" => "1" }, body: fake_body)
+ nxt = next_request("?page=2", template: template)
+
+ assert_equal(template.method, nxt.method)
+ assert_equal(template.headers, nxt.headers)
+ assert_same(template.body, nxt.body)
+ refute_equal(Dexpace::URL.external_form(template.url), Dexpace::URL.external_form(nxt.url))
+ end
+
+ test "PAGE-19: the base is the RESPONSE's request URL, not the template's" do
+ template = fake_request(url: "https://template/only?page=0")
+ response = page_response(request: fake_request(url: "https://final/hop/list?page=3"))
+
+ nxt = Dexpace::Page.next_request_from(template, response, "?page=4")
+
+ assert_equal("https://final/hop/list?page=4", Dexpace::URL.external_form(nxt.url))
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb
index b813b6a..02630f3 100644
--- a/gems/dexpace-core/test/dexpace_test.rb
+++ b/gems/dexpace-core/test/dexpace_test.rb
@@ -80,9 +80,12 @@ class DexpaceTest < DexpaceTestCase
# PhaseSevenLayers case below pins, and its two errors are namespaced there (SSE-37 makes the
# subsystem self-contained, and nothing outside it raises either).
SSE_LAYER = %i[SSE].freeze
+ # Phase 7c: ONE flat constant, the page value that is also the subsystem's namespace (P7-2);
+ # everything else the layer ships is under Dexpace::Page, which the PhaseSevenLayers case pins.
+ PAGE_LAYER = %i[Page].freeze
LAYERS = [
DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, CONTEXT_LAYER, RECOVERY_LAYER, PIPELINE_LAYER,
- CONFIGURATION_LAYER, RESILIENCE_LAYER, AUTH_LAYER, REDIRECT_LAYER, SSE_LAYER,
+ CONFIGURATION_LAYER, RESILIENCE_LAYER, AUTH_LAYER, REDIRECT_LAYER, SSE_LAYER, PAGE_LAYER,
].flatten.freeze
test "defines nothing outside the Dexpace namespace" do
@@ -284,6 +287,37 @@ class PhaseSevenLayers < DexpaceTestCase
assert_raises(::NameError) { Dexpace::SSE::Reader::DIGITS_ONLY }
assert_raises(::NameError) { Dexpace::SSE::LimitExceededError::KINDS }
end
+
+ # A consumer requires "dexpace" and nothing else: the pagination layer resolves too (phase
+ # 7c), under Dexpace::Page -- the page value is the namespace (P7-2) -- with its three
+ # private_constants and the two engines' private drives as unreachable as Dexpace::Hooks, and
+ # none of the nested names defined at the top level or flat under Dexpace (P3-7's shadowing
+ # audit, run rather than asserted).
+ test "requiring dexpace alone makes the whole pagination layer resolve, its helpers private" do
+ page = Dexpace::Page
+
+ assert_equal(
+ %i[
+ AsyncPaginator CursorStrategy Fetchers Info Items LinkStrategy PageNumberStrategy
+ PageStateError Pages Paginator QueryRewriter
+ ],
+ page.constants(false).sort,
+ )
+ assert_kind_of(Dexpace::Closeable, page.allocate)
+ assert_raises(::NameError) { Dexpace::Page::Walk }
+ assert_raises(::NameError) { Dexpace::Page::Closing }
+ assert_raises(::NameError) { Dexpace::Page::LinkHeader }
+ assert_raises(::NameError) { Dexpace::Page::Paginator::Drive }
+ assert_raises(::NameError) { Dexpace::Page::Fetchers::Drive }
+ assert_raises(::NameError) { Dexpace::Page::AsyncPaginator::Pump }
+ %i[
+ Info QueryRewriter LinkHeader CursorStrategy PageNumberStrategy LinkStrategy Walk Closing
+ Items Pages Paginator AsyncPaginator Fetchers PageStateError
+ ].each do |name|
+ refute(Object.const_defined?(name), "#{name} shadows a top-level constant")
+ refute(Dexpace.const_defined?(name, false), "#{name} shadows a flat Dexpace constant")
+ end
+ end
end
# The shadowing names this SDK never defines: each would make a bare `rescue ArgumentError`,
diff --git a/gems/dexpace-core/test/support/page_fixtures.rb b/gems/dexpace-core/test/support/page_fixtures.rb
new file mode 100644
index 0000000..496bdde
--- /dev/null
+++ b/gems/dexpace-core/test/support/page_fixtures.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require "dexpace"
+require_relative "fake_body"
+require_relative "fake_response_body"
+require_relative "scripted_transport"
+require_relative "scripted_async_transport"
+
+# The requests, responses, strategies and engines every phase-7c pagination suite is written
+# against, in the RecoveryFixtures / RedirectFixtures shape. No Response double is written
+# anywhere in phase 7c: every response here is a REAL Dexpace::Response built through
+# Response.builder over phase 3b's FakeResponseBody (an un-latched #closes counter that raises
+# from #close on demand -- the un-latched count is exactly what proves the Page's own latch) or
+# phase 4b's RecordingBody, so `response.body.closes` says whether and how often a page's
+# response was closed.
+#
+# The page sequence a test wants -- `[[1, 2], [3]]` -- is scripted through ScriptedStrategy, a
+# stateless strategy that reads the page INDEX off the executed request's `p` query parameter
+# and derives the next request by splicing `p=index+1`; the transport script it is paired with
+# is one callable per page that builds the response around the request the engine actually sent,
+# so `response.request` is the executed request the strategies and PAGE-17 read. A test that
+# wants to observe or break a page's close hands the fixture its own bodies (`bodies:`), one per
+# page; a test that needs the transport reads it back off the engine.
+module PageFixtures
+ # A stateless strategy over a frozen page script: page `index` yields `pages[index]` and the
+ # next request carries `p=index+1`, until the last page, which is terminal.
+ class ScriptedStrategy
+ def initialize(pages)
+ @pages = pages.map { |items| items.dup.freeze }.freeze
+ freeze
+ end
+
+ def parse(response, template)
+ index = (Dexpace::Page::QueryRewriter.get(response.request.url.query, "p") || "0").to_i
+ items = @pages.fetch(index) { [] }
+ return Dexpace::Page::Info.terminal(items: items) if index + 1 >= @pages.size
+
+ url = Dexpace::Page::QueryRewriter.rewrite_url(template.url, "p", (index + 1).to_s)
+ Dexpace::Page::Info.build(items: items, next_request: template.with(url: url))
+ end
+ end
+
+ # A strategy that always answers the same next request: PAGE-9's "server echoing the same
+ # cursor forever".
+ class EndlessStrategy
+ def parse(_response, template)
+ Dexpace::Page::Info.build(items: [1], next_request: template)
+ end
+ end
+
+ # A strategy whose #parse raises, for PAGE-13's parse-failure paths.
+ class RaisingStrategy
+ def initialize(error) = @error = error
+
+ def parse(_response, _template)
+ raise @error
+ end
+ end
+
+ def fake_request(url: "https://x/i", method: "GET", headers: {}, body: nil)
+ builder = Dexpace::Headers.builder
+ headers.each { |name, value| builder.add(name, value) }
+ Dexpace::Request.build(method: method, url: url, headers: builder.build, body: body)
+ end
+
+ def fake_body = FakeBody.new("payload", replayable: true)
+
+ # A closable response body whose #close raises `close_error` when one is given.
+ def fake_response_body(close_error: nil)
+ FakeResponseBody.new(Dexpace::IO::BufferedSource.of_bytes("".b), close_error: close_error)
+ end
+
+ # A REAL response over a closable body; `headers:` is added through the inbound builder.
+ def page_response(request: fake_request, status: 200, headers: {}, body: nil, close_error: nil)
+ builder = Dexpace::Response.builder
+ builder.request = request
+ builder.protocol = Dexpace::Protocol::HTTP_1_1
+ builder.status = status
+ builder.headers = inbound_headers(headers)
+ builder.body = body || fake_response_body(close_error: close_error)
+ builder.build
+ end
+
+ def inbound_headers(headers)
+ inbound = Dexpace::Headers.inbound_builder
+ headers.each { |name, values| Array(values).each { |value| inbound.add(name, value) } }
+ inbound.build
+ end
+
+ def closes_of(response) = response.body.closes
+
+ # One transport script entry per page: a callable that builds the response around the request
+ # the engine actually sent, over the body at that index (or a fresh one). `sequences` repeats
+ # the script for a test that walks the same engine more than once (PAGE-8).
+ def page_script(count, bodies: nil, sequences: 1)
+ Array.new(count * sequences) do |index|
+ lambda do |request, _options, _cancellation|
+ page_response(request: request, body: bodies&.fetch(index % count))
+ end
+ end
+ end
+
+ def scripted_transport(pages, bodies: nil, sequences: 1)
+ ScriptedTransport.new(page_script(pages.size, bodies: bodies, sequences: sequences))
+ end
+
+ # The strategy-based sync engine over a scripted page sequence.
+ def paginator_over(pages, transport: nil, bodies: nil, strategy: nil, sequences: 1, **keywords)
+ Dexpace::Page::Paginator.build(
+ transport: transport || scripted_transport(pages, bodies: bodies, sequences: sequences),
+ template: keywords.delete(:template) || fake_request,
+ strategy: strategy || ScriptedStrategy.new(pages),
+ **keywords,
+ )
+ end
+
+ # The async engine over the same scripted sequence; `deferred: true` holds every future for
+ # `transport.settle_next!`.
+ def async_paginator_over(pages, transport: nil, bodies: nil, strategy: nil, deferred: false,
+ **keywords)
+ transport ||= ScriptedAsyncTransport.new(page_script(pages.size, bodies: bodies),
+ settle_later: deferred,)
+ Dexpace::Page::AsyncPaginator.build(
+ transport: transport,
+ template: keywords.delete(:template) || fake_request,
+ strategy: strategy || ScriptedStrategy.new(pages),
+ **keywords,
+ )
+ end
+
+ # A Walk over a paginator's drive, the private objects reached the way 4c's cursor_test reaches
+ # its private cursor.
+ def walk_over(paginator)
+ walk = Dexpace::Page.const_get(:Walk)
+ drive = Dexpace::Page::Paginator.const_get(:Drive)
+ walk.new(drive.new(paginator), cap: paginator.cap)
+ end
+
+ def raising_strategy(error) = RaisingStrategy.new(error)
+
+ # A page over a fresh response, for the fetcher front-end's suites.
+ def page_with(items: [], next_link: nil, continuation_token: nil, body: nil)
+ Dexpace::Page.build(response: page_response(body: body), items: items, next_link: next_link,
+ continuation_token: continuation_token,)
+ end
+end
diff --git a/gems/dexpace-core/test/support/probe_executor.rb b/gems/dexpace-core/test/support/probe_executor.rb
new file mode 100644
index 0000000..47efb64
--- /dev/null
+++ b/gems/dexpace-core/test/support/probe_executor.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+# The two executor shapes PAGE-29 and PAGE-30 need beyond phase 2's InlineExecutor: a QUEUED
+# executor that records every posted block and runs the queue on a fresh ::Thread it joins
+# (`#drain`), so "the consumer ran on the executor's thread" is an assertion about a thread that
+# is not the test's and cannot pass vacuously; and a REJECTING executor that runs the first
+# `after` posts inline and raises ProbeExecutor::Rejected on the next -- the one rejection
+# vocabulary Ruby offers, since there is no RejectedExecutionException to catch by type.
+#
+# #drain loops until the queue is empty rather than over a snapshot, because a block it runs
+# may post the next page's continuation while it is still draining (the pump re-arms from
+# inside the settlement it handles), and it joins the thread before returning, which is what
+# keeps DexpaceTestCase's thread-count teardown green.
+class ProbeExecutor
+ # The rejection a `:rejecting` executor raises from #post once its budget is spent.
+ class Rejected < StandardError; end
+
+ attr_reader :posts, :threads
+
+ def initialize(mode: :queued, after: nil)
+ unless %i[queued rejecting].include?(mode)
+ raise ArgumentError, "mode must be :queued or :rejecting"
+ end
+
+ @mode = mode
+ @after = after
+ @posts = 0
+ @queue = []
+ @threads = []
+ @mutex = ::Thread::Mutex.new
+ end
+
+ def post(&block)
+ @mutex.synchronize { @posts += 1 }
+ if @mode == :queued
+ @mutex.synchronize { @queue << block }
+ else
+ raise Rejected, "ProbeExecutor rejected post ##{@posts} (after #{@after})" if @posts > @after
+
+ yield
+ end
+ nil
+ end
+
+ # Runs every queued block, in order, on one fresh thread, then joins it; nil.
+ def drain
+ thread = ::Thread.new do
+ @mutex.synchronize { @threads << ::Thread.current }
+ while (block = @mutex.synchronize { @queue.shift })
+ block.call
+ end
+ end
+ thread.join
+ nil
+ end
+
+ def pending = @mutex.synchronize { @queue.size }
+end
diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb
new file mode 100644
index 0000000..44526cd
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+module Dexpace
+ class Page
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb
new file mode 100644
index 0000000..597ff93
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+module Dexpace
+ class Page
+ class Info
+ end
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs
new file mode 100644
index 0000000..e217535
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs
@@ -0,0 +1,4 @@
+module Dexpace
+ class Page
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs
new file mode 100644
index 0000000..21445a6
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs
@@ -0,0 +1,6 @@
+module Dexpace
+ class Page
+ class Info
+ end
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb
new file mode 100644
index 0000000..44526cd
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+module Dexpace
+ class Page
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb
new file mode 100644
index 0000000..597ff93
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+module Dexpace
+ class Page
+ class Info
+ end
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs
new file mode 100644
index 0000000..e217535
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs
@@ -0,0 +1,4 @@
+module Dexpace
+ class Page
+ end
+end
diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs
new file mode 100644
index 0000000..21445a6
--- /dev/null
+++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs
@@ -0,0 +1,6 @@
+module Dexpace
+ class Page
+ class Info
+ end
+ end
+end
diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt
index 03e99b6..14f0a11 100644
--- a/test/fixtures/surface/dexpace-core.txt
+++ b/test/fixtures/surface/dexpace-core.txt
@@ -698,6 +698,82 @@ Dexpace::Outcome::Success#success?
Dexpace::Outcome::Success.build
Dexpace::OutcomeError
Dexpace::OutcomeError#offending_class
+Dexpace::Page
+Dexpace::Page#continuation_token
+Dexpace::Page#headers
+Dexpace::Page#items
+Dexpace::Page#next_link
+Dexpace::Page#request
+Dexpace::Page#response
+Dexpace::Page#status
+Dexpace::Page.build
+Dexpace::Page.next_request_from
+Dexpace::Page::AsyncPaginator
+Dexpace::Page::AsyncPaginator#cap
+Dexpace::Page::AsyncPaginator#executor
+Dexpace::Page::AsyncPaginator#options
+Dexpace::Page::AsyncPaginator#strategy
+Dexpace::Page::AsyncPaginator#template
+Dexpace::Page::AsyncPaginator#transport
+Dexpace::Page::AsyncPaginator#walk
+Dexpace::Page::AsyncPaginator#walk_pages
+Dexpace::Page::AsyncPaginator.build
+Dexpace::Page::CursorStrategy
+Dexpace::Page::CursorStrategy#extract
+Dexpace::Page::CursorStrategy#parameter
+Dexpace::Page::CursorStrategy#parse
+Dexpace::Page::CursorStrategy.build
+Dexpace::Page::Fetchers
+Dexpace::Page::Fetchers#each_item
+Dexpace::Page::Fetchers#each_page
+Dexpace::Page::Fetchers#first
+Dexpace::Page::Fetchers#items
+Dexpace::Page::Fetchers#next_page
+Dexpace::Page::Fetchers#options
+Dexpace::Page::Fetchers#pages
+Dexpace::Page::Fetchers.build
+Dexpace::Page::Info
+Dexpace::Page::Info#continuation_token
+Dexpace::Page::Info#items
+Dexpace::Page::Info#next_link
+Dexpace::Page::Info#next_request
+Dexpace::Page::Info.build
+Dexpace::Page::Info.terminal
+Dexpace::Page::Items
+Dexpace::Page::Items#close
+Dexpace::Page::Items#each
+Dexpace::Page::LinkStrategy
+Dexpace::Page::LinkStrategy#extract_items
+Dexpace::Page::LinkStrategy#header
+Dexpace::Page::LinkStrategy#parse
+Dexpace::Page::LinkStrategy.build
+Dexpace::Page::PageNumberStrategy
+Dexpace::Page::PageNumberStrategy#extract_items
+Dexpace::Page::PageNumberStrategy#parameter
+Dexpace::Page::PageNumberStrategy#parse
+Dexpace::Page::PageNumberStrategy#start
+Dexpace::Page::PageNumberStrategy.build
+Dexpace::Page::PageStateError
+Dexpace::Page::Pages
+Dexpace::Page::Pages#close
+Dexpace::Page::Pages#closed?
+Dexpace::Page::Pages#each
+Dexpace::Page::Pages#more?
+Dexpace::Page::Paginator
+Dexpace::Page::Paginator#cap
+Dexpace::Page::Paginator#each_item
+Dexpace::Page::Paginator#each_page
+Dexpace::Page::Paginator#items
+Dexpace::Page::Paginator#options
+Dexpace::Page::Paginator#pages
+Dexpace::Page::Paginator#strategy
+Dexpace::Page::Paginator#template
+Dexpace::Page::Paginator#transport
+Dexpace::Page::Paginator.build
+Dexpace::Page::QueryRewriter
+Dexpace::Page::QueryRewriter#get
+Dexpace::Page::QueryRewriter#rewrite_url
+Dexpace::Page::QueryRewriter#set
Dexpace::PercentEncoding
Dexpace::PercentEncoding#decode_component
Dexpace::PercentEncoding#encode_component
@@ -1175,6 +1251,7 @@ Dexpace::TypedResponse#value
Dexpace::URL
Dexpace::URL#external_form
Dexpace::URL#parse!
+Dexpace::URL#resolve
Dexpace::UUID
Dexpace::UUID#generate
Dexpace::VERSION : String
diff --git a/test/gates/serde_boundary_test.rb b/test/gates/serde_boundary_test.rb
index 8190e98..0df071b 100644
--- a/test/gates/serde_boundary_test.rb
+++ b/test/gates/serde_boundary_test.rb
@@ -5,18 +5,22 @@
require_relative "../../tools/serde_boundary"
# SSE-37's mechanism, and the phase-7 segmentation design's spec-forced boundary 5: core's SSE
-# layer -- and, once 7c lands, its pagination layer -- holds no serialization dependency, checked
-# by a PARSED scan of every require, every constant read and every RBS type name under the
-# guarded paths. The gate that would have caught a `require "json"` or a `Dexpace::Serde`
-# reference the require allowlist cannot see, because the seam is core's own.
+# layer and its pagination layer hold no serialization dependency, checked by a PARSED scan of
+# every require, every constant read and every RBS type name under the guarded paths. The gate
+# that would have caught a `require "json"` or a `Dexpace::Serde` reference the require allowlist
+# cannot see, because the seam is core's own. The pagination rows were PENDING while 7b and 7c
+# were built on one base apart, and GUARDED since the reconcile pass of 2026-09-20.
class SerdeBoundaryTest < GateCase
FIXTURES = File.join(ROOT, "test/fixtures/gates/serde_boundary")
- test "the real repository is clean, and its pending rows are the pagination layer's" do
+ test "the real repository is clean, both layers guarded and nothing pending" do
assert_empty(SerdeBoundary.violations(ROOT))
- assert_equal(%w[gems/dexpace-core/lib/dexpace/page/**/*.rb
+ assert_empty(SerdeBoundary.pending(ROOT))
+ assert_equal(%w[gems/dexpace-core/lib/dexpace/page.rb
+ gems/dexpace-core/lib/dexpace/page/**/*.rb
+ gems/dexpace-core/sig/dexpace/page.rbs
gems/dexpace-core/sig/dexpace/page/**/*.rbs],
- SerdeBoundary.pending(ROOT).map(&:first),)
+ SerdeBoundary::GUARDED.map(&:first).grep(%r{/page}),)
end
test "every GUARDED glob matches at least one real file, so the boundary checks something" do
@@ -71,6 +75,16 @@ class SerdeBoundaryTest < GateCase
assert_empty(scan("clean.rbs"))
end
+ test "a pagination violation names spec-forced boundary 5, not SSE-37" do
+ page_rows = SerdeBoundary::GUARDED.select { |glob, _| glob.include?("/page") }
+
+ assert_equal(["spec-forced boundary 5"] * 4, page_rows.map(&:last))
+ found = SerdeBoundary.scan_file(File.join(FIXTURES, "files", "requires_json.rb"),
+ requirement: page_rows.first.last,)
+
+ assert_includes(found.join("\n"), "(spec-forced boundary 5)")
+ end
+
test "rejects a serde type name in a guarded signature" do
assert_includes(scan("names_serde.rbs"), "names the type Serde")
assert_includes(scan("names_json.rbs"), "names the type JSON")
@@ -91,6 +105,8 @@ class SerdeBoundaryTest < GateCase
end
end
+ # The fixture holds a file for every other guarded glob, the pagination ones included, so the
+ # one violation is the one empty glob's and nothing else's.
test "a GUARDED glob matching no file is a violation naming the glob, never a silent pass" do
found = SerdeBoundary.violations(File.join(FIXTURES, "empty_glob"))
@@ -106,12 +122,13 @@ class SerdeBoundaryTest < GateCase
refute_predicate(status, :success?)
assert_includes(err, "lib/dexpace/sse/reader.rb:4: require \"json\"")
assert_includes(err, "SSE-37")
+ refute_includes(err, "matches no file")
out, err, status = rake("gates:serde_boundary")
assert_predicate(status, :success?, err)
- assert_includes(out, "4 guarded globs clean")
- assert_includes(out, "PENDING gems/dexpace-core/lib/dexpace/page/**/*.rb")
+ assert_includes(out, "8 guarded globs clean")
+ refute_includes(out, "PENDING")
end
private
diff --git a/tools/serde_boundary.rb b/tools/serde_boundary.rb
index aa18ee1..d763bb9 100644
--- a/tools/serde_boundary.rb
+++ b/tools/serde_boundary.rb
@@ -25,28 +25,32 @@
# file, because a glob with a typo scans nothing and reports clean forever, which is precisely the
# way a boundary gate stops being a gate. PENDING holds a path whose files do not exist on this
# base yet; it is not silent -- the task prints every pending row and its reason on every run --
-# and the row moves to GUARDED in the change that lands the files (7c's pagination layer, in the
-# reconcile pass after both lanes are on main).
+# and a row moves to GUARDED in the change that lands the files. The pagination layer's rows made
+# that move on 2026-09-20, in the reconcile pass that put 7c's tree on the same base as 7b's gate,
+# and PENDING has been empty since; it stays as the mechanism for the next layer built beside a
+# gate that predates it.
module SerdeBoundary
extend self
- # A guarded glob, relative to the workspace root, and the requirement that guards it.
+ # A guarded glob, relative to the workspace root, and the requirement that guards it. The
+ # pagination rows carry spec-forced boundary 5 (phase-7 segmentation design): §12's pagination
+ # serde-agnosticism carries no requirement ID, so the SSE-37 mechanism is extended one path
+ # wider, and the entry file has a row of its own for the same reason sse.rb has one -- `**`
+ # under a directory cannot match the file beside it.
GUARDED = [
["gems/dexpace-core/lib/dexpace/sse.rb", "SSE-37"],
["gems/dexpace-core/lib/dexpace/sse/**/*.rb", "SSE-37"],
["gems/dexpace-core/sig/dexpace/sse.rbs", "SSE-37"],
["gems/dexpace-core/sig/dexpace/sse/**/*.rbs", "SSE-37"],
+ ["gems/dexpace-core/lib/dexpace/page.rb", "spec-forced boundary 5"],
+ ["gems/dexpace-core/lib/dexpace/page/**/*.rb", "spec-forced boundary 5"],
+ ["gems/dexpace-core/sig/dexpace/page.rbs", "spec-forced boundary 5"],
+ ["gems/dexpace-core/sig/dexpace/page/**/*.rbs", "spec-forced boundary 5"],
].freeze
- # Spec-forced boundary 5 (phase-7 segmentation design): §12's pagination serde-agnosticism
- # carries no requirement ID, so the SSE-37 mechanism is extended one path wider. Moves to
- # GUARDED when lib/dexpace/page/ exists on the same base.
- PENDING = [
- ["gems/dexpace-core/lib/dexpace/page/**/*.rb",
- "phase-7 segmentation design, spec-forced boundary 5: guarded once 7c's files land",],
- ["gems/dexpace-core/sig/dexpace/page/**/*.rbs",
- "phase-7 segmentation design, spec-forced boundary 5: guarded once 7c's files land",],
- ].freeze
+ # A path whose files do not exist on this base yet, with the reason it is pending: printed on
+ # every run, never silent. Empty since the pagination rows moved to GUARDED on 2026-09-20.
+ PENDING = [].freeze
# The constant names a guarded file may not read, wherever they sit in a path: `Serde`,
# `Dexpace::Serde`, `Dexpace::Serde::JSON`, `JSON` and `::JSON` -- the last being the spelling