Skip to main content

dash_platform_queries/documents/
document_ranked_entries.rs

1//! `FromProof` + `Fetch` for [`DocumentRankedEntries`] — the
2//! **ranked** (`GROUP BY … ORDER BY <aggregate> LIMIT n [OFFSET m]`)
3//! view of the unified `getDocuments` endpoint.
4//!
5//! A ranked query answers "which `n` groups score highest (or lowest)
6//! on an aggregate?" — *top 5 restaurants by average grade* — in
7//! `O(log n + k)`, with a proof. It reads a pre-sorted per-axis
8//! *secondary* Merk maintained by the write path (grovedb PR #657)
9//! rather than walking value trees, which is why it is cheap and why
10//! its shape is so constrained.
11//!
12//! Per-request resolution (which axis, which direction, how many
13//! groups, how many ranks to skip, which index covers them) lives in
14//! [`super::ranked_proof_helpers`]; this module is the thin
15//! `Fetch`-side wrapper.
16//!
17//! ## Request shape
18//!
19//! Exactly one aggregate `select`, exactly one `group_by` property,
20//! exactly one `ORDER BY` clause naming that select's aggregate, and a
21//! `LIMIT` — plus an optional `OFFSET`. `where` clauses are pins on a
22//! covering compound ranked index's leading properties (one per leading
23//! property, selecting which prefix's own ranking the walk reads) —
24//! absent for a single-property index. Each pin is an equality, except
25//! that **at most one** may be an `IN` of 2..=10 distinct elements: one
26//! walk per element, merged by `(aggregate, encoded pin, group key)`,
27//! with each merged entry carrying the encoded branch segment in
28//! `in_key` (unset on single-branch responses; a single-element `IN`
29//! normalizes to the equality pin). A non-zero `OFFSET` cannot combine
30//! with the `IN`, nor can a `null` pin on another property. No
31//! `having`, no `start_at`: each of those is rejected rather than
32//! ignored, on both sides, because a ranked walk cannot honour them and
33//! silently answering a different question is worse than an error.
34//!
35//! [`DocumentQuery::order_by_selected_aggregate`] builds the ordering
36//! clause, deriving the ordered field from the `select` through
37//! rs-drive's own key mapping (`SUM(f)` / `AVG(f)` are named by `f`,
38//! `COUNT(*)` by the `$count` sentinel), so there is no way to name it
39//! wrong by hand.
40//!
41//! ## Contract prerequisites
42//!
43//! The index must opt in with `rankedCountable` / `rankedSummable` /
44//! `rankedAverageable` (meta-schema v3, **protocol version 14+**). The
45//! index may be single-property (`group_by` its property, no `where`)
46//! or compound (`group_by` its trailing property, pin every leading one
47//! — equality pins, at most one of them an `IN`, as above). Against a
48//! protocol-version-13
49//! node the request is refused — v13's query table has no ranked path
50//! and rejects the ordering as `Unsupported`. That is the intended
51//! activation gate, not a bug: a v13 node and a v14 node must disagree
52//! here and nowhere else, which is what lets a mixed-version network
53//! run through the upgrade.
54//!
55//! ## Ranks, offsets, and the empty ranking
56//!
57//! The fetch result carries
58//! [`starting_rank`](drive_proof_verifier::DocumentRankedEntries::starting_rank)
59//! alongside the entries: entry `i` is the group at rank
60//! `starting_rank + i`, which is what makes `LIMIT 1 OFFSET 4`
61//! meaningful as "the 5th best" rather than "some entry". On the proved
62//! path that number is re-derived from the proof's counted subtree
63//! commitments, not taken from the node.
64//!
65//! An offset past the end of the ranking is a legitimate, provable
66//! answer: no entries, and `starting_rank` equal to the ranking's whole
67//! attested population. **Empty rankings prove too** — grovedb's
68//! paginated prover emits a guaranteed-empty range against an empty
69//! axis secondary rather than refusing — so querying a freshly
70//! registered contract with `prove = true` returns an empty page rather
71//! than an error, and the proved and unproven paths agree.
72//!
73//! ## Reading the values
74//!
75//! Entries come back in ranking order; **do not re-sort**. Averages
76//! are fixed-point integers: divide by
77//! [`RANKED_AVG_SCALE`](drive_proof_verifier::RANKED_AVG_SCALE) — a
78//! re-export of grovedb's own constant, which moved from `10^15` to
79//! `10^19` before release, so never hardcode the literal — or call
80//! [`RankedEntryValue::as_f64`](drive_proof_verifier::RankedEntryValue::as_f64),
81//! which does that division for you.
82//!
83//! **How exact the average is depends on the path.** Fetched with a
84//! proof (the default, and what the examples below do), the fixed point
85//! is the integer grovedb committed to and ranked on. Fetched without
86//! one, the wire carries only an `f64` of the average and the SDK
87//! re-scales it back, so the digits past `f64`'s ~15–16 significant
88//! decimals are reconstruction noise — fine to render, not something to
89//! compare for equality. Ranking *order* is exact either way.
90//!
91//! ## Example: top 5 restaurants by average grade
92//!
93//! `SELECT AVG(grade) GROUP BY restaurantId ORDER BY avg(grade) DESC LIMIT 5`
94//!
95//! ```rust,ignore
96//! use dash_sdk::{Sdk, platform::{DataContract, DocumentQuery, Fetch, Identifier}};
97//! use dash_sdk::drive::query::SelectProjection;
98//! use dash_sdk::platform::documents::document_query::RankingDirection;
99//! use drive_proof_verifier::{DocumentRankedEntries, RankedEntryValue, RANKED_AVG_SCALE};
100//! use futures::executor::block_on;
101//!
102//! # const RESTAURANTS_CONTRACT_ID: [u8; 32] = [0; 32];
103//! let sdk = Sdk::new_mock();
104//! let contract = block_on(DataContract::fetch(&sdk, Identifier::new(RESTAURANTS_CONTRACT_ID)))
105//!     .expect("fetch contract")
106//!     .expect("contract exists");
107//!
108//! let query = DocumentQuery::new(contract, "review")
109//!     .expect("document type exists")
110//!     .with_select(SelectProjection::avg("grade"))
111//!     .with_group_by("restaurantId")
112//!     .order_by_selected_aggregate(RankingDirection::Descending)
113//!     .with_limit(5);
114//!
115//! let ranked = block_on(DocumentRankedEntries::fetch(&sdk, query))
116//!     .expect("fetch succeeds")
117//!     .expect("a well-formed ranked query always answers");
118//!
119//! // Entry order IS the ranking order — best first.
120//! for (offset, entry) in ranked.entries.iter().enumerate() {
121//!     let rank = ranked.starting_rank + offset as u64;
122//!     let restaurant = String::from_utf8_lossy(&entry.key);
123//!     if let RankedEntryValue::AvgFixedPoint(fixed_point) = entry.value {
124//!         // `as_f64()` is this same division, for when you only want
125//!         // to display the number:
126//!         //     let average = entry.value.as_f64();
127//!         // Keep the `fixed_point` itself when you need the exact
128//!         // integer the proof committed to — comparing two groups,
129//!         // reproducing the ranking, storing it. On a `prove = false`
130//!         // fetch that integer is a reconstruction from the wire's
131//!         // double, so it is only as precise as an `f64`.
132//!         let average = (fixed_point as f64) / (RANKED_AVG_SCALE as f64);
133//!         println!("#{}: {restaurant}: {average}", rank + 1);
134//!     }
135//! }
136//! ```
137//!
138//! ## Example: the 5th-best restaurant
139//!
140//! `SELECT AVG(grade) GROUP BY restaurantId ORDER BY avg(grade) DESC LIMIT 1 OFFSET 4`
141//!
142//! ```rust,ignore
143//! # use dash_sdk::platform::{DataContract, DocumentQuery};
144//! # use dash_sdk::platform::documents::document_query::RankingDirection;
145//! # use dash_sdk::drive::query::SelectProjection;
146//! # fn example(contract: DataContract) -> Result<(), dash_sdk::Error> {
147//! let query = DocumentQuery::new(contract, "review")?
148//!     .with_select(SelectProjection::avg("grade"))
149//!     .with_group_by("restaurantId")
150//!     .order_by_selected_aggregate(RankingDirection::Descending)
151//!     .with_limit(1)
152//!     .with_offset(4);
153//! # Ok(())
154//! # }
155//! ```
156
157use crate::documents::document_query::DocumentQuery;
158use crate::documents::ranked_proof_helpers::{assert_ranked_shape, verify_ranked_query};
159use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
160use dash_context_provider::ContextProvider;
161use dpp::dashcore::Network;
162use dpp::version::PlatformVersion;
163use drive_proof_verifier::{DocumentRankedEntries, FromProof};
164
165impl FromProof<DocumentQuery> for DocumentRankedEntries {
166    type Request = DocumentQuery;
167    type Response = GetDocumentsResponse;
168
169    /// A request outside the ranked grammar is not refused by every node: a node routing
170    /// with an older query table serves it as a plain grouped aggregate, whose proof the
171    /// ranked verifier then refuses. Its pending time-range selections only ever add
172    /// equality pins, which the grammar cannot refuse a request for lacking, so checking
173    /// before they are resolved refuses nothing the verifier accepts.
174    fn check_request(
175        request: &Self::Request,
176        platform_version: &PlatformVersion,
177    ) -> Result<(), drive_proof_verifier::Error> {
178        request.ensure_no_sub_queries()?;
179        assert_ranked_shape(request, platform_version).map(|_| ())
180    }
181
182    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
183        request: I,
184        response: O,
185        _network: Network,
186        platform_version: &PlatformVersion,
187        provider: &'a dyn ContextProvider,
188    ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
189    where
190        Self: 'a,
191    {
192        let request: Self::Request = request.into();
193        let response: Self::Response = response.into();
194        // Unlike the count / sum / average impls there is no separate
195        // `assert_select_is_*` pre-check here: the ranked grammar
196        // check is the first step of resolution and returns the
197        // resolved mode, so it runs once, inside the helper. See
198        // `ranked_proof_helpers::assert_ranked_shape`.
199        let (page, mtd, proof) =
200            verify_ranked_query(request, response, platform_version, provider)?;
201        Ok((page.map(DocumentRankedEntries::from_verified), mtd, proof))
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    //! Offline tests for the ranked client surface: the ordering
208    //! builder, the request→wire encoding, and the client-side grammar
209    //! mirror.
210    //!
211    //! Proof verification needs a populated Drive and a real grovedb
212    //! proof, so it is exercised where those exist: rs-drive's
213    //! `drive_document_ranked_query::tests` runs prover and verifier
214    //! against a live Drive (including a bit-flip sweep proving no
215    //! tamper survives with the honest root hash), and rs-drive-abci's
216    //! `ranked_tests` pins the wire encoding of the same values. The
217    //! SDK's own network-backed suites live in `tests/fetch/` and run
218    //! against recorded vectors; there is no ranked vector yet because
219    //! recording one needs a protocol-version-14 network, which does
220    //! not exist offline.
221
222    use super::*;
223    use crate::documents::document_query::RankingDirection;
224    use crate::documents::ranked_proof_helpers::assert_ranked_shape;
225    use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::select as proto_select;
226    use dapi_grpc::platform::v0::get_documents_request::{
227        order_clause, GetDocumentsRequestV1, OrderClause as ProtoOrderClause,
228        Version as RequestVersion,
229    };
230    use dapi_grpc::platform::v0::GetDocumentsRequest;
231    use dpp::data_contract::DataContract;
232    use dpp::tests::fixtures::get_data_contract_fixture;
233    use dpp::version::TryFromPlatformVersioned;
234    use drive::query::{
235        HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand,
236        SelectProjection, WhereClause, WhereOperator, RANKED_COUNT_ORDER_KEY,
237    };
238    use std::sync::Arc;
239
240    /// The protocol version the ranked surface activates at. Pinned
241    /// as a literal so a future bump of `PlatformVersion::latest()`
242    /// past 14 doesn't silently change what these tests encode.
243    fn platform_version() -> &'static PlatformVersion {
244        PlatformVersion::latest()
245    }
246
247    /// Any contract with a known document type — the wire encoder is
248    /// schema-agnostic (it never resolves `select` / `group_by` /
249    /// `order_by` field names against the contract; the *server* does
250    /// that), so the fixture's document type carries the encoding
251    /// test fine while the field names stay the headline example's,
252    /// keeping this comparable to rs-drive-abci's `ranked_tests`.
253    fn contract() -> Arc<DataContract> {
254        Arc::new(
255            get_data_contract_fixture(None, 0, platform_version().protocol_version)
256                .data_contract_owned(),
257        )
258    }
259
260    /// `SELECT AVG(grade) GROUP BY restaurantId ORDER BY grade DESC LIMIT 5`.
261    fn top_five_by_avg_grade() -> DocumentQuery {
262        DocumentQuery::new(contract(), "niceDocument")
263            .expect("the fixture has this document type")
264            .with_select(SelectProjection::avg("grade"))
265            .with_group_by("restaurantId")
266            .order_by_selected_aggregate(RankingDirection::Descending)
267            .with_limit(5)
268    }
269
270    fn encode(query: DocumentQuery) -> GetDocumentsRequest {
271        GetDocumentsRequest::try_from_platform_versioned(query, platform_version())
272            .expect("a ranked query encodes onto the V1 wire")
273    }
274
275    fn v1_of(request: GetDocumentsRequest) -> GetDocumentsRequestV1 {
276        match request.version.expect("the encoder always sets a version") {
277            RequestVersion::V1(v1) => v1,
278            RequestVersion::V0(_) => {
279                panic!("a ranked query must encode onto the V1 wire; V0 has no `order_by` targets")
280            }
281        }
282    }
283
284    /// The ordered field name, asserted through the wire's `target`
285    /// oneof so a future change to the aggregate-target spelling can't
286    /// pass this test by accident.
287    fn ordered_field(clause: &ProtoOrderClause) -> &str {
288        match clause.target.as_ref().expect("the target is always set") {
289            order_clause::Target::Field(field) => field.as_str(),
290            other => panic!("a ranked ORDER BY rides the field target, got {other:?}"),
291        }
292    }
293
294    /// The headline round-trip: the SDK must put a ranked query on the
295    /// wire in **exactly** the shape rs-drive-abci's
296    /// `avg_axis_top_k_returns_fixed_point_entries` proved the server
297    /// accepts. Asserted field by field rather than with a single
298    /// struct comparison so a failure names the field that drifted.
299    #[test]
300    fn ranked_query_encodes_the_proven_wire_shape() {
301        let v1 = v1_of(encode(top_five_by_avg_grade()));
302
303        // One aggregate select, naming the averaged property.
304        assert_eq!(v1.selects.len(), 1, "a ranked query has exactly one select");
305        assert_eq!(v1.selects[0].function, proto_select::Function::Avg as i32);
306        assert_eq!(v1.selects[0].field, "grade");
307
308        // One GROUP BY property — the ranked index's only property.
309        assert_eq!(v1.group_by, vec!["restaurantId".to_string()]);
310
311        // One ORDER BY clause naming the select's aggregate, descending.
312        assert_eq!(
313            v1.order_by.len(),
314            1,
315            "a ranked query has exactly one ordering clause — it is the ranking"
316        );
317        assert_eq!(ordered_field(&v1.order_by[0]), "grade");
318        assert!(
319            !v1.order_by[0].ascending,
320            "Descending is the `top n` reading and must not invert on the wire"
321        );
322
323        // The ranking's n rides `limit`.
324        assert_eq!(v1.limit, Some(5));
325
326        // Everything else must be at its "unset" wire value: a ranked
327        // request that carried any of these would be rejected.
328        assert!(v1.where_clauses.is_empty());
329        assert!(v1.having.is_empty());
330        assert_eq!(v1.offset, None);
331        assert!(v1.start.is_none());
332        assert!(v1.prove, "the Fetch path always requests a proof");
333    }
334
335    /// **The 5th-best group.** `LIMIT 1 OFFSET 4` is the whole point of
336    /// the offset surface, and the offset has to survive onto the wire
337    /// — a dropped one silently answers "the best" instead.
338    #[test]
339    fn the_fifth_best_encodes_limit_one_offset_four() {
340        let query = DocumentQuery::new(contract(), "niceDocument")
341            .expect("the fixture has this document type")
342            .with_select(SelectProjection::avg("grade"))
343            .with_group_by("restaurantId")
344            .order_by_selected_aggregate(RankingDirection::Descending)
345            .with_limit(1)
346            .with_offset(4);
347
348        let v1 = v1_of(encode(query.clone()));
349        assert_eq!(v1.limit, Some(1));
350        assert_eq!(v1.offset, Some(4));
351        assert!(!v1.order_by[0].ascending);
352
353        // And the client-side grammar resolves it to the same page the
354        // prover will produce.
355        let mode = assert_ranked_shape(&query, platform_version())
356            .expect("LIMIT 1 OFFSET 4 is well-formed");
357        assert_eq!(mode.k, 1);
358        assert_eq!(mode.offset, 4);
359        assert!(mode.descending);
360    }
361
362    /// An offset far past any plausible population is **not** capped:
363    /// grovedb counts the skipped region from the subtree aggregates
364    /// rather than walking it, on both `prove` settings, so the cost of
365    /// a deep page does not grow with the offset — `O(log n)` in the
366    /// size of the ranking, not in how far you page — and there is
367    /// nothing for a cap to protect.
368    #[test]
369    fn a_very_deep_offset_is_not_capped() {
370        let query = top_five_by_avg_grade().with_limit(1).with_offset(u32::MAX);
371        assert_eq!(v1_of(encode(query.clone())).offset, Some(u32::MAX));
372        let mode = assert_ranked_shape(&query, platform_version())
373            .expect("an offset past the end is a provable answer, not an error");
374        assert_eq!(mode.offset, u32::MAX);
375    }
376
377    /// `COUNT(*)` rankings are ordered by the **`$count` sentinel**,
378    /// not by a property name — the axis counts documents per group, so
379    /// there is no property to name. The builder derives it from the
380    /// select through rs-drive's own mapping, which is the only way the
381    /// client and the server can be guaranteed to agree.
382    #[test]
383    fn count_star_ranking_orders_by_the_count_sentinel() {
384        let query = DocumentQuery::new(contract(), "niceDocument")
385            .expect("the fixture has this document type")
386            .with_select(SelectProjection::count_star())
387            .with_group_by("restaurantId")
388            .order_by_selected_aggregate(RankingDirection::Descending)
389            .with_limit(2);
390
391        let v1 = v1_of(encode(query));
392        assert_eq!(v1.selects[0].function, proto_select::Function::Count as i32);
393        assert_eq!(v1.selects[0].field, "");
394        assert_eq!(ordered_field(&v1.order_by[0]), RANKED_COUNT_ORDER_KEY);
395        assert_eq!(
396            RANKED_COUNT_ORDER_KEY, "$count",
397            "the sentinel is wire-visible; changing it is a protocol change"
398        );
399    }
400
401    /// `Ascending` is the "bottom n" reading and must reach the wire as
402    /// `ascending: true`. Pinned separately from the descending case
403    /// because a builder that ignored its argument would still pass the
404    /// headline test.
405    #[test]
406    fn ascending_encodes_the_bottom_n_reading() {
407        let query = top_five_by_avg_grade()
408            .order_by_selected_aggregate(RankingDirection::Ascending)
409            .with_limit(1);
410        let v1 = v1_of(encode(query.clone()));
411        assert!(v1.order_by[0].ascending);
412
413        let mode = assert_ranked_shape(&query, platform_version()).expect("ASC LIMIT 1 is valid");
414        assert!(!mode.descending, "ASC ranks lowest-first");
415        assert_eq!(mode.k, 1, "ASC LIMIT 1 is the single worst-ranked group");
416    }
417
418    /// `order_by_selected_aggregate` **replaces** rather than appends.
419    /// A ranked query takes exactly one ordering clause, so a builder
420    /// that pushed would turn a second call — or a call after an
421    /// unrelated `with_order_by` — into a request the server rejects.
422    #[test]
423    fn order_by_selected_aggregate_replaces_any_prior_ordering() {
424        let query = top_five_by_avg_grade()
425            .with_order_by(drive::query::OrderClause {
426                field: "restaurantId".to_string(),
427                ascending: true,
428            })
429            .order_by_selected_aggregate(RankingDirection::Descending);
430
431        assert_eq!(query.order_by_clauses.len(), 1);
432        assert_eq!(query.order_by_clauses[0].field, "grade");
433        assert!(assert_ranked_shape(&query, platform_version()).is_ok());
434    }
435
436    /// The client-side grammar must resolve the same
437    /// `(axis, descending, k, offset)` tuple the server does — that
438    /// tuple goes into the proof envelope and is re-checked by the
439    /// verifier, so a client that resolved it differently could not
440    /// verify an honest proof.
441    #[test]
442    fn assert_ranked_shape_resolves_the_ranking() {
443        let mode = assert_ranked_shape(&top_five_by_avg_grade(), platform_version())
444            .expect("the headline query is well-formed");
445        assert!(mode.descending, "DESC ranks highest-first");
446        assert_eq!(mode.k, 5);
447        assert_eq!(mode.offset, 0, "an unset OFFSET is rank 0");
448        assert_eq!(mode.group_by_property, "restaurantId");
449        assert_eq!(mode.aggregate_field, "grade");
450    }
451
452    /// Every knob a ranked walk cannot honour is rejected **client
453    /// side**, before a round trip. Each of these is also rejected by
454    /// the server; mirroring them here turns a network error into an
455    /// immediate, specific one.
456    #[test]
457    fn assert_ranked_shape_rejects_what_a_ranking_cannot_honour() {
458        let base = top_five_by_avg_grade();
459
460        // An ordering on something other than the selected aggregate
461        // asks for an order the secondary cannot produce.
462        let wrong_order = {
463            let mut q = base.clone();
464            q.order_by_clauses = vec![drive::query::OrderClause {
465                field: "restaurantId".to_string(),
466                ascending: true,
467            }];
468            q
469        };
470        assert!(
471            assert_ranked_shape(&wrong_order, platform_version()).is_err(),
472            "ordering by the GROUP BY property is not a ranking by the aggregate"
473        );
474
475        // No ordering at all: a plain grouped aggregate, and the caller
476        // wanted `DocumentSplitAverages`.
477        let no_order = {
478            let mut q = base.clone();
479            q.order_by_clauses = Vec::new();
480            q
481        };
482        assert!(
483            assert_ranked_shape(&no_order, platform_version()).is_err(),
484            "a query with no ordering is not a ranked query"
485        );
486
487        let with_where = {
488            let mut q = base.clone();
489            q.where_clauses = vec![WhereClause {
490                field: "restaurantId".to_string(),
491                operator: WhereOperator::GreaterThan,
492                value: dpp::platform_value::Value::Text("a".to_string()),
493            }];
494            q
495        };
496        let err = assert_ranked_shape(&with_where, platform_version())
497            .expect_err("the axis secondary cannot rank a filtered subset");
498        assert!(format!("{err}").contains("where"));
499
500        // HAVING is a boolean per-group predicate and the ranked
501        // executor cannot drop groups from the middle of its walk.
502        let with_having = base.clone().with_having(vec![HavingClause {
503            aggregate: HavingAggregate {
504                function: HavingAggregateFunction::Avg,
505                field: "grade".to_string(),
506            },
507            operator: HavingOperator::GreaterThan,
508            right: HavingRightOperand::Value(dpp::platform_value::Value::U64(4)),
509        }]);
510        assert!(
511            assert_ranked_shape(&with_having, platform_version()).is_err(),
512            "a ranking cannot also filter its groups"
513        );
514    }
515
516    /// The ranking's `n` rides `limit`, and an out-of-range one is
517    /// rejected rather than clamped: `k` is part of the traversal the
518    /// client rebuilds to verify, so a silent clamp would produce a
519    /// page the client's reconstruction did not ask for.
520    #[test]
521    fn assert_ranked_shape_rejects_an_out_of_range_limit() {
522        // `0` is `DocumentQuery`'s "unset" sentinel, and a ranking with
523        // no `n` has no size; `101` is past `MAX_RANKED_LIMIT`.
524        for limit in [0u32, 101] {
525            let query = top_five_by_avg_grade().with_limit(limit);
526            assert!(
527                assert_ranked_shape(&query, platform_version()).is_err(),
528                "LIMIT {limit} is outside 1..=100 and must be rejected, not clamped"
529            );
530        }
531    }
532
533    /// The V0 wire has no `offset` field at all, so encoding one there
534    /// has to fail loudly. Dropping it would page from rank 0 while the
535    /// caller believed they had skipped ahead.
536    #[test]
537    fn a_v0_encode_refuses_to_silently_drop_an_offset() {
538        let mut v0_version = PlatformVersion::latest().clone();
539        v0_version
540            .drive_abci
541            .query
542            .document_query
543            .default_current_version = 0;
544
545        let query = DocumentQuery::new(contract(), "niceDocument")
546            .expect("doctype exists")
547            .with_offset(4);
548
549        let err = GetDocumentsRequest::try_from_platform_versioned(query, &v0_version)
550            .expect_err("V0 cannot carry an offset");
551        assert!(
552            format!("{err}").contains("offset"),
553            "the refusal must name the field that cannot be carried, got: {err}"
554        );
555    }
556}