Skip to main content

riffdb_client_rust/
projected.rs

1//! Typed projected-query facade over `ExecuteProjectedQuery`.
2//!
3//! Request construction is name-addressed (application values + field names).
4//! Ready responses surface rows as canonical values; PACKED Ready is decoded
5//! through a block decoder with the same offset rigor as the measurement
6//! harness (monotone, closed bounds, exact cell documents).
7
8use std::time::Duration;
9
10use riffdb_proto::app::v1 as app_v1;
11use riffdb_proto::{aggregate_sum_from_proto, canonical_value_from_proto};
12use riffdb_types::{
13    CanonicalValue, CommitToken, FreshnessPolicy, ProjectionFrontier, decode_canonical_value,
14};
15
16use crate::application::{
17    ApplicationClientError, ApplicationContract, ApplicationValue, lower_value, validate_contract,
18};
19use crate::{CallMetadata, RiffDbClient, generate_request_id};
20
21/// Wire encoding preference for a Ready projected-query response.
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23pub enum ProjectedResponseEncoding {
24    /// Row-oriented `ready` arm (historical default when absent).
25    #[default]
26    Row,
27    /// Column-major packed `ready_packed` arm.
28    Packed,
29}
30
31/// Sort direction for one projected order key.
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub enum ProjectedSortDirection {
34    /// Ascending (engine default).
35    #[default]
36    Asc,
37    /// Descending.
38    Desc,
39}
40
41/// Equality or range predicate addressed by projected field name.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum ProjectedPredicate {
44    /// Field equals value.
45    Eq {
46        /// Field name.
47        field: String,
48        /// Comparison value.
49        value: ApplicationValue,
50    },
51    /// Inclusive-low / exclusive-high range (either bound optional).
52    Range {
53        /// Field name.
54        field: String,
55        /// Inclusive lower bound.
56        low: Option<ApplicationValue>,
57        /// Exclusive upper bound.
58        high: Option<ApplicationValue>,
59    },
60}
61
62/// One sort key addressed by projected field name.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct ProjectedOrder {
65    /// Field name (projected field or primary-key component).
66    pub field: String,
67    /// Sort direction.
68    pub direction: ProjectedSortDirection,
69}
70
71/// One aggregate function over a projected column.
72///
73/// Typed rather than `(op, field)`: `Count` reads no column, so a count that
74/// names one is unrepresentable instead of rejected at the server.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum ProjectedAggregate {
77    /// Number of matching rows.
78    Count,
79    /// Number of matching rows where the named optional column is non-null.
80    CountPresent {
81        /// Field name to count when present.
82        field: String,
83    },
84    /// Number of distinct values of the named column (`NULL` counts as one value).
85    CountDistinct {
86        /// Field name to count distinct values over.
87        field: String,
88    },
89    /// Number of distinct non-null values of the named optional column.
90    CountDistinctPresent {
91        /// Field name to count distinct non-null values over.
92        field: String,
93    },
94    /// Exact integer/fixed-point sum of one column (`I64`, `U64`, uniform-scale `Decimal`, or uniform-currency `Money`).
95    Sum {
96        /// Field name to sum.
97        field: String,
98    },
99    /// Exact integer/fixed-point mean (`(total, count)`) of one column (`I64`, `U64`, uniform-scale `Decimal`, or uniform-currency `Money`).
100    Mean {
101        /// Field name to average.
102        field: String,
103    },
104    /// Smallest value of one column.
105    Min {
106        /// Field name to minimize.
107        field: String,
108    },
109    /// Largest value of one column.
110    Max {
111        /// Field name to maximize.
112        field: String,
113    },
114    /// Logical OR over a boolean column (`false` on empty input).
115    Any {
116        /// Boolean field name.
117        field: String,
118    },
119    /// Logical AND over a boolean column (`true` on empty input).
120    All {
121        /// Boolean field name.
122        field: String,
123    },
124}
125
126/// One aggregate result cell.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub enum ProjectedAggregateValue {
129    /// Result of [`ProjectedAggregate::Count`], [`ProjectedAggregate::CountPresent`],
130    /// [`ProjectedAggregate::CountDistinct`], or [`ProjectedAggregate::CountDistinctPresent`].
131    Count(u64),
132    /// Result of [`ProjectedAggregate::Sum`], exact to the full `i128` range.
133    Sum(i128),
134    /// Result of [`ProjectedAggregate::Mean`]: exact sum and contributing row count.
135    ExactMean {
136        /// Exact `i128` numerator (`0` when `count == 0`).
137        total: i128,
138        /// Number of contributing rows (`0` when no row contributed).
139        count: u64,
140    },
141    /// Result of [`ProjectedAggregate::Min`] / [`ProjectedAggregate::Max`].
142    ///
143    /// `None` means no row contributed to the group. That is distinct from
144    /// `Some(CanonicalValue::Null)`, which is a real NULL extreme over an
145    /// optional column.
146    Scalar(Option<CanonicalValue>),
147    /// Result of [`ProjectedAggregate::Any`] / [`ProjectedAggregate::All`].
148    Bool(bool),
149}
150
151/// One aggregate group: key cells and aggregate cells.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct ProjectedAggregateGroup {
154    /// Key cells aligned with the outcome's `group_key_fields`.
155    ///
156    /// Empty for the single group of a whole-set aggregate.
157    pub keys: Vec<CanonicalValue>,
158    /// Aggregate cells aligned with the outcome's `aggregates`.
159    pub values: Vec<ProjectedAggregateValue>,
160}
161
162/// Typed projected-query request builder.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct ProjectedQuery {
165    contract: ApplicationContract,
166    projection_name: String,
167    org_scope: ApplicationValue,
168    select: Vec<String>,
169    predicates: Vec<ProjectedPredicate>,
170    order: Vec<ProjectedOrder>,
171    limit: Option<u32>,
172    group_by: Vec<String>,
173    aggregates: Vec<ProjectedAggregate>,
174    freshness: FreshnessPolicy,
175    encoding: ProjectedResponseEncoding,
176}
177
178impl ProjectedQuery {
179    /// Builds a projected query with required org scope and available freshness.
180    pub fn new(
181        contract: ApplicationContract,
182        projection_name: impl Into<String>,
183        org_scope: ApplicationValue,
184    ) -> Result<Self, ApplicationClientError> {
185        let projection_name = projection_name.into();
186        if projection_name.is_empty() || projection_name.len() > 256 {
187            return Err(ApplicationClientError::InvalidInput);
188        }
189        validate_contract(&contract)?;
190        Ok(Self {
191            contract,
192            projection_name,
193            org_scope,
194            select: Vec::new(),
195            predicates: Vec::new(),
196            order: Vec::new(),
197            limit: None,
198            group_by: Vec::new(),
199            aggregates: Vec::new(),
200            freshness: FreshnessPolicy::Available,
201            encoding: ProjectedResponseEncoding::Row,
202        })
203    }
204
205    /// Sets selected field names (empty = all projected fields).
206    #[must_use]
207    pub fn select(mut self, select: Vec<String>) -> Self {
208        self.select = select;
209        self
210    }
211
212    /// Sets conjunctive predicates.
213    #[must_use]
214    pub fn predicates(mut self, predicates: Vec<ProjectedPredicate>) -> Self {
215        self.predicates = predicates;
216        self
217    }
218
219    /// Sets sort keys (primary-key tie-break remains engine-owned).
220    #[must_use]
221    pub fn order(mut self, order: Vec<ProjectedOrder>) -> Self {
222        self.order = order;
223        self
224    }
225
226    /// Sets the declared result bound: the post-sort row limit.
227    ///
228    /// For a request with [`Self::group_by`] keys this is the maximum number of
229    /// **groups** the query may return — grouped results are measured in groups
230    /// rather than rows. Exceeding it is a typed rejection returning nothing,
231    /// not a truncated set of groups.
232    ///
233    /// It has no effect on an aggregate without group keys: that result is one
234    /// value per requested function however many rows were folded.
235    #[must_use]
236    pub fn limit(mut self, limit: Option<u32>) -> Self {
237        self.limit = limit;
238        self
239    }
240
241    /// Adds one aggregate function; repeat for several.
242    ///
243    /// A query with at least one aggregate answers with
244    /// [`ProjectedQueryOutcome::ReadyAggregates`] instead of rows, and the
245    /// requested [`ProjectedResponseEncoding`] no longer applies.
246    ///
247    /// Several functions require [`Self::group_by`] keys: the server computes
248    /// exactly one whole-set function per query.
249    #[must_use]
250    pub fn aggregate(mut self, aggregate: ProjectedAggregate) -> Self {
251        self.aggregates.push(aggregate);
252        self
253    }
254
255    /// Sets the group-by key field names (empty = whole-set aggregate).
256    #[must_use]
257    pub fn group_by(mut self, keys: Vec<String>) -> Self {
258        self.group_by = keys;
259        self
260    }
261
262    /// Sets the freshness policy (Available / Bounded / Causal with token).
263    #[must_use]
264    pub fn freshness(mut self, freshness: FreshnessPolicy) -> Self {
265        self.freshness = freshness;
266        self
267    }
268
269    /// Selects row or packed Ready encoding.
270    #[must_use]
271    pub const fn encoding(mut self, encoding: ProjectedResponseEncoding) -> Self {
272        self.encoding = encoding;
273        self
274    }
275
276    /// Projection name.
277    #[must_use]
278    pub fn projection_name(&self) -> &str {
279        &self.projection_name
280    }
281
282    /// Requested response encoding.
283    #[must_use]
284    pub const fn response_encoding(&self) -> ProjectedResponseEncoding {
285        self.encoding
286    }
287
288    /// The response arm this request must be answered by.
289    ///
290    /// Group keys alone make a request aggregate-shaped: a body with keys and
291    /// no functions is the distinct-values query, which the server answers
292    /// with `ready_aggregates` carrying key-only groups.
293    fn response_shape(&self) -> ProjectedResponseShape {
294        if self.aggregates.is_empty() && self.group_by.is_empty() {
295            ProjectedResponseShape::Rows(self.encoding)
296        } else {
297            ProjectedResponseShape::Aggregates(ProjectedAggregateShape {
298                group_by: self.group_by.clone(),
299                aggregates: self.aggregates.clone(),
300                order: self.order.clone(),
301            })
302        }
303    }
304
305    /// Builds the public wire request (for tests and advanced callers).
306    pub fn into_wire_request(
307        self,
308    ) -> Result<app_v1::ExecuteProjectedQueryRequest, ApplicationClientError> {
309        let request_id = Vec::from(
310            generate_request_id()
311                .map_err(|_| ApplicationClientError::IdentifierUnavailable)?
312                .into_bytes(),
313        );
314        let mut predicates = Vec::with_capacity(self.predicates.len());
315        for predicate in self.predicates {
316            predicates.push(lower_predicate(predicate)?);
317        }
318        if !self.group_by.is_empty() {
319            for item in &self.order {
320                if !self.group_by.contains(&item.field) {
321                    return Err(ApplicationClientError::InvalidInput);
322                }
323            }
324        }
325        let order = self
326            .order
327            .into_iter()
328            .map(|item| {
329                if item.field.is_empty() || item.field.len() > 256 {
330                    return Err(ApplicationClientError::InvalidInput);
331                }
332                Ok(app_v1::ProjectedOrder {
333                    field: item.field,
334                    descending: matches!(item.direction, ProjectedSortDirection::Desc),
335                })
336            })
337            .collect::<Result<Vec<_>, _>>()?;
338        for name in &self.select {
339            if name.is_empty() || name.len() > 256 {
340                return Err(ApplicationClientError::InvalidInput);
341            }
342        }
343        for name in &self.group_by {
344            if name.is_empty() || name.len() > 256 {
345                return Err(ApplicationClientError::InvalidInput);
346            }
347        }
348        // The server computes one whole-set function per query; refuse locally
349        // rather than spend a round trip on a shape it must reject.
350        if self.group_by.is_empty() && self.aggregates.len() > 1 {
351            return Err(ApplicationClientError::InvalidInput);
352        }
353        let aggregates = self
354            .aggregates
355            .into_iter()
356            .map(lower_aggregate)
357            .collect::<Result<Vec<_>, _>>()?;
358        Ok(app_v1::ExecuteProjectedQueryRequest {
359            contract: lower_contract_selector(self.contract),
360            projection_name: self.projection_name,
361            request: Some(app_v1::ProjectedQueryBody {
362                select: self.select,
363                org_scope: Some(lower_value(self.org_scope)?),
364                predicates,
365                order,
366                limit: self.limit,
367                group_by: self.group_by,
368                aggregates,
369            }),
370            freshness: Some(lower_freshness(self.freshness)),
371            response_encoding: match self.encoding {
372                ProjectedResponseEncoding::Row => {
373                    Some(app_v1::ProjectedResponseEncoding::Row as i32)
374                }
375                ProjectedResponseEncoding::Packed => {
376                    Some(app_v1::ProjectedResponseEncoding::Packed as i32)
377                }
378            },
379            request_id,
380        })
381    }
382}
383
384/// One Ready projected row: select cells and primary-key components.
385#[derive(Clone, Debug, Eq, PartialEq)]
386pub struct ProjectedReadyRow {
387    /// Select cells in `fields` order.
388    pub cells: Vec<CanonicalValue>,
389    /// Primary-key components in `primary_key_fields` order.
390    pub primary_key: Vec<CanonicalValue>,
391}
392
393/// Closed rebuilding reason codes from the wire.
394#[derive(Clone, Copy, Debug, Eq, PartialEq)]
395pub enum ProjectedRebuildingReason {
396    /// Replay budget exceeded.
397    ReplayBudgetExceeded,
398    /// Explicit rebuild requested.
399    ExplicitRebuild,
400    /// State integrity failure.
401    StateIntegrityFailure,
402}
403
404/// Closed degraded reason codes from the wire.
405#[derive(Clone, Copy, Debug, Eq, PartialEq)]
406pub enum ProjectedDegradedReason {
407    /// Apply lag SLO breached.
408    ApplyLagSlo,
409    /// Maintenance backlog.
410    MaintenanceBacklog,
411    /// Partial inventory.
412    PartialInventory,
413}
414
415/// Typed projected-query outcome mirroring the wire oneof arms.
416#[derive(Clone, Debug, Eq, PartialEq)]
417pub enum ProjectedQueryOutcome {
418    /// Published snapshot under the requested freshness policy.
419    ///
420    /// The select/primary-key split depends on the requested encoding, because
421    /// the row-oriented wire arm carries no separate primary-key vector:
422    ///
423    /// * [`ProjectedResponseEncoding::Packed`] — `fields` are exactly the
424    ///   selected projected fields, `primary_key_fields` are the entity
425    ///   primary-key components, and each row's `cells`/`primary_key` align
426    ///   with them respectively.
427    /// * [`ProjectedResponseEncoding::Row`] — `primary_key_fields` is always
428    ///   empty and every row's `primary_key` is empty. Primary-key names that
429    ///   were not selected are appended by the server to `fields`, so they
430    ///   arrive as trailing `cells`. Request `Packed` when the split matters.
431    Ready {
432        /// Projected field names in wire order for each row's cells.
433        ///
434        /// Under `Row` encoding this also carries non-selected primary-key
435        /// names appended after the selected fields.
436        fields: Vec<String>,
437        /// Entity primary-key field names aligned with each row's primary_key.
438        ///
439        /// Always empty under `Row` encoding (see the variant documentation).
440        primary_key_fields: Vec<String>,
441        /// Matching rows (`primary_key` empty under `Row` encoding).
442        rows: Vec<ProjectedReadyRow>,
443        /// Served projection frontier.
444        frontier: ProjectionFrontier,
445        /// Application head known at serve time.
446        head: ProjectionFrontier,
447        /// Commit token corresponding to the served frontier when sequenced.
448        commit_token: Option<CommitToken>,
449    },
450    /// Published snapshot answering an aggregate or grouped request.
451    ///
452    /// Answers every request carrying at least one
453    /// [`ProjectedAggregate`], grouped or not; a whole-set aggregate arrives
454    /// as exactly one group with an empty `keys` list. The requested
455    /// [`ProjectedResponseEncoding`] does not apply to this shape.
456    ///
457    /// `groups` is in the server's deterministic canonical value order over
458    /// the group keys (or the requested group-key `order` with canonical
459    /// ascending tie-breaking).
460    ReadyAggregates {
461        /// Group key field names; column order for every group's `keys`.
462        group_key_fields: Vec<String>,
463        /// The request's aggregate functions echoed back; column order for
464        /// every group's `values`.
465        aggregates: Vec<ProjectedAggregate>,
466        /// Groups in server order.
467        groups: Vec<ProjectedAggregateGroup>,
468        /// Served projection frontier.
469        frontier: ProjectionFrontier,
470        /// Application head known at serve time.
471        head: ProjectionFrontier,
472        /// Commit token corresponding to the served frontier when sequenced.
473        commit_token: Option<CommitToken>,
474    },
475    /// Freshness policy not yet satisfied.
476    Lagging {
477        /// Required frontier derived from the causal token or bounded policy.
478        required: ProjectionFrontier,
479        /// Current projection frontier.
480        current: ProjectionFrontier,
481        /// Application head.
482        head: ProjectionFrontier,
483        /// Sequence distance backlog when both are sequenced.
484        lag_sequences: Option<u64>,
485        /// Optional retry hint.
486        retry_after: Option<Duration>,
487    },
488    /// Catch-up before first publication.
489    Building {
490        /// Processed position during catch-up.
491        applied_through: ProjectionFrontier,
492        /// Known application head.
493        head: ProjectionFrontier,
494    },
495    /// Rebuild in progress.
496    Rebuilding {
497        /// Closed reason code.
498        reason: ProjectedRebuildingReason,
499        /// Progress numerator.
500        progress_applied: u64,
501        /// Progress denominator (0 = unknown).
502        progress_total: u64,
503    },
504    /// Degraded health while still identified.
505    Degraded {
506        /// Closed reason code.
507        reason: ProjectedDegradedReason,
508        /// Current frontier while degraded.
509        current: ProjectionFrontier,
510    },
511    /// Durable definition fingerprint mismatch.
512    Invalid {
513        /// Fingerprint expected by the registered definition.
514        expected_fingerprint: Vec<u8>,
515        /// Fingerprint found in durable state.
516        found_fingerprint: Vec<u8>,
517    },
518}
519
520impl RiffDbClient {
521    /// Executes one projected columnar query through the public application RPC.
522    pub async fn execute_projected_query(
523        &mut self,
524        query: ProjectedQuery,
525        metadata: &CallMetadata,
526    ) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
527        let shape = query.response_shape();
528        let request = query.into_wire_request()?;
529        let response = self
530            .execute_projected_query_raw(request, metadata)
531            .await
532            .map_err(ApplicationClientError::from)?;
533        raise_projected_outcome(response, shape)
534    }
535}
536
537impl crate::StableApplicationClient {
538    /// Executes one projected columnar query through the application facade.
539    pub async fn execute_projected_query(
540        &mut self,
541        query: ProjectedQuery,
542        metadata: &CallMetadata,
543    ) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
544        self.inner.execute_projected_query(query, metadata).await
545    }
546}
547
548/// Raises one wire response exactly as [`RiffDbClient::execute_projected_query`]
549/// would, given the query that produced it.
550///
551/// Exposed so a server-side test can drive real encoder output through the real
552/// client decoder in one process — the only place the two halves of the
553/// carriage meet without a live daemon. Not part of the supported surface: use
554/// the client method, which does this for you.
555#[doc(hidden)]
556pub fn raise_projected_response_for_query(
557    response: app_v1::ExecuteProjectedQueryResponse,
558    query: &ProjectedQuery,
559) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
560    raise_projected_outcome(response, query.response_shape())
561}
562
563fn lower_contract_selector(contract: ApplicationContract) -> Option<app_v1::ContractSelector> {
564    match contract {
565        ApplicationContract::Active => None,
566        ApplicationContract::Exact {
567            lineage,
568            version,
569            bundle_hash,
570        } => Some(app_v1::ContractSelector {
571            lineage,
572            version,
573            bundle_hash: bundle_hash.map_or_else(Vec::new, |hash| hash.to_vec()),
574        }),
575    }
576}
577
578fn lower_predicate(
579    predicate: ProjectedPredicate,
580) -> Result<app_v1::ProjectedPredicate, ApplicationClientError> {
581    match predicate {
582        ProjectedPredicate::Eq { field, value } => {
583            if field.is_empty() || field.len() > 256 {
584                return Err(ApplicationClientError::InvalidInput);
585            }
586            Ok(app_v1::ProjectedPredicate {
587                field,
588                kind: Some(app_v1::projected_predicate::Kind::Eq(lower_value(value)?)),
589            })
590        }
591        ProjectedPredicate::Range { field, low, high } => {
592            if field.is_empty() || field.len() > 256 {
593                return Err(ApplicationClientError::InvalidInput);
594            }
595            Ok(app_v1::ProjectedPredicate {
596                field,
597                kind: Some(app_v1::projected_predicate::Kind::Range(
598                    app_v1::ProjectedRange {
599                        lower: low.map(lower_value).transpose()?,
600                        upper: high.map(lower_value).transpose()?,
601                        // Engine shape: inclusive-low / exclusive-high.
602                        lower_inclusive: true,
603                        upper_inclusive: false,
604                    },
605                )),
606            })
607        }
608    }
609}
610
611fn lower_aggregate(
612    aggregate: ProjectedAggregate,
613) -> Result<app_v1::ProjectedAggregate, ApplicationClientError> {
614    let (op, field) = match aggregate {
615        ProjectedAggregate::Count => (app_v1::ProjectedAggregateOp::Count, String::new()),
616        ProjectedAggregate::CountPresent { field } => {
617            (app_v1::ProjectedAggregateOp::CountPresent, field)
618        }
619        ProjectedAggregate::CountDistinct { field } => {
620            (app_v1::ProjectedAggregateOp::CountDistinct, field)
621        }
622        ProjectedAggregate::CountDistinctPresent { field } => {
623            (app_v1::ProjectedAggregateOp::CountDistinctPresent, field)
624        }
625        ProjectedAggregate::Sum { field } => (app_v1::ProjectedAggregateOp::Sum, field),
626        ProjectedAggregate::Mean { field } => (app_v1::ProjectedAggregateOp::Mean, field),
627        ProjectedAggregate::Min { field } => (app_v1::ProjectedAggregateOp::Min, field),
628        ProjectedAggregate::Max { field } => (app_v1::ProjectedAggregateOp::Max, field),
629        ProjectedAggregate::Any { field } => (app_v1::ProjectedAggregateOp::Any, field),
630        ProjectedAggregate::All { field } => (app_v1::ProjectedAggregateOp::All, field),
631    };
632    if op != app_v1::ProjectedAggregateOp::Count && (field.is_empty() || field.len() > 256) {
633        return Err(ApplicationClientError::InvalidInput);
634    }
635    Ok(app_v1::ProjectedAggregate {
636        field,
637        op: op as i32,
638    })
639}
640
641fn lower_freshness(freshness: FreshnessPolicy) -> app_v1::FreshnessPolicyProto {
642    let policy = match freshness {
643        FreshnessPolicy::Available => {
644            app_v1::freshness_policy_proto::Policy::Available(app_v1::FreshnessAvailable {})
645        }
646        FreshnessPolicy::Bounded { max_lag_sequences } => {
647            app_v1::freshness_policy_proto::Policy::Bounded(app_v1::FreshnessBounded {
648                max_lag_sequences,
649            })
650        }
651        FreshnessPolicy::Causal { token, max_wait } => {
652            app_v1::freshness_policy_proto::Policy::Causal(app_v1::FreshnessCausal {
653                commit_token: token.into_bytes(),
654                max_wait_nanos: u64::try_from(max_wait.as_nanos()).unwrap_or(u64::MAX),
655            })
656        }
657    };
658    app_v1::FreshnessPolicyProto {
659        policy: Some(policy),
660    }
661}
662
663/// The aggregate contract one request asked for.
664///
665/// Carried into decoding so the response is checked against the *request*, not
666/// only against itself. Without it a server can return a self-consistent
667/// answer to a different question — a `count` echoed as a `count` where a
668/// `sum` was asked for reads as a plausible number for the wrong column.
669#[derive(Clone, Debug, Eq, PartialEq)]
670struct ProjectedAggregateShape {
671    /// Requested group-by key names, in request order. Empty = whole-set.
672    group_by: Vec<String>,
673    /// Requested aggregate functions, in request order.
674    aggregates: Vec<ProjectedAggregate>,
675    /// Requested group-key ordering, in request order.
676    order: Vec<ProjectedOrder>,
677}
678
679/// The single Ready arm one request may legitimately be answered by.
680#[derive(Clone, Debug, Eq, PartialEq)]
681enum ProjectedResponseShape {
682    /// Row request; the encoding chooses between `ready` and `ready_packed`.
683    Rows(ProjectedResponseEncoding),
684    /// Aggregate or grouped request; only `ready_aggregates` is valid.
685    Aggregates(ProjectedAggregateShape),
686}
687
688fn raise_projected_outcome(
689    response: app_v1::ExecuteProjectedQueryResponse,
690    shape: ProjectedResponseShape,
691) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
692    match response.outcome {
693        Some(app_v1::execute_projected_query_response::Outcome::Ready(ready)) => {
694            if shape != ProjectedResponseShape::Rows(ProjectedResponseEncoding::Row) {
695                // Falsifiability (c): PACKED requests must not surface as Ready
696                // row, and an aggregate request must never surface as rows.
697                return Err(ApplicationClientError::InvalidResponse);
698            }
699            raise_ready_row(ready)
700        }
701        Some(app_v1::execute_projected_query_response::Outcome::ReadyPacked(packed)) => {
702            if shape != ProjectedResponseShape::Rows(ProjectedResponseEncoding::Packed) {
703                // The packed arm is present only when the request opted into
704                // PACKED; the opposite substitution is a protocol violation.
705                return Err(ApplicationClientError::InvalidResponse);
706            }
707            raise_ready_packed(packed)
708        }
709        Some(app_v1::execute_projected_query_response::Outcome::ReadyAggregates(aggregates)) => {
710            let ProjectedResponseShape::Aggregates(requested) = shape else {
711                // A row request answered with folded values would silently
712                // report a different question's answer.
713                return Err(ApplicationClientError::InvalidResponse);
714            };
715            raise_ready_aggregates(aggregates, &requested)
716        }
717        Some(app_v1::execute_projected_query_response::Outcome::Lagging(lagging)) => {
718            Ok(ProjectedQueryOutcome::Lagging {
719                required: raise_frontier(lagging.required)?,
720                current: raise_frontier(lagging.current)?,
721                head: raise_frontier(lagging.head)?,
722                lag_sequences: lagging.lag_sequences,
723                retry_after: lagging.retry_after_nanos.map(Duration::from_nanos),
724            })
725        }
726        Some(app_v1::execute_projected_query_response::Outcome::Building(building)) => {
727            Ok(ProjectedQueryOutcome::Building {
728                applied_through: raise_frontier(building.applied_through)?,
729                head: raise_frontier(building.head)?,
730            })
731        }
732        Some(app_v1::execute_projected_query_response::Outcome::Rebuilding(rebuilding)) => {
733            Ok(ProjectedQueryOutcome::Rebuilding {
734                reason: raise_rebuilding_reason(rebuilding.reason)?,
735                progress_applied: rebuilding.progress_applied,
736                progress_total: rebuilding.progress_total,
737            })
738        }
739        Some(app_v1::execute_projected_query_response::Outcome::Degraded(degraded)) => {
740            Ok(ProjectedQueryOutcome::Degraded {
741                reason: raise_degraded_reason(degraded.reason)?,
742                current: raise_frontier(degraded.current)?,
743            })
744        }
745        Some(app_v1::execute_projected_query_response::Outcome::Invalid(invalid)) => {
746            Ok(ProjectedQueryOutcome::Invalid {
747                expected_fingerprint: invalid.expected_fingerprint,
748                found_fingerprint: invalid.found_fingerprint,
749            })
750        }
751        None => Err(ApplicationClientError::InvalidResponse),
752    }
753}
754
755fn raise_ready_row(
756    ready: app_v1::ProjectedQueryReady,
757) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
758    // Row wire merges select then PK names not already in select into `fields`.
759    // Without an explicit PK list we treat all fields as select cells and leave
760    // primary_key empty (row arm does not carry a separate PK vector).
761    let fields = ready.fields;
762    let mut rows = Vec::with_capacity(ready.rows.len());
763    for row in ready.rows {
764        let mut cells = Vec::with_capacity(fields.len());
765        // Build a name → value map; missing names fail closed.
766        let mut by_name = std::collections::BTreeMap::new();
767        for parameter in row.fields {
768            let value = parameter
769                .value
770                .ok_or(ApplicationClientError::InvalidResponse)?;
771            let canonical = canonical_value_from_proto(value)
772                .map_err(|_| ApplicationClientError::InvalidResponse)?;
773            if by_name.insert(parameter.name, canonical).is_some() {
774                return Err(ApplicationClientError::InvalidResponse);
775            }
776        }
777        for name in &fields {
778            let value = by_name
779                .remove(name)
780                .ok_or(ApplicationClientError::InvalidResponse)?;
781            cells.push(value);
782        }
783        if !by_name.is_empty() {
784            return Err(ApplicationClientError::InvalidResponse);
785        }
786        rows.push(ProjectedReadyRow {
787            cells,
788            primary_key: Vec::new(),
789        });
790    }
791    Ok(ProjectedQueryOutcome::Ready {
792        fields,
793        primary_key_fields: Vec::new(),
794        rows,
795        frontier: raise_frontier(ready.frontier)?,
796        head: raise_frontier(ready.head)?,
797        commit_token: raise_optional_token(ready.commit_token)?,
798    })
799}
800
801/// Block decoder: offsets → cell byte slices → decode_canonical_value.
802///
803/// Offset validation matches the harness packed decoder: length = row_count+1,
804/// first offset 0, last offset = data.len(), monotone non-decreasing, and each
805/// slice is an exact canonical document (trailing garbage rejected by decode).
806///
807/// `row_count` is remote input, so a nonzero count requires at least one column:
808/// with a column present the offsets checks bound the row count by the actual
809/// payload, and without one nothing else would.
810fn raise_ready_packed(
811    packed: app_v1::ProjectedReadyPacked,
812) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
813    let expected_columns = packed
814        .primary_key_fields
815        .len()
816        .saturating_add(packed.fields.len());
817    if packed.columns.len() != expected_columns {
818        return Err(ApplicationClientError::InvalidResponse);
819    }
820    let row_count = packed.row_count as usize;
821    if packed.columns.is_empty() && row_count != 0 {
822        return Err(ApplicationClientError::InvalidResponse);
823    }
824    for column in &packed.columns {
825        validate_packed_column_offsets(column, row_count)?;
826    }
827
828    let mut rows = Vec::with_capacity(row_count);
829    for row_index in 0..row_count {
830        let mut primary_key = Vec::with_capacity(packed.primary_key_fields.len());
831        let mut cells = Vec::with_capacity(packed.fields.len());
832        for (col_index, column) in packed.columns.iter().enumerate() {
833            let start = column.offsets[row_index] as usize;
834            let end = column.offsets[row_index + 1] as usize;
835            let value = decode_canonical_value(&column.data[start..end])
836                .map_err(|_| ApplicationClientError::InvalidResponse)?;
837            if col_index < packed.primary_key_fields.len() {
838                primary_key.push(value);
839            } else {
840                cells.push(value);
841            }
842        }
843        rows.push(ProjectedReadyRow { cells, primary_key });
844    }
845    Ok(ProjectedQueryOutcome::Ready {
846        fields: packed.fields,
847        primary_key_fields: packed.primary_key_fields,
848        rows,
849        frontier: raise_frontier(packed.frontier)?,
850        head: raise_frontier(packed.head)?,
851        commit_token: raise_optional_token(packed.commit_token)?,
852    })
853}
854
855/// Aggregate decoder, with the same fail-closed discipline as the packed one.
856///
857/// Nothing about a group is self-describing on its own: keys are positional
858/// against `group_key_fields` and values are positional against `aggregates`.
859/// Every length and every arm is therefore checked before a value is handed
860/// back, so a truncated, padded, or mislabelled group is rejected rather than
861/// silently re-interpreted as a different column.
862///
863/// The checks that matter most are against `requested`, not against the
864/// response itself. A response is free to be internally consistent and still
865/// answer a different question: echoing `count` where `sum` was asked for
866/// yields a plausible number for the wrong column, and self-consistency cannot
867/// detect it. Four request-anchored invariants close that:
868///
869/// * the echoed group-key names equal the requested keys, in order
870/// * the echoed descriptors equal the requested functions, in order
871/// * a whole-set request (no requested keys) answers with exactly one group,
872///   whose key list is empty — the wire contract admits no other shape
873/// * groups are strictly ordered in the server's canonical group-key order (or
874///   the requested `order` over group keys with canonical ascending
875///   tie-breaking), which both enforces the documented emission order and
876///   makes a repeated key impossible
877fn raise_ready_aggregates(
878    aggregates: app_v1::ProjectedReadyAggregates,
879    requested: &ProjectedAggregateShape,
880) -> Result<ProjectedQueryOutcome, ApplicationClientError> {
881    if aggregates.group_key_fields != requested.group_by {
882        return Err(ApplicationClientError::InvalidResponse);
883    }
884    let order_positions: Vec<(usize, ProjectedSortDirection)> = requested
885        .order
886        .iter()
887        .map(|spec| {
888            requested
889                .group_by
890                .iter()
891                .position(|key| key == &spec.field)
892                .map(|pos| (pos, spec.direction))
893                .ok_or(ApplicationClientError::InvalidResponse)
894        })
895        .collect::<Result<_, _>>()?;
896    let descriptors = aggregates
897        .aggregates
898        .into_iter()
899        .map(raise_aggregate_descriptor)
900        .collect::<Result<Vec<_>, _>>()?;
901    if descriptors != requested.aggregates {
902        return Err(ApplicationClientError::InvalidResponse);
903    }
904    // A whole-set fold has exactly one answer. Zero groups would read as "no
905    // result" for a COUNT that must report a number, and several would be
906    // several conflicting answers to one question.
907    if requested.group_by.is_empty() && aggregates.groups.len() != 1 {
908        return Err(ApplicationClientError::InvalidResponse);
909    }
910    let mut groups = Vec::with_capacity(aggregates.groups.len());
911    let mut previous_keys: Option<(Vec<CanonicalValue>, Vec<u8>)> = None;
912    let mut canonical_ascending = true;
913    let mut encoded_ascending = true;
914    for group in aggregates.groups {
915        if group.keys.len() != aggregates.group_key_fields.len() {
916            return Err(ApplicationClientError::InvalidResponse);
917        }
918        if group.values.len() != descriptors.len() {
919            return Err(ApplicationClientError::InvalidResponse);
920        }
921        let mut keys = Vec::with_capacity(group.keys.len());
922        for key in group.keys {
923            keys.push(
924                canonical_value_from_proto(key)
925                    .map_err(|_| ApplicationClientError::InvalidResponse)?,
926            );
927        }
928        let encoded = encoded_group_key(&keys)?;
929        if let Some((previous, previous_encoded)) = &previous_keys {
930            if !order_positions.is_empty() {
931                if compare_group_keys(previous, &keys, &order_positions) != std::cmp::Ordering::Less
932                {
933                    return Err(ApplicationClientError::InvalidResponse);
934                }
935            } else {
936                if compare_group_keys(previous, &keys, &[]) != std::cmp::Ordering::Less {
937                    canonical_ascending = false;
938                }
939                if *previous_encoded >= encoded {
940                    encoded_ascending = false;
941                }
942                if !canonical_ascending && !encoded_ascending {
943                    return Err(ApplicationClientError::InvalidResponse);
944                }
945            }
946        }
947        previous_keys = Some((keys.clone(), encoded));
948        let mut values = Vec::with_capacity(group.values.len());
949        for (value, descriptor) in group.values.into_iter().zip(&descriptors) {
950            values.push(raise_aggregate_value(value, descriptor)?);
951        }
952        groups.push(ProjectedAggregateGroup { keys, values });
953    }
954    Ok(ProjectedQueryOutcome::ReadyAggregates {
955        group_key_fields: aggregates.group_key_fields,
956        aggregates: descriptors,
957        groups,
958        frontier: raise_frontier(aggregates.frontier)?,
959        head: raise_frontier(aggregates.head)?,
960        commit_token: raise_optional_token(aggregates.commit_token)?,
961    })
962}
963
964fn encoded_group_key(keys: &[CanonicalValue]) -> Result<Vec<u8>, ApplicationClientError> {
965    let mut encoded = Vec::new();
966    for key in keys {
967        let cell = riffdb_types::encode_canonical_value(key)
968            .map_err(|_| ApplicationClientError::InvalidResponse)?;
969        let length =
970            u32::try_from(cell.len()).map_err(|_| ApplicationClientError::InvalidResponse)?;
971        encoded.extend_from_slice(&length.to_be_bytes());
972        encoded.extend_from_slice(&cell);
973    }
974    Ok(encoded)
975}
976
977fn compare_group_keys(
978    left_keys: &[CanonicalValue],
979    right_keys: &[CanonicalValue],
980    order_positions: &[(usize, ProjectedSortDirection)],
981) -> std::cmp::Ordering {
982    for (pos, direction) in order_positions {
983        let cmp = compare_canonical_values(&left_keys[*pos], &right_keys[*pos]);
984        let cmp = match direction {
985            ProjectedSortDirection::Asc => cmp,
986            ProjectedSortDirection::Desc => cmp.reverse(),
987        };
988        if cmp != std::cmp::Ordering::Equal {
989            return cmp;
990        }
991    }
992    for (left, right) in left_keys.iter().zip(right_keys.iter()) {
993        let cmp = compare_canonical_values(left, right);
994        if cmp != std::cmp::Ordering::Equal {
995            return cmp;
996        }
997    }
998    left_keys.len().cmp(&right_keys.len())
999}
1000
1001fn compare_canonical_values(left: &CanonicalValue, right: &CanonicalValue) -> std::cmp::Ordering {
1002    match (left, right) {
1003        (CanonicalValue::Bool(a), CanonicalValue::Bool(b)) => a.cmp(b),
1004        (CanonicalValue::I64(a), CanonicalValue::I64(b)) => a.cmp(b),
1005        (CanonicalValue::U64(a), CanonicalValue::U64(b)) => a.cmp(b),
1006        (CanonicalValue::Uuid(a), CanonicalValue::Uuid(b)) => a.cmp(b),
1007        (CanonicalValue::String(a), CanonicalValue::String(b)) => a.as_str().cmp(b.as_str()),
1008        (CanonicalValue::Bytes(a), CanonicalValue::Bytes(b)) => a.as_bytes().cmp(b.as_bytes()),
1009        (CanonicalValue::Timestamp(a), CanonicalValue::Timestamp(b)) => a.cmp(b),
1010        (CanonicalValue::Date(a), CanonicalValue::Date(b)) => a.cmp(b),
1011        (CanonicalValue::Null, CanonicalValue::Null) => std::cmp::Ordering::Equal,
1012        (CanonicalValue::Null, _) => std::cmp::Ordering::Less,
1013        (_, CanonicalValue::Null) => std::cmp::Ordering::Greater,
1014        _ => {
1015            let left_bytes = riffdb_types::encode_canonical_value(left).unwrap_or_default();
1016            let right_bytes = riffdb_types::encode_canonical_value(right).unwrap_or_default();
1017            left_bytes.cmp(&right_bytes)
1018        }
1019    }
1020}
1021
1022fn raise_aggregate_descriptor(
1023    descriptor: app_v1::ProjectedAggregate,
1024) -> Result<ProjectedAggregate, ApplicationClientError> {
1025    let op = app_v1::ProjectedAggregateOp::try_from(descriptor.op)
1026        .map_err(|_| ApplicationClientError::InvalidResponse)?;
1027    match op {
1028        app_v1::ProjectedAggregateOp::Count => {
1029            if descriptor.field.is_empty() {
1030                Ok(ProjectedAggregate::Count)
1031            } else {
1032                Err(ApplicationClientError::InvalidResponse)
1033            }
1034        }
1035        app_v1::ProjectedAggregateOp::CountPresent => Ok(ProjectedAggregate::CountPresent {
1036            field: named_column(descriptor.field)?,
1037        }),
1038        app_v1::ProjectedAggregateOp::CountDistinct => Ok(ProjectedAggregate::CountDistinct {
1039            field: named_column(descriptor.field)?,
1040        }),
1041        app_v1::ProjectedAggregateOp::CountDistinctPresent => {
1042            Ok(ProjectedAggregate::CountDistinctPresent {
1043                field: named_column(descriptor.field)?,
1044            })
1045        }
1046        app_v1::ProjectedAggregateOp::Sum => Ok(ProjectedAggregate::Sum {
1047            field: named_column(descriptor.field)?,
1048        }),
1049        app_v1::ProjectedAggregateOp::Mean => Ok(ProjectedAggregate::Mean {
1050            field: named_column(descriptor.field)?,
1051        }),
1052        app_v1::ProjectedAggregateOp::Min => Ok(ProjectedAggregate::Min {
1053            field: named_column(descriptor.field)?,
1054        }),
1055        app_v1::ProjectedAggregateOp::Max => Ok(ProjectedAggregate::Max {
1056            field: named_column(descriptor.field)?,
1057        }),
1058        app_v1::ProjectedAggregateOp::Any => Ok(ProjectedAggregate::Any {
1059            field: named_column(descriptor.field)?,
1060        }),
1061        app_v1::ProjectedAggregateOp::All => Ok(ProjectedAggregate::All {
1062            field: named_column(descriptor.field)?,
1063        }),
1064        app_v1::ProjectedAggregateOp::Unspecified => Err(ApplicationClientError::InvalidResponse),
1065    }
1066}
1067
1068fn named_column(field: String) -> Result<String, ApplicationClientError> {
1069    if field.is_empty() {
1070        return Err(ApplicationClientError::InvalidResponse);
1071    }
1072    Ok(field)
1073}
1074
1075/// Raises one aggregate cell, requiring its arm to match its descriptor.
1076///
1077/// Without the cross-check a server could return a count where a sum was asked
1078/// for and the caller would read a plausible number for the wrong question.
1079fn raise_aggregate_value(
1080    value: app_v1::ProjectedAggregateValue,
1081    descriptor: &ProjectedAggregate,
1082) -> Result<ProjectedAggregateValue, ApplicationClientError> {
1083    use app_v1::projected_aggregate_value::Value as WireValue;
1084    let arm = value.value.ok_or(ApplicationClientError::InvalidResponse)?;
1085    match (arm, descriptor) {
1086        (
1087            WireValue::Count(count),
1088            ProjectedAggregate::Count
1089            | ProjectedAggregate::CountPresent { .. }
1090            | ProjectedAggregate::CountDistinct { .. }
1091            | ProjectedAggregate::CountDistinctPresent { .. },
1092        ) => Ok(ProjectedAggregateValue::Count(count)),
1093        (WireValue::Sum(decimal), ProjectedAggregate::Sum { .. }) => {
1094            // Scale 0, no asserted precision, minimal 1..=16 byte coefficient.
1095            let sum = aggregate_sum_from_proto(&decimal)
1096                .map_err(|_| ApplicationClientError::InvalidResponse)?;
1097            Ok(ProjectedAggregateValue::Sum(sum))
1098        }
1099        (WireValue::ExactMean(exact_mean), ProjectedAggregate::Mean { .. }) => {
1100            let decimal = exact_mean
1101                .total
1102                .ok_or(ApplicationClientError::InvalidResponse)?;
1103            let total = aggregate_sum_from_proto(&decimal)
1104                .map_err(|_| ApplicationClientError::InvalidResponse)?;
1105            if exact_mean.count == 0 && total != 0 {
1106                return Err(ApplicationClientError::InvalidResponse);
1107            }
1108            Ok(ProjectedAggregateValue::ExactMean {
1109                total,
1110                count: exact_mean.count,
1111            })
1112        }
1113        (
1114            WireValue::Scalar(scalar),
1115            ProjectedAggregate::Min { .. } | ProjectedAggregate::Max { .. },
1116        ) => {
1117            // Absent = empty group; present null_value = a real NULL extreme.
1118            let value = scalar
1119                .value
1120                .map(canonical_value_from_proto)
1121                .transpose()
1122                .map_err(|_| ApplicationClientError::InvalidResponse)?;
1123            Ok(ProjectedAggregateValue::Scalar(value))
1124        }
1125        (
1126            WireValue::Boolean(boolean),
1127            ProjectedAggregate::Any { .. } | ProjectedAggregate::All { .. },
1128        ) => Ok(ProjectedAggregateValue::Bool(boolean)),
1129        _ => Err(ApplicationClientError::InvalidResponse),
1130    }
1131}
1132
1133fn validate_packed_column_offsets(
1134    column: &app_v1::PackedColumn,
1135    row_count: usize,
1136) -> Result<(), ApplicationClientError> {
1137    if column.offsets.len() != row_count.saturating_add(1) {
1138        return Err(ApplicationClientError::InvalidResponse);
1139    }
1140    if column.offsets.first().copied() != Some(0) {
1141        return Err(ApplicationClientError::InvalidResponse);
1142    }
1143    let last = *column.offsets.last().unwrap_or(&0) as usize;
1144    if last != column.data.len() {
1145        return Err(ApplicationClientError::InvalidResponse);
1146    }
1147    for window in column.offsets.windows(2) {
1148        if window[0] > window[1] || window[1] as usize > column.data.len() {
1149            return Err(ApplicationClientError::InvalidResponse);
1150        }
1151    }
1152    Ok(())
1153}
1154
1155fn raise_frontier(bytes: Vec<u8>) -> Result<ProjectionFrontier, ApplicationClientError> {
1156    ProjectionFrontier::from_bytes(bytes).map_err(|_| ApplicationClientError::InvalidResponse)
1157}
1158
1159fn raise_optional_token(bytes: Vec<u8>) -> Result<Option<CommitToken>, ApplicationClientError> {
1160    if bytes.is_empty() {
1161        return Ok(None);
1162    }
1163    CommitToken::from_bytes(bytes)
1164        .map(Some)
1165        .map_err(|_| ApplicationClientError::InvalidResponse)
1166}
1167
1168fn raise_rebuilding_reason(
1169    reason: i32,
1170) -> Result<ProjectedRebuildingReason, ApplicationClientError> {
1171    match app_v1::ProjectedRebuildingReason::try_from(reason) {
1172        Ok(app_v1::ProjectedRebuildingReason::ReplayBudgetExceeded) => {
1173            Ok(ProjectedRebuildingReason::ReplayBudgetExceeded)
1174        }
1175        Ok(app_v1::ProjectedRebuildingReason::ExplicitRebuild) => {
1176            Ok(ProjectedRebuildingReason::ExplicitRebuild)
1177        }
1178        Ok(app_v1::ProjectedRebuildingReason::StateIntegrityFailure) => {
1179            Ok(ProjectedRebuildingReason::StateIntegrityFailure)
1180        }
1181        Ok(app_v1::ProjectedRebuildingReason::Unspecified) | Err(_) => {
1182            Err(ApplicationClientError::InvalidResponse)
1183        }
1184    }
1185}
1186
1187fn raise_degraded_reason(reason: i32) -> Result<ProjectedDegradedReason, ApplicationClientError> {
1188    match app_v1::ProjectedDegradedReason::try_from(reason) {
1189        Ok(app_v1::ProjectedDegradedReason::ApplyLagSlo) => {
1190            Ok(ProjectedDegradedReason::ApplyLagSlo)
1191        }
1192        Ok(app_v1::ProjectedDegradedReason::MaintenanceBacklog) => {
1193            Ok(ProjectedDegradedReason::MaintenanceBacklog)
1194        }
1195        Ok(app_v1::ProjectedDegradedReason::PartialInventory) => {
1196            Ok(ProjectedDegradedReason::PartialInventory)
1197        }
1198        Ok(app_v1::ProjectedDegradedReason::Unspecified) | Err(_) => {
1199            Err(ApplicationClientError::InvalidResponse)
1200        }
1201    }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207    use riffdb_types::{CommitSequence, encode_canonical_value};
1208
1209    fn pack_one(value: &CanonicalValue) -> app_v1::PackedColumn {
1210        let data = encode_canonical_value(value).expect("enc");
1211        let end = data.len() as u32;
1212        app_v1::PackedColumn {
1213            data,
1214            offsets: vec![0, end],
1215        }
1216    }
1217
1218    fn valid_packed() -> app_v1::ProjectedReadyPacked {
1219        app_v1::ProjectedReadyPacked {
1220            fields: vec!["title".to_owned()],
1221            primary_key_fields: vec!["ticket_id".to_owned()],
1222            row_count: 1,
1223            columns: vec![
1224                pack_one(&CanonicalValue::Uuid([1; 16])),
1225                pack_one(&CanonicalValue::string("hello").expect("s")),
1226            ],
1227            frontier: ProjectionFrontier::new(
1228                1,
1229                riffdb_types::FrontierPosition::AppliedThrough(
1230                    CommitSequence::new(7).expect("seq"),
1231                ),
1232            )
1233            .into_bytes(),
1234            head: ProjectionFrontier::new(
1235                1,
1236                riffdb_types::FrontierPosition::AppliedThrough(
1237                    CommitSequence::new(9).expect("seq"),
1238                ),
1239            )
1240            .into_bytes(),
1241            commit_token: CommitToken::new(1, CommitSequence::new(7).expect("seq")).into_bytes(),
1242        }
1243    }
1244
1245    #[test]
1246    fn packed_decoder_accepts_baseline_and_rejects_hostile_shapes() {
1247        let ok = raise_ready_packed(valid_packed()).expect("valid");
1248        match ok {
1249            ProjectedQueryOutcome::Ready {
1250                fields,
1251                primary_key_fields,
1252                rows,
1253                ..
1254            } => {
1255                assert_eq!(fields, vec!["title".to_owned()]);
1256                assert_eq!(primary_key_fields, vec!["ticket_id".to_owned()]);
1257                assert_eq!(rows.len(), 1);
1258                assert_eq!(rows[0].primary_key, vec![CanonicalValue::Uuid([1; 16])]);
1259                assert_eq!(
1260                    rows[0].cells,
1261                    vec![CanonicalValue::string("hello").expect("s")]
1262                );
1263            }
1264            other => panic!("expected Ready, got {other:?}"),
1265        }
1266
1267        // Truncated data: last offset beyond the buffer.
1268        let mut truncated = valid_packed();
1269        truncated.columns[0].data.pop();
1270        assert!(raise_ready_packed(truncated).is_err());
1271
1272        // Non-monotone offsets — falsifiability (a).
1273        let mut nonmono = valid_packed();
1274        nonmono.columns[1].offsets = vec![1, 0];
1275        assert!(
1276            raise_ready_packed(nonmono).is_err(),
1277            "non-monotone offsets must fail closed"
1278        );
1279
1280        // Trailing garbage inside a cell.
1281        let mut garbage = valid_packed();
1282        garbage.columns[1].data.push(0xFF);
1283        let end = garbage.columns[1].data.len() as u32;
1284        garbage.columns[1].offsets = vec![0, end];
1285        assert!(raise_ready_packed(garbage).is_err());
1286
1287        // Row-count mismatch.
1288        let mut mismatch = valid_packed();
1289        mismatch.row_count = 2;
1290        assert!(raise_ready_packed(mismatch).is_err());
1291    }
1292
1293    /// A nonzero `row_count` with no columns must not reserve remote-chosen
1294    /// capacity: with zero columns the per-column offset checks are vacuous, so
1295    /// this shape is the only path where `row_count` is unbounded by payload.
1296    #[test]
1297    fn packed_decoder_rejects_rows_without_columns() {
1298        let mut fabricated = valid_packed();
1299        fabricated.fields = Vec::new();
1300        fabricated.primary_key_fields = Vec::new();
1301        fabricated.columns = Vec::new();
1302        fabricated.row_count = 100_000;
1303        let err = raise_ready_packed(fabricated)
1304            .expect_err("nonzero row_count with no columns must fail closed");
1305        assert!(matches!(err, ApplicationClientError::InvalidResponse));
1306
1307        // Genuinely empty result sets stay acceptable.
1308        let mut empty = valid_packed();
1309        empty.fields = Vec::new();
1310        empty.primary_key_fields = Vec::new();
1311        empty.columns = Vec::new();
1312        empty.row_count = 0;
1313        match raise_ready_packed(empty).expect("zero rows and zero columns") {
1314            ProjectedQueryOutcome::Ready { rows, fields, .. } => {
1315                assert!(rows.is_empty());
1316                assert!(fields.is_empty());
1317            }
1318            other => panic!("expected Ready, got {other:?}"),
1319        }
1320    }
1321
1322    /// The monotone comparison is the sole guard against a reversed cell span.
1323    /// This shape passes every other offsets check (length, first == 0,
1324    /// last == data.len(), all offsets in bounds) and only the pairwise
1325    /// comparison rejects it; without that comparison the decoder slices
1326    /// `data[span..0]` and panics.
1327    #[test]
1328    fn packed_decoder_rejects_mid_column_offset_decrease() {
1329        let cell = encode_canonical_value(&CanonicalValue::Uuid([2; 16])).expect("enc");
1330        let span = u32::try_from(cell.len()).expect("span");
1331        let mut data = Vec::with_capacity(cell.len() * 3);
1332        for _ in 0..3 {
1333            data.extend_from_slice(&cell);
1334        }
1335        let mut hostile = valid_packed();
1336        hostile.fields = vec!["title".to_owned()];
1337        hostile.primary_key_fields = Vec::new();
1338        hostile.row_count = 3;
1339        hostile.columns = vec![app_v1::PackedColumn {
1340            data,
1341            offsets: vec![0, span, 0, span * 3],
1342        }];
1343        let err = raise_ready_packed(hostile)
1344            .expect_err("mid-column offset decrease must fail closed, not panic");
1345        assert!(matches!(err, ApplicationClientError::InvalidResponse));
1346    }
1347
1348    #[test]
1349    fn packed_request_builder_sets_packed_encoding() {
1350        let query = ProjectedQuery::new(
1351            ApplicationContract::Exact {
1352                lineage: "TicketDesk".to_owned(),
1353                version: 1,
1354                bundle_hash: None,
1355            },
1356            "board",
1357            ApplicationValue::Uuid(crate::ApplicationUuid::from_bytes([9; 16])),
1358        )
1359        .expect("query")
1360        .encoding(ProjectedResponseEncoding::Packed)
1361        .limit(Some(50));
1362        let wire = query.into_wire_request().expect("wire");
1363        assert_eq!(
1364            wire.response_encoding,
1365            Some(app_v1::ProjectedResponseEncoding::Packed as i32)
1366        );
1367        assert_eq!(wire.projection_name, "board");
1368        assert_eq!(wire.request.as_ref().expect("body").limit, Some(50));
1369    }
1370
1371    #[test]
1372    fn packed_encoding_rejects_ready_row_arm() {
1373        let ready = app_v1::ExecuteProjectedQueryResponse {
1374            outcome: Some(app_v1::execute_projected_query_response::Outcome::Ready(
1375                app_v1::ProjectedQueryReady {
1376                    fields: vec!["title".to_owned()],
1377                    rows: Vec::new(),
1378                    frontier: ProjectionFrontier::new(
1379                        1,
1380                        riffdb_types::FrontierPosition::BeforeFirst,
1381                    )
1382                    .into_bytes(),
1383                    head: ProjectionFrontier::new(1, riffdb_types::FrontierPosition::BeforeFirst)
1384                        .into_bytes(),
1385                    commit_token: Vec::new(),
1386                },
1387            )),
1388        };
1389        let err = raise_projected_outcome(
1390            ready,
1391            ProjectedResponseShape::Rows(ProjectedResponseEncoding::Packed),
1392        )
1393        .expect_err("row arm under packed must fail");
1394        assert!(matches!(err, ApplicationClientError::InvalidResponse));
1395    }
1396
1397    #[test]
1398    fn row_encoding_rejects_ready_packed_arm() {
1399        let packed = app_v1::ExecuteProjectedQueryResponse {
1400            outcome: Some(
1401                app_v1::execute_projected_query_response::Outcome::ReadyPacked(valid_packed()),
1402            ),
1403        };
1404        let err = raise_projected_outcome(
1405            packed,
1406            ProjectedResponseShape::Rows(ProjectedResponseEncoding::Row),
1407        )
1408        .expect_err("packed arm under row must fail");
1409        assert!(matches!(err, ApplicationClientError::InvalidResponse));
1410
1411        // The same payload remains acceptable under a PACKED request.
1412        let packed = app_v1::ExecuteProjectedQueryResponse {
1413            outcome: Some(
1414                app_v1::execute_projected_query_response::Outcome::ReadyPacked(valid_packed()),
1415            ),
1416        };
1417        assert!(
1418            raise_projected_outcome(
1419                packed,
1420                ProjectedResponseShape::Rows(ProjectedResponseEncoding::Packed)
1421            )
1422            .is_ok()
1423        );
1424    }
1425}
1426
1427#[cfg(test)]
1428mod aggregate_tests {
1429    use super::*;
1430    use riffdb_proto::aggregate_sum_to_proto;
1431    use riffdb_types::CommitSequence;
1432
1433    fn frontier_bytes(sequence: u64) -> Vec<u8> {
1434        ProjectionFrontier::new(
1435            1,
1436            riffdb_types::FrontierPosition::AppliedThrough(
1437                CommitSequence::new(sequence).expect("seq"),
1438            ),
1439        )
1440        .into_bytes()
1441    }
1442
1443    fn descriptor(op: app_v1::ProjectedAggregateOp, field: &str) -> app_v1::ProjectedAggregate {
1444        app_v1::ProjectedAggregate {
1445            op: op as i32,
1446            field: field.to_owned(),
1447        }
1448    }
1449
1450    fn count_cell(count: u64) -> app_v1::ProjectedAggregateValue {
1451        app_v1::ProjectedAggregateValue {
1452            value: Some(app_v1::projected_aggregate_value::Value::Count(count)),
1453        }
1454    }
1455
1456    fn sum_cell(sum: i128) -> app_v1::ProjectedAggregateValue {
1457        app_v1::ProjectedAggregateValue {
1458            value: Some(app_v1::projected_aggregate_value::Value::Sum(
1459                aggregate_sum_to_proto(sum),
1460            )),
1461        }
1462    }
1463
1464    fn scalar_cell(value: Option<CanonicalValue>) -> app_v1::ProjectedAggregateValue {
1465        app_v1::ProjectedAggregateValue {
1466            value: Some(app_v1::projected_aggregate_value::Value::Scalar(
1467                app_v1::ProjectedAggregateScalar {
1468                    value: value
1469                        .as_ref()
1470                        .map(|value| riffdb_proto::canonical_value_to_proto(value).expect("value")),
1471                },
1472            )),
1473        }
1474    }
1475
1476    /// One grouped payload: keys = [status], aggregates = [count, sum, min].
1477    fn valid_aggregates() -> app_v1::ProjectedReadyAggregates {
1478        app_v1::ProjectedReadyAggregates {
1479            group_key_fields: vec!["status".to_owned()],
1480            aggregates: vec![
1481                descriptor(app_v1::ProjectedAggregateOp::Count, ""),
1482                descriptor(app_v1::ProjectedAggregateOp::Sum, "story_points"),
1483                descriptor(app_v1::ProjectedAggregateOp::Min, "title"),
1484            ],
1485            groups: vec![
1486                app_v1::ProjectedAggregateGroup {
1487                    keys: vec![
1488                        riffdb_proto::canonical_value_to_proto(
1489                            &CanonicalValue::string("closed").expect("s"),
1490                        )
1491                        .expect("value"),
1492                    ],
1493                    values: vec![
1494                        count_cell(2),
1495                        sum_cell(11),
1496                        scalar_cell(Some(CanonicalValue::string("a").expect("s"))),
1497                    ],
1498                },
1499                app_v1::ProjectedAggregateGroup {
1500                    keys: vec![
1501                        riffdb_proto::canonical_value_to_proto(
1502                            &CanonicalValue::string("open").expect("s"),
1503                        )
1504                        .expect("value"),
1505                    ],
1506                    values: vec![count_cell(0), sum_cell(0), scalar_cell(None)],
1507                },
1508            ],
1509            frontier: frontier_bytes(7),
1510            head: frontier_bytes(9),
1511            commit_token: CommitToken::new(1, CommitSequence::new(7).expect("seq")).into_bytes(),
1512        }
1513    }
1514
1515    /// The request contract `valid_aggregates()` is a valid answer to.
1516    fn requested() -> ProjectedAggregateShape {
1517        ProjectedAggregateShape {
1518            group_by: vec!["status".to_owned()],
1519            aggregates: vec![
1520                ProjectedAggregate::Count,
1521                ProjectedAggregate::Sum {
1522                    field: "story_points".to_owned(),
1523                },
1524                ProjectedAggregate::Min {
1525                    field: "title".to_owned(),
1526                },
1527            ],
1528            order: Vec::new(),
1529        }
1530    }
1531
1532    /// A whole-set request for a single COUNT.
1533    fn requested_whole_set() -> ProjectedAggregateShape {
1534        ProjectedAggregateShape {
1535            group_by: Vec::new(),
1536            aggregates: vec![ProjectedAggregate::Count],
1537            order: Vec::new(),
1538        }
1539    }
1540
1541    /// One valid whole-set answer: exactly one group with an empty key list.
1542    fn valid_whole_set() -> app_v1::ProjectedReadyAggregates {
1543        app_v1::ProjectedReadyAggregates {
1544            group_key_fields: Vec::new(),
1545            aggregates: vec![descriptor(app_v1::ProjectedAggregateOp::Count, "")],
1546            groups: vec![app_v1::ProjectedAggregateGroup {
1547                keys: Vec::new(),
1548                values: vec![count_cell(6)],
1549            }],
1550            frontier: frontier_bytes(7),
1551            head: frontier_bytes(9),
1552            commit_token: Vec::new(),
1553        }
1554    }
1555
1556    fn aggregates_response(
1557        payload: app_v1::ProjectedReadyAggregates,
1558    ) -> app_v1::ExecuteProjectedQueryResponse {
1559        app_v1::ExecuteProjectedQueryResponse {
1560            outcome: Some(
1561                app_v1::execute_projected_query_response::Outcome::ReadyAggregates(payload),
1562            ),
1563        }
1564    }
1565
1566    #[test]
1567    fn aggregate_decoder_accepts_baseline_payload_in_server_order() {
1568        let outcome = raise_ready_aggregates(valid_aggregates(), &requested()).expect("valid");
1569        let ProjectedQueryOutcome::ReadyAggregates {
1570            group_key_fields,
1571            aggregates,
1572            groups,
1573            commit_token,
1574            ..
1575        } = outcome
1576        else {
1577            panic!("expected ReadyAggregates");
1578        };
1579        assert_eq!(group_key_fields, vec!["status".to_owned()]);
1580        assert_eq!(
1581            aggregates,
1582            vec![
1583                ProjectedAggregate::Count,
1584                ProjectedAggregate::Sum {
1585                    field: "story_points".to_owned()
1586                },
1587                ProjectedAggregate::Min {
1588                    field: "title".to_owned()
1589                },
1590            ]
1591        );
1592        assert_eq!(groups.len(), 2);
1593        // Server order is canonical value order ("closed" precedes "open").
1594        assert_eq!(
1595            groups[0].keys,
1596            vec![CanonicalValue::string("closed").expect("s")]
1597        );
1598        assert_eq!(groups[0].values[0], ProjectedAggregateValue::Count(2));
1599        assert_eq!(groups[0].values[1], ProjectedAggregateValue::Sum(11));
1600        assert_eq!(
1601            groups[0].values[2],
1602            ProjectedAggregateValue::Scalar(Some(CanonicalValue::string("a").expect("s")))
1603        );
1604        assert_eq!(groups[1].values[2], ProjectedAggregateValue::Scalar(None));
1605        assert!(commit_token.is_some());
1606    }
1607
1608    /// Positional alignment is the only thing that makes a group readable, so
1609    /// every length disagreement must fail closed rather than truncate.
1610    #[test]
1611    fn aggregate_decoder_rejects_length_disagreements() {
1612        // One value too few for the descriptor list.
1613        let mut short_values = valid_aggregates();
1614        short_values.groups[0].values.pop();
1615        assert!(
1616            raise_ready_aggregates(short_values, &requested()).is_err(),
1617            "a group with fewer values than descriptors must fail closed"
1618        );
1619
1620        // One value too many.
1621        let mut long_values = valid_aggregates();
1622        long_values.groups[1].values.push(count_cell(9));
1623        assert!(raise_ready_aggregates(long_values, &requested()).is_err());
1624
1625        // Key cells that do not cover the key-name list.
1626        let mut short_keys = valid_aggregates();
1627        short_keys.groups[0].keys.clear();
1628        assert!(
1629            raise_ready_aggregates(short_keys, &requested()).is_err(),
1630            "a group missing a key cell must fail closed"
1631        );
1632
1633        // More key cells than names.
1634        let mut long_keys = valid_aggregates();
1635        long_keys.groups[1]
1636            .keys
1637            .push(riffdb_proto::canonical_value_to_proto(&CanonicalValue::U64(1)).expect("value"));
1638        assert!(raise_ready_aggregates(long_keys, &requested()).is_err());
1639    }
1640
1641    /// A value arm that disagrees with its descriptor would report a plausible
1642    /// number for a different question.
1643    #[test]
1644    fn aggregate_decoder_rejects_arm_descriptor_mismatch() {
1645        let mut swapped = valid_aggregates();
1646        swapped.groups[0].values[0] = sum_cell(2);
1647        assert!(
1648            raise_ready_aggregates(swapped, &requested()).is_err(),
1649            "a sum under a count descriptor must fail closed"
1650        );
1651
1652        let mut scalar_for_sum = valid_aggregates();
1653        scalar_for_sum.groups[0].values[1] = scalar_cell(Some(CanonicalValue::U64(1)));
1654        assert!(raise_ready_aggregates(scalar_for_sum, &requested()).is_err());
1655
1656        let mut count_for_min = valid_aggregates();
1657        count_for_min.groups[0].values[2] = count_cell(1);
1658        assert!(raise_ready_aggregates(count_for_min, &requested()).is_err());
1659
1660        // An unset value oneof is not a value.
1661        let mut unset = valid_aggregates();
1662        unset.groups[0].values[0] = app_v1::ProjectedAggregateValue { value: None };
1663        assert!(raise_ready_aggregates(unset, &requested()).is_err());
1664    }
1665
1666    #[test]
1667    fn aggregate_decoder_rejects_malformed_descriptors() {
1668        let mut unknown_op = valid_aggregates();
1669        unknown_op.aggregates[0].op = 99;
1670        assert!(
1671            raise_ready_aggregates(unknown_op, &requested()).is_err(),
1672            "an unknown op enum must fail closed"
1673        );
1674
1675        let mut unspecified = valid_aggregates();
1676        unspecified.aggregates[0].op = app_v1::ProjectedAggregateOp::Unspecified as i32;
1677        assert!(raise_ready_aggregates(unspecified, &requested()).is_err());
1678
1679        let mut counted_column = valid_aggregates();
1680        counted_column.aggregates[0].field = "title".to_owned();
1681        assert!(raise_ready_aggregates(counted_column, &requested()).is_err());
1682
1683        let mut columnless_sum = valid_aggregates();
1684        columnless_sum.aggregates[1].field = String::new();
1685        assert!(raise_ready_aggregates(columnless_sum, &requested()).is_err());
1686    }
1687
1688    /// Sum carriage is a closed scale-0 integer format, not a general decimal.
1689    #[test]
1690    fn aggregate_decoder_rejects_hostile_sum_decimals() {
1691        let oversized = app_v1::ProjectedAggregateValue {
1692            value: Some(app_v1::projected_aggregate_value::Value::Sum(
1693                riffdb_proto::v1::Decimal {
1694                    coefficient_twos_complement: vec![1; 17],
1695                    scale: 0,
1696                    precision: None,
1697                },
1698            )),
1699        };
1700        let mut payload = valid_aggregates();
1701        payload.groups[0].values[1] = oversized;
1702        assert!(
1703            raise_ready_aggregates(payload, &requested()).is_err(),
1704            "a 17-byte coefficient exceeds i128 and must fail closed"
1705        );
1706
1707        let mut scaled = valid_aggregates();
1708        scaled.groups[0].values[1] = app_v1::ProjectedAggregateValue {
1709            value: Some(app_v1::projected_aggregate_value::Value::Sum(
1710                riffdb_proto::v1::Decimal {
1711                    coefficient_twos_complement: vec![1],
1712                    scale: 2,
1713                    precision: None,
1714                },
1715            )),
1716        };
1717        assert!(
1718            raise_ready_aggregates(scaled, &requested()).is_err(),
1719            "a non-zero scale is not this carriage"
1720        );
1721
1722        let mut asserted_precision = valid_aggregates();
1723        asserted_precision.groups[0].values[1] = app_v1::ProjectedAggregateValue {
1724            value: Some(app_v1::projected_aggregate_value::Value::Sum(
1725                riffdb_proto::v1::Decimal {
1726                    coefficient_twos_complement: vec![1],
1727                    scale: 0,
1728                    precision: Some(38),
1729                },
1730            )),
1731        };
1732        assert!(raise_ready_aggregates(asserted_precision, &requested()).is_err());
1733
1734        let mut empty_coefficient = valid_aggregates();
1735        empty_coefficient.groups[0].values[1] = app_v1::ProjectedAggregateValue {
1736            value: Some(app_v1::projected_aggregate_value::Value::Sum(
1737                riffdb_proto::v1::Decimal {
1738                    coefficient_twos_complement: Vec::new(),
1739                    scale: 0,
1740                    precision: None,
1741                },
1742            )),
1743        };
1744        assert!(raise_ready_aggregates(empty_coefficient, &requested()).is_err());
1745
1746        // Non-minimal padding is a second encoding of the same number.
1747        let mut padded = valid_aggregates();
1748        padded.groups[0].values[1] = app_v1::ProjectedAggregateValue {
1749            value: Some(app_v1::projected_aggregate_value::Value::Sum(
1750                riffdb_proto::v1::Decimal {
1751                    coefficient_twos_complement: vec![0, 1],
1752                    scale: 0,
1753                    precision: None,
1754                },
1755            )),
1756        };
1757        assert!(raise_ready_aggregates(padded, &requested()).is_err());
1758    }
1759
1760    /// Every i128 the engine can produce round-trips exactly.
1761    #[test]
1762    fn sum_round_trips_across_the_full_i128_range() {
1763        for sum in [
1764            0_i128,
1765            1,
1766            -1,
1767            i64::MAX as i128,
1768            i64::MIN as i128,
1769            i64::MAX as i128 + 1,
1770            i128::MAX,
1771            i128::MIN,
1772        ] {
1773            let mut payload = valid_aggregates();
1774            payload.groups[0].values[1] = sum_cell(sum);
1775            let ProjectedQueryOutcome::ReadyAggregates { groups, .. } =
1776                raise_ready_aggregates(payload, &requested()).expect("valid")
1777            else {
1778                panic!("expected ReadyAggregates");
1779            };
1780            assert_eq!(groups[0].values[1], ProjectedAggregateValue::Sum(sum));
1781        }
1782    }
1783
1784    /// R3: absence and a real NULL extreme must stay distinguishable after
1785    /// decoding, not just on the wire.
1786    #[test]
1787    fn empty_group_scalar_is_distinct_from_a_null_extreme() {
1788        let mut absent = valid_aggregates();
1789        absent.groups[0].values[2] = scalar_cell(None);
1790        let mut null_extreme = valid_aggregates();
1791        null_extreme.groups[0].values[2] = scalar_cell(Some(CanonicalValue::Null));
1792
1793        let value_of = |payload| {
1794            let ProjectedQueryOutcome::ReadyAggregates { groups, .. } =
1795                raise_ready_aggregates(payload, &requested()).expect("valid")
1796            else {
1797                panic!("expected ReadyAggregates");
1798            };
1799            groups[0].values[2].clone()
1800        };
1801        assert_eq!(value_of(absent), ProjectedAggregateValue::Scalar(None));
1802        assert_eq!(
1803            value_of(null_extreme),
1804            ProjectedAggregateValue::Scalar(Some(CanonicalValue::Null))
1805        );
1806    }
1807
1808    /// Arm-versus-request cross-check, both directions.
1809    #[test]
1810    fn aggregate_arm_and_request_shape_must_agree() {
1811        for shape in [
1812            ProjectedResponseShape::Rows(ProjectedResponseEncoding::Row),
1813            ProjectedResponseShape::Rows(ProjectedResponseEncoding::Packed),
1814        ] {
1815            let err = raise_projected_outcome(aggregates_response(valid_aggregates()), shape)
1816                .expect_err("a row request must refuse folded values");
1817            assert!(matches!(err, ApplicationClientError::InvalidResponse));
1818        }
1819        assert!(
1820            raise_projected_outcome(
1821                aggregates_response(valid_aggregates()),
1822                ProjectedResponseShape::Aggregates(requested())
1823            )
1824            .is_ok()
1825        );
1826
1827        // An aggregate request answered with rows, both row arms.
1828        let ready_row = app_v1::ExecuteProjectedQueryResponse {
1829            outcome: Some(app_v1::execute_projected_query_response::Outcome::Ready(
1830                app_v1::ProjectedQueryReady {
1831                    fields: Vec::new(),
1832                    rows: Vec::new(),
1833                    frontier: frontier_bytes(7),
1834                    head: frontier_bytes(7),
1835                    commit_token: Vec::new(),
1836                },
1837            )),
1838        };
1839        assert!(matches!(
1840            raise_projected_outcome(ready_row, ProjectedResponseShape::Aggregates(requested())),
1841            Err(ApplicationClientError::InvalidResponse)
1842        ));
1843        let ready_packed = app_v1::ExecuteProjectedQueryResponse {
1844            outcome: Some(
1845                app_v1::execute_projected_query_response::Outcome::ReadyPacked(
1846                    app_v1::ProjectedReadyPacked {
1847                        fields: Vec::new(),
1848                        primary_key_fields: Vec::new(),
1849                        row_count: 0,
1850                        columns: Vec::new(),
1851                        frontier: frontier_bytes(7),
1852                        head: frontier_bytes(7),
1853                        commit_token: Vec::new(),
1854                    },
1855                ),
1856            ),
1857        };
1858        assert!(matches!(
1859            raise_projected_outcome(
1860                ready_packed,
1861                ProjectedResponseShape::Aggregates(requested())
1862            ),
1863            Err(ApplicationClientError::InvalidResponse)
1864        ));
1865    }
1866
1867    /// F4 — the echo is checked against the REQUEST, not only against itself.
1868    ///
1869    /// The reviewer's probe: a request for `Sum{story_points}` answered by a
1870    /// perfectly self-consistent `Count` of 9999. Every internal check passes;
1871    /// only the request comparison catches it, and without it the caller reads
1872    /// a plausible number for a column nobody asked about.
1873    #[test]
1874    fn a_self_consistent_answer_to_a_different_question_is_rejected() {
1875        let mislabelled = app_v1::ProjectedReadyAggregates {
1876            group_key_fields: Vec::new(),
1877            aggregates: vec![descriptor(app_v1::ProjectedAggregateOp::Count, "")],
1878            groups: vec![app_v1::ProjectedAggregateGroup {
1879                keys: Vec::new(),
1880                values: vec![count_cell(9999)],
1881            }],
1882            frontier: frontier_bytes(7),
1883            head: frontier_bytes(9),
1884            commit_token: Vec::new(),
1885        };
1886        let asked_for_a_sum = ProjectedAggregateShape {
1887            group_by: Vec::new(),
1888            aggregates: vec![ProjectedAggregate::Sum {
1889                field: "story_points".to_owned(),
1890            }],
1891            order: Vec::new(),
1892        };
1893        assert!(
1894            raise_ready_aggregates(mislabelled.clone(), &asked_for_a_sum).is_err(),
1895            "a count echoed where a sum was requested must fail closed"
1896        );
1897        // The same payload is fine for the request it actually answers.
1898        assert!(raise_ready_aggregates(mislabelled, &requested_whole_set()).is_ok());
1899
1900        // Descriptor order is response column order, so order must match too.
1901        let mut reordered = valid_aggregates();
1902        reordered.aggregates.swap(0, 1);
1903        for group in &mut reordered.groups {
1904            group.values.swap(0, 1);
1905        }
1906        assert!(
1907            raise_ready_aggregates(reordered, &requested()).is_err(),
1908            "descriptors reordered against the request must fail closed"
1909        );
1910
1911        // Group-key names are equally load-bearing: they name the key columns.
1912        let mut renamed = valid_aggregates();
1913        renamed.group_key_fields = vec!["project_id".to_owned()];
1914        assert!(
1915            raise_ready_aggregates(renamed, &requested()).is_err(),
1916            "a different key column than the one requested must fail closed"
1917        );
1918    }
1919
1920    /// F5 — a whole-set fold has exactly one answer.
1921    ///
1922    /// The reviewer's probe: zero groups (a COUNT arriving as "no result") and
1923    /// three groups (three conflicting answers to one question) were both
1924    /// accepted. The proto states the contract twice; the decoder now holds it.
1925    #[test]
1926    fn whole_set_requests_require_exactly_one_empty_key_group() {
1927        assert!(
1928            raise_ready_aggregates(valid_whole_set(), &requested_whole_set()).is_ok(),
1929            "the baseline whole-set answer must still decode"
1930        );
1931
1932        let mut none = valid_whole_set();
1933        none.groups.clear();
1934        assert!(
1935            raise_ready_aggregates(none, &requested_whole_set()).is_err(),
1936            "a whole-set COUNT must never arrive as no result at all"
1937        );
1938
1939        let mut several = valid_whole_set();
1940        several.groups = vec![
1941            app_v1::ProjectedAggregateGroup {
1942                keys: Vec::new(),
1943                values: vec![count_cell(1)],
1944            },
1945            app_v1::ProjectedAggregateGroup {
1946                keys: Vec::new(),
1947                values: vec![count_cell(2)],
1948            },
1949            app_v1::ProjectedAggregateGroup {
1950                keys: Vec::new(),
1951                values: vec![count_cell(3)],
1952            },
1953        ];
1954        assert!(
1955            raise_ready_aggregates(several, &requested_whole_set()).is_err(),
1956            "three conflicting whole-set answers must fail closed"
1957        );
1958
1959        // A grouped request keeps its own cardinality freedom, including zero
1960        // groups, which is the correct answer to an empty matching set.
1961        let mut empty_grouped = valid_aggregates();
1962        empty_grouped.groups.clear();
1963        assert!(
1964            raise_ready_aggregates(empty_grouped, &requested()).is_ok(),
1965            "a grouped request may legitimately match nothing"
1966        );
1967    }
1968
1969    /// F6 — strictly ordered group keys.
1970    ///
1971    /// The reviewer's probe: `[zzz→5, aaa→7, zzz→11]` decoded fine, so a caller
1972    /// folding groups into a map or a total would double-count `zzz`. One
1973    /// strict ordering check catches both the repeat and the disorder.
1974    #[test]
1975    fn group_keys_must_be_strictly_ascending_in_encoded_order() {
1976        let group = |key: &str, count: u64| app_v1::ProjectedAggregateGroup {
1977            keys: vec![
1978                riffdb_proto::canonical_value_to_proto(
1979                    &CanonicalValue::string(key).expect("bounded"),
1980                )
1981                .expect("value"),
1982            ],
1983            values: vec![count_cell(count)],
1984        };
1985        let payload =
1986            |groups: Vec<app_v1::ProjectedAggregateGroup>| app_v1::ProjectedReadyAggregates {
1987                group_key_fields: vec!["title".to_owned()],
1988                aggregates: vec![descriptor(app_v1::ProjectedAggregateOp::Count, "")],
1989                groups,
1990                frontier: frontier_bytes(7),
1991                head: frontier_bytes(9),
1992                commit_token: Vec::new(),
1993            };
1994        let asked = ProjectedAggregateShape {
1995            group_by: vec!["title".to_owned()],
1996            aggregates: vec![ProjectedAggregate::Count],
1997            order: Vec::new(),
1998        };
1999
2000        // Baseline: same-length keys in ascending order.
2001        assert!(
2002            raise_ready_aggregates(payload(vec![group("aaa", 7), group("zzz", 5)]), &asked).is_ok(),
2003            "ascending keys must decode"
2004        );
2005
2006        // The reviewer's probe: a repeated key with a run of disorder between.
2007        assert!(
2008            raise_ready_aggregates(
2009                payload(vec![group("zzz", 5), group("aaa", 7), group("zzz", 11)]),
2010                &asked
2011            )
2012            .is_err(),
2013            "a repeated group key would double-count in any caller folding groups"
2014        );
2015
2016        // Plain descending when default ascending was requested.
2017        assert!(
2018            raise_ready_aggregates(payload(vec![group("zzz", 5), group("aaa", 7)]), &asked)
2019                .is_err(),
2020            "descending groups break the default ascending emission order"
2021        );
2022
2023        // Adjacent exact duplicates.
2024        assert!(
2025            raise_ready_aggregates(payload(vec![group("aaa", 7), group("aaa", 7)]), &asked)
2026                .is_err()
2027        );
2028
2029        // Canonical value order ("aaa" before "zz") and length-prefixed order ("zz" before "aaa")
2030        // are each accepted when uniform across the unordered response, while mixed disorder fails.
2031        assert!(
2032            raise_ready_aggregates(payload(vec![group("aaa", 2), group("zz", 1)]), &asked).is_ok(),
2033            "canonical value order sorts 'aaa' before 'zz'"
2034        );
2035        assert!(
2036            raise_ready_aggregates(payload(vec![group("zz", 1), group("aaa", 2)]), &asked).is_ok(),
2037            "length-prefixed order sorts 'zz' before 'aaa'"
2038        );
2039        assert!(
2040            raise_ready_aggregates(
2041                payload(vec![group("aaa", 2), group("zz", 1), group("b", 3)]),
2042                &asked
2043            )
2044            .is_err(),
2045            "mixed disorder is neither canonical ascending nor encoded ascending"
2046        );
2047
2048        // When explicit descending order over the group key is requested, descending decodes
2049        // and ascending is rejected.
2050        let asked_desc = ProjectedAggregateShape {
2051            group_by: vec!["title".to_owned()],
2052            aggregates: vec![ProjectedAggregate::Count],
2053            order: vec![ProjectedOrder {
2054                field: "title".to_owned(),
2055                direction: ProjectedSortDirection::Desc,
2056            }],
2057        };
2058        assert!(
2059            raise_ready_aggregates(payload(vec![group("zz", 1), group("aaa", 2)]), &asked_desc)
2060                .is_ok(),
2061            "descending group order must decode when requested"
2062        );
2063        assert!(
2064            raise_ready_aggregates(payload(vec![group("aaa", 2), group("zz", 1)]), &asked_desc)
2065                .is_err(),
2066            "ascending group order must be rejected when descending was requested"
2067        );
2068    }
2069
2070    // ---------------------------------------------------------------
2071    // Request building
2072    // ---------------------------------------------------------------
2073
2074    fn query() -> ProjectedQuery {
2075        ProjectedQuery::new(
2076            ApplicationContract::Exact {
2077                lineage: "TicketDesk".to_owned(),
2078                version: 1,
2079                bundle_hash: None,
2080            },
2081            "board",
2082            ApplicationValue::Uuid(crate::ApplicationUuid::from_bytes([9; 16])),
2083        )
2084        .expect("query")
2085    }
2086
2087    // req: PRJ-002, OQ-045, OQ-046, OQ-047, OQ-048, DX-014
2088    #[test]
2089    fn aggregate_builder_lowers_every_op_and_keeps_request_order() {
2090        let wire = query()
2091            .group_by(vec!["status".to_owned()])
2092            .aggregate(ProjectedAggregate::Count)
2093            .aggregate(ProjectedAggregate::CountPresent {
2094                field: "assignee".to_owned(),
2095            })
2096            .aggregate(ProjectedAggregate::CountDistinct {
2097                field: "status".to_owned(),
2098            })
2099            .aggregate(ProjectedAggregate::CountDistinctPresent {
2100                field: "assignee".to_owned(),
2101            })
2102            .aggregate(ProjectedAggregate::Sum {
2103                field: "story_points".to_owned(),
2104            })
2105            .aggregate(ProjectedAggregate::Mean {
2106                field: "story_points".to_owned(),
2107            })
2108            .aggregate(ProjectedAggregate::Min {
2109                field: "title".to_owned(),
2110            })
2111            .aggregate(ProjectedAggregate::Max {
2112                field: "title".to_owned(),
2113            })
2114            .aggregate(ProjectedAggregate::Any {
2115                field: "is_open".to_owned(),
2116            })
2117            .aggregate(ProjectedAggregate::All {
2118                field: "is_open".to_owned(),
2119            })
2120            .into_wire_request()
2121            .expect("wire");
2122        let body = wire.request.expect("body");
2123        assert_eq!(body.group_by, vec!["status".to_owned()]);
2124        assert_eq!(
2125            body.aggregates
2126                .iter()
2127                .map(|item| (item.op, item.field.clone()))
2128                .collect::<Vec<_>>(),
2129            vec![
2130                (app_v1::ProjectedAggregateOp::Count as i32, String::new()),
2131                (
2132                    app_v1::ProjectedAggregateOp::CountPresent as i32,
2133                    "assignee".to_owned()
2134                ),
2135                (
2136                    app_v1::ProjectedAggregateOp::CountDistinct as i32,
2137                    "status".to_owned()
2138                ),
2139                (
2140                    app_v1::ProjectedAggregateOp::CountDistinctPresent as i32,
2141                    "assignee".to_owned()
2142                ),
2143                (
2144                    app_v1::ProjectedAggregateOp::Sum as i32,
2145                    "story_points".to_owned()
2146                ),
2147                (
2148                    app_v1::ProjectedAggregateOp::Mean as i32,
2149                    "story_points".to_owned()
2150                ),
2151                (app_v1::ProjectedAggregateOp::Min as i32, "title".to_owned()),
2152                (app_v1::ProjectedAggregateOp::Max as i32, "title".to_owned()),
2153                (
2154                    app_v1::ProjectedAggregateOp::Any as i32,
2155                    "is_open".to_owned()
2156                ),
2157                (
2158                    app_v1::ProjectedAggregateOp::All as i32,
2159                    "is_open".to_owned()
2160                ),
2161            ]
2162        );
2163    }
2164
2165    /// F2 — group keys alone make a request aggregate-shaped.
2166    ///
2167    /// The distinct-values query (`group_by` with no functions) is an
2168    /// explicitly supported body: the proto documents it, the conversion
2169    /// layer lowers it, and the engine answers it with `ready_aggregates`.
2170    /// Deriving the expected arm from the function list alone made the SDK
2171    /// build a valid request and then reject the server's correct answer.
2172    #[test]
2173    fn group_keys_without_functions_expect_the_aggregate_arm() {
2174        let distinct = query().group_by(vec!["status".to_owned()]);
2175        assert_eq!(
2176            distinct.clone().response_shape(),
2177            ProjectedResponseShape::Aggregates(ProjectedAggregateShape {
2178                group_by: vec!["status".to_owned()],
2179                aggregates: Vec::new(),
2180                order: Vec::new(),
2181            }),
2182            "a grouped request must not declare itself row-shaped"
2183        );
2184
2185        // The request really is the shape the server will treat as grouped.
2186        let body = distinct
2187            .clone()
2188            .into_wire_request()
2189            .expect("wire")
2190            .request
2191            .expect("body");
2192        assert_eq!(body.group_by, vec!["status".to_owned()]);
2193        assert!(body.aggregates.is_empty());
2194
2195        // And the server's key-only answer decodes rather than being refused.
2196        let key_only = app_v1::ProjectedReadyAggregates {
2197            group_key_fields: vec!["status".to_owned()],
2198            aggregates: Vec::new(),
2199            groups: vec![
2200                app_v1::ProjectedAggregateGroup {
2201                    keys: vec![
2202                        riffdb_proto::canonical_value_to_proto(
2203                            &CanonicalValue::string("closed").expect("bounded"),
2204                        )
2205                        .expect("value"),
2206                    ],
2207                    values: Vec::new(),
2208                },
2209                app_v1::ProjectedAggregateGroup {
2210                    keys: vec![
2211                        riffdb_proto::canonical_value_to_proto(
2212                            &CanonicalValue::string("open___").expect("bounded"),
2213                        )
2214                        .expect("value"),
2215                    ],
2216                    values: Vec::new(),
2217                },
2218            ],
2219            frontier: frontier_bytes(7),
2220            head: frontier_bytes(9),
2221            commit_token: Vec::new(),
2222        };
2223        let outcome = raise_projected_response_for_query(aggregates_response(key_only), &distinct)
2224            .expect("a grouped request must accept the grouped answer it asked for");
2225        let ProjectedQueryOutcome::ReadyAggregates {
2226            group_key_fields,
2227            aggregates,
2228            groups,
2229            ..
2230        } = outcome
2231        else {
2232            panic!("expected ReadyAggregates");
2233        };
2234        assert_eq!(group_key_fields, vec!["status".to_owned()]);
2235        assert!(aggregates.is_empty(), "no functions were requested");
2236        assert_eq!(groups.len(), 2);
2237        assert!(groups.iter().all(|group| group.values.is_empty()));
2238    }
2239
2240    #[test]
2241    fn row_requests_still_carry_no_aggregate_vocabulary() {
2242        let body = query().into_wire_request().expect("wire").request;
2243        let body = body.expect("body");
2244        assert!(body.aggregates.is_empty());
2245        assert!(body.group_by.is_empty());
2246    }
2247
2248    /// Refused locally rather than spending a round trip: the server computes
2249    /// exactly one whole-set function per query.
2250    #[test]
2251    fn multiple_aggregates_without_group_keys_are_refused_locally() {
2252        let err = query()
2253            .aggregate(ProjectedAggregate::Count)
2254            .aggregate(ProjectedAggregate::Sum {
2255                field: "story_points".to_owned(),
2256            })
2257            .into_wire_request()
2258            .expect_err("two whole-set functions have no server path");
2259        assert!(matches!(err, ApplicationClientError::InvalidInput));
2260
2261        // One is fine, and so are two once a key is present.
2262        assert!(
2263            query()
2264                .aggregate(ProjectedAggregate::Count)
2265                .into_wire_request()
2266                .is_ok()
2267        );
2268        assert!(
2269            query()
2270                .group_by(vec!["status".to_owned()])
2271                .aggregate(ProjectedAggregate::Count)
2272                .aggregate(ProjectedAggregate::Sum {
2273                    field: "story_points".to_owned(),
2274                })
2275                .into_wire_request()
2276                .is_ok()
2277        );
2278    }
2279
2280    #[test]
2281    fn unbounded_names_are_refused_before_the_wire() {
2282        let long = "x".repeat(257);
2283        assert!(matches!(
2284            query()
2285                .aggregate(ProjectedAggregate::Sum {
2286                    field: long.clone()
2287                })
2288                .into_wire_request(),
2289            Err(ApplicationClientError::InvalidInput)
2290        ));
2291        assert!(matches!(
2292            query()
2293                .group_by(vec![long])
2294                .aggregate(ProjectedAggregate::Count)
2295                .into_wire_request(),
2296            Err(ApplicationClientError::InvalidInput)
2297        ));
2298        assert!(matches!(
2299            query()
2300                .group_by(vec![String::new()])
2301                .aggregate(ProjectedAggregate::Count)
2302                .into_wire_request(),
2303            Err(ApplicationClientError::InvalidInput)
2304        ));
2305    }
2306
2307    /// An aggregate request must expect the aggregate arm even when the caller
2308    /// also asked for PACKED: encoding does not apply to folded results.
2309    #[test]
2310    fn aggregate_requests_expect_the_aggregate_arm_under_any_encoding() {
2311        for encoding in [
2312            ProjectedResponseEncoding::Row,
2313            ProjectedResponseEncoding::Packed,
2314        ] {
2315            let query = query()
2316                .encoding(encoding)
2317                .aggregate(ProjectedAggregate::Count);
2318            assert_eq!(
2319                query.response_shape(),
2320                ProjectedResponseShape::Aggregates(ProjectedAggregateShape {
2321                    group_by: Vec::new(),
2322                    aggregates: vec![ProjectedAggregate::Count],
2323                    order: Vec::new(),
2324                })
2325            );
2326        }
2327        assert_eq!(
2328            query().response_shape(),
2329            ProjectedResponseShape::Rows(ProjectedResponseEncoding::Row)
2330        );
2331    }
2332}