Skip to main content

pond/
handlers.rs

1//! Transport-agnostic wire handlers (spec.md#protocol), one inner module per
2//! operation.
3
4fn map_error(error: crate::Error) -> crate::wire::ErrorEnvelope {
5    error.into()
6}
7
8/// Typed identifier for the namespace a wire request targets. v1 is
9/// single-namespace, so every successful resolve returns `root()`; the
10/// type lets future multi-namespace routing land without churning call
11/// sites (spec.md#wire-namespace-resolution).
12#[derive(Debug, Clone)]
13pub struct NamespaceIdent(pub Vec<String>);
14
15impl NamespaceIdent {
16    pub fn root() -> Self {
17        Self(vec![])
18    }
19    pub fn as_table_id(&self, table_name: &str) -> Vec<String> {
20        let mut id = self.0.clone();
21        id.push(table_name.to_string());
22        id
23    }
24}
25
26/// The one and only namespace-resolution point; every wire handler funnels
27/// through this. v1 accepts `None` or the default and returns the singleton
28/// root namespace; everything else is a hard reject.
29pub fn resolve_namespace(
30    namespace: Option<&str>,
31) -> Result<NamespaceIdent, crate::wire::ErrorEnvelope> {
32    match namespace {
33        None | Some(crate::wire::DEFAULT_NAMESPACE) => Ok(NamespaceIdent::root()),
34        Some(other) => Err(map_error(crate::Error::namespace_unknown(other))),
35    }
36}
37
38fn map_storage(error: anyhow::Error) -> crate::wire::ErrorEnvelope {
39    // Classify before bucketing: an OCC commit-conflict exhaustion has its own
40    // wire code (spec.md#protocol). Everything else lands in `storage_unavailable`.
41    if let Some(conflict) = error.downcast_ref::<crate::substrate::ConflictExhausted>() {
42        return map_error(crate::Error::Conflict {
43            attempts: conflict.attempts,
44        });
45    }
46    map_error(crate::Error::Storage(error))
47}
48
49mod ingest_handler {
50    use anyhow::Result;
51    use tokio_stream::StreamExt;
52
53    use crate::{
54        adapter::{Adapter, AdapterYield, SkipOracle, SkipReason},
55        sessions::{IngestEvent, IngestSummary, IngestValidator, OutcomeStatus, RowOutcome, Store},
56        wire::{
57            ErrorBody, ErrorCode, IngestEnvelope, IngestRequest, IngestResponse, IngestResult,
58            IngestStatus, validate_protocol,
59        },
60    };
61
62    use super::{map_error, map_storage};
63
64    /// Hard cap on events per `pond_ingest` batch (spec.md#protocol).
65    pub const MAX_INGEST_EVENTS: usize = 1000;
66
67    /// Progress signals emitted by [`ingest_adapter`] for the CLI bar (and
68    /// any other observer). One [`SyncEvent::Discovered`] fires up front
69    /// once `adapter.discover()` returns; then one [`SyncEvent::SessionDone`]
70    /// fires per session as the validator commits it or the adapter skips
71    /// it. The adapter path never errors at the event level - every
72    /// per-session outcome is surfaced through this enum.
73    #[derive(Debug, Clone)]
74    pub enum SyncEvent {
75        /// Up-front session count from `adapter.discover()`. Emitted exactly
76        /// once before any `SessionDone`. When discovery fails, the field is
77        /// `None` and the bar runs in rolling-counter mode.
78        Discovered { total: Option<usize> },
79        /// One session finished: committed, skipped (undecodable source),
80        /// or rejected by the validator.
81        SessionDone(SessionOutcome),
82        /// Aggregate skip: one callback for N files (typically `Fresh`).
83        SkippedBulk { status: SyncStatus, count: usize },
84        /// A flush batch of `pending` staged sessions is about to embed + write:
85        /// the slow phase during which no `SessionDone` fires. Lets the bar show
86        /// the commit is in progress instead of freezing between drains.
87        Flushing { pending: usize },
88    }
89
90    /// What happened to one session in an adapter-driven sync.
91    #[derive(Debug, Clone)]
92    pub struct SessionOutcome {
93        /// Project/cwd the session ran in, when the adapter could parse it.
94        pub project: Option<String>,
95        /// Session id, when the source was decodable far enough to read one.
96        /// `None` means the file was unreadable before any `Session` event.
97        pub session_id: Option<String>,
98        /// Messages observed in the source stream (not the same as rows
99        /// written: validator-rejected sessions still report the count).
100        pub messages: usize,
101        pub status: SyncStatus,
102    }
103
104    /// Per-session outcome class.
105    ///
106    /// - `Ok` - committed cleanly, zero drops.
107    /// - `Partial` - committed, but the validator dropped N events from this
108    ///   session (per-event drop policy: bad-line skips, ordering violations,
109    ///   duplicate ids). The non-bad events landed.
110    /// - `Skipped` - the adapter couldn't extract a Session header from this
111    ///   file at all (empty `.jsonl`, header corruption). Nothing written.
112    /// - `Rejected` - the validator rejected the session at flush time on a
113    ///   Session-level invariant (`source_agent` / `project` immutability).
114    ///   The substream is dropped wholesale. This is the rare case where the
115    ///   *whole* session is lost; for everything else use `Partial`.
116    #[derive(Debug, Clone)]
117    pub enum SyncStatus {
118        Ok,
119        Partial {
120            dropped_events: usize,
121            /// First drop's error message; subsequent drops counted, not
122            /// retained. Full detail at `-vv` (debug) verbosity.
123            first_drop_reason: Option<String>,
124        },
125        Skipped {
126            reason: String,
127        },
128        Rejected {
129            reason: String,
130        },
131        /// Per-session staleness skip (spec.md#adapter-integrity-event-ordering): adapter short-circuited
132        /// the file decode because `mtime < MAX(messages.timestamp)`.
133        Fresh,
134        /// File produced no importable session (empty `.jsonl`, sidecar-only
135        /// rows, or an unextractable header). Benign: counted in
136        /// `skipped_empty`, never an error or a drop.
137        Empty,
138        /// Session present in more than one source form; this copy is
139        /// superseded by an authoritative copy the same run ingests (e.g.
140        /// opencode's legacy tree copy of a DB-resident session). Content
141        /// identity is not verified - supersession is by session id (the
142        /// source's documented migration contract). Counted in
143        /// `skipped_superseded`, never folded into `Empty`.
144        Superseded,
145    }
146
147    #[derive(Debug, Default)]
148    struct InFlight {
149        project: Option<String>,
150        session_id: String,
151        messages: usize,
152        /// Events the adapter dropped mid-stream (skip-bad-line) that belong
153        /// to this in-flight session. Summed with the validator's per-event
154        /// drops at flush time to compute the final `SyncStatus::Partial`
155        /// count.
156        dropped_events: usize,
157        first_drop_reason: Option<String>,
158        /// The `index` value used when the Session event was pushed to the
159        /// validator. After batched flush, `RowOutcome.index` lets us match
160        /// per-session outcomes back to the originating session.
161        session_index: usize,
162    }
163
164    /// One session that has been fully observed but whose write hasn't
165    /// completed yet (queued in the validator's batched-flush buffer).
166    /// Emitted as `SyncEvent::SessionDone` after the corresponding flush
167    /// returns its outcomes.
168    #[derive(Debug)]
169    struct PendingDone {
170        project: Option<String>,
171        session_id: String,
172        messages: usize,
173        dropped_events: usize,
174        first_drop_reason: Option<String>,
175        session_index: usize,
176    }
177
178    /// Batch size used by the adapter ingest loop: flush every N completed
179    /// substreams to amortize per-commit cost. 100 is the value validated in
180    /// `benches/ingest_bench.rs` against the measured profile (substream
181    /// flushes were 78-88% of wall time at batch=1; ~25x fewer commits at
182    /// batch=100 closes most of that gap). Memory bound: ~N x (avg events
183    /// per session) staged in RAM, ~tens of MB at this scale.
184    const ADAPTER_FLUSH_BATCH: usize = 100;
185
186    /// Drain `adapter.events()` into `store`, accumulating an [`IngestSummary`]
187    /// and reporting progress through `on_event`. The adapter path is
188    /// CLI-driven (`pond sync`) and reports aggregates, not per-row results -
189    /// the wire-level [`pond_ingest`] handler keeps the per-row contract for
190    /// HTTP clients.
191    ///
192    /// Undecodable session substreams are skipped, not warned: the design
193    /// contract (no silent drops) is met by surfacing each skip through
194    /// `on_event` as [`SyncStatus::Skipped`]. The tracing line stays available
195    /// at DEBUG for deep-debug; default verbosity is silent.
196    pub async fn ingest_adapter<F>(
197        store: &Store,
198        adapter: &dyn Adapter,
199        oracle: &dyn SkipOracle,
200        mut on_event: F,
201    ) -> Result<IngestSummary>
202    where
203        F: FnMut(SyncEvent),
204    {
205        let mut summary = IngestSummary::default();
206        let truncations_before = crate::adapter::extract::truncated_values_count();
207        // Discovery is best-effort: a failure (no read perm, bad config)
208        // still lets the bar run as a rolling counter. We surface the count
209        // upfront when we can; otherwise the bar uses `set_length(0)`.
210        let discover_started = std::time::Instant::now();
211        let total = adapter
212            .discover()
213            .await
214            .map_err(|error| tracing::debug!(%error, "adapter discover failed"))
215            .ok();
216        tracing::debug!(target: "pond::perf", stage = "discover", elapsed_ms = discover_started.elapsed().as_millis() as u64, total = total.unwrap_or(0), "sync stage");
217        on_event(SyncEvent::Discovered { total });
218
219        let mut events = adapter.events_with(oracle);
220        let mut validator = IngestValidator::default();
221        // Adapter events have no stable input index (they stream from disk);
222        // assign a monotonic counter so RowOutcome.index stays unique even
223        // though the values aren't surfaced anywhere.
224        let mut index = 0usize;
225        let mut in_flight: Option<InFlight> = None;
226        // Sessions whose end-of-stream we've observed but whose write is
227        // still pending in the validator's batch buffer. Drained in FIFO
228        // order against `validator.flush()`'s outcome stream.
229        let mut pending_dones: std::collections::VecDeque<PendingDone> =
230            std::collections::VecDeque::new();
231        // Perf probe accumulators. Logged once at the end of the run at `-v`
232        // (info) verbosity so a single sync emits one tidy summary plus
233        // per-merge_insert lines from substrate. Visible only at INFO; never
234        // affects normal output.
235        let mut decode_total = std::time::Duration::ZERO;
236        let mut decode_count = 0u64;
237        let mut validator_total = std::time::Duration::ZERO;
238        let mut validator_count = 0u64;
239        let run_started = std::time::Instant::now();
240
241        loop {
242            let decode_start = std::time::Instant::now();
243            let next = events.next().await;
244            decode_total += decode_start.elapsed();
245            decode_count += 1;
246            let event = match next {
247                Some(event) => event,
248                None => break,
249            };
250            match event {
251                Ok(AdapterYield::Skipped {
252                    session_id,
253                    project,
254                    reason,
255                }) => {
256                    let status = match reason {
257                        SkipReason::Fresh => {
258                            summary.skipped_fresh += 1;
259                            SyncStatus::Fresh
260                        }
261                        SkipReason::Empty => {
262                            summary.skipped_empty += 1;
263                            SyncStatus::Empty
264                        }
265                        SkipReason::Superseded => {
266                            summary.skipped_superseded += 1;
267                            SyncStatus::Superseded
268                        }
269                        SkipReason::Unsupported(reason) => {
270                            summary.skipped_files += 1;
271                            SyncStatus::Skipped { reason }
272                        }
273                    };
274                    on_event(SyncEvent::SessionDone(SessionOutcome {
275                        project,
276                        session_id,
277                        messages: 0,
278                        status,
279                    }));
280                }
281                Ok(AdapterYield::SkippedBatch { reason, count }) => {
282                    let status = match reason {
283                        SkipReason::Fresh => {
284                            summary.skipped_fresh += count;
285                            SyncStatus::Fresh
286                        }
287                        SkipReason::Empty => {
288                            summary.skipped_empty += count;
289                            SyncStatus::Empty
290                        }
291                        SkipReason::Superseded => {
292                            summary.skipped_superseded += count;
293                            SyncStatus::Superseded
294                        }
295                        SkipReason::Unsupported(reason) => {
296                            summary.skipped_files += count;
297                            SyncStatus::Skipped { reason }
298                        }
299                    };
300                    on_event(SyncEvent::SkippedBulk { status, count });
301                }
302                Ok(AdapterYield::Event(event)) => {
303                    // A new Session means the current one is being closed
304                    // out by the validator (moved to its `completed` buffer
305                    // for batched flush). Stage the PendingDone so we can
306                    // emit SessionDone with proper status after flush.
307                    if matches!(&event, IngestEvent::Session(_))
308                        && let Some(prev) = in_flight.take()
309                    {
310                        pending_dones.push_back(PendingDone {
311                            project: prev.project,
312                            session_id: prev.session_id,
313                            messages: prev.messages,
314                            dropped_events: prev.dropped_events,
315                            first_drop_reason: prev.first_drop_reason,
316                            session_index: prev.session_index,
317                        });
318                    }
319                    let event_index = index;
320                    match &event {
321                        IngestEvent::Session(session) => {
322                            in_flight = Some(InFlight {
323                                project: Some((*session.project).clone()),
324                                session_id: session.id.clone(),
325                                messages: 0,
326                                dropped_events: 0,
327                                first_drop_reason: None,
328                                session_index: event_index,
329                            });
330                        }
331                        IngestEvent::Message(_) => {
332                            if let Some(slot) = in_flight.as_mut() {
333                                slot.messages += 1;
334                            }
335                        }
336                        IngestEvent::Part(_) => {}
337                    }
338
339                    let validator_start = std::time::Instant::now();
340                    let push_outcomes = validator.push(store, index, event).await?;
341                    validator_total += validator_start.elapsed();
342                    validator_count += 1;
343                    // Per-event drops returned synchronously by push (ordering
344                    // / dup-id violations) attribute to the in-flight
345                    // session's drop count. Session-level errors (e.g. empty
346                    // source_agent) come back here too; we don't currently
347                    // distinguish them - they're rare and end up in
348                    // `summary.dropped_events`.
349                    for outcome in &push_outcomes {
350                        if matches!(outcome.status, OutcomeStatus::Error)
351                            && outcome.kind != "session"
352                            && let Some(slot) = in_flight.as_mut()
353                        {
354                            slot.dropped_events += 1;
355                            if slot.first_drop_reason.is_none() {
356                                slot.first_drop_reason =
357                                    outcome.error.as_ref().map(|err| err.message.clone());
358                            }
359                        }
360                    }
361                    summary.add_outcomes(&push_outcomes);
362                    index += 1;
363
364                    // Drain the batch periodically. The validator's
365                    // `pending_substreams()` count grows by one each time we
366                    // close a substream; once it hits the batch threshold we
367                    // commit them in one parallel 3-table merge_insert.
368                    if validator.pending_substreams() >= ADAPTER_FLUSH_BATCH {
369                        on_event(SyncEvent::Flushing {
370                            pending: validator.pending_substreams(),
371                        });
372                        let flush_start = std::time::Instant::now();
373                        let (flush_outcomes, flush_counts) = validator.flush(store).await?;
374                        validator_total += flush_start.elapsed();
375                        validator_count += 1;
376                        // Counts come from the pre-existence sweep inside the
377                        // flush, not from per-row outcomes (which would
378                        // double-count if we also called `add_outcomes`).
379                        summary.add_outcomes_errors_only(&flush_outcomes);
380                        summary.add_batch(&flush_counts);
381                        drain_pending_dones(&mut pending_dones, &flush_outcomes, &mut on_event);
382                    }
383                }
384                Err(error) => {
385                    // Per-event drop semantics: the adapter's error is either
386                    // a pre-Session header failure (whole file unusable) or a
387                    // mid-session bad-line skip. The validator is not reset
388                    // on either case so subsequent good lines from the same
389                    // file still land.
390                    tracing::debug!(
391                        %error,
392                        "adapter event error (per-line drop by design)"
393                    );
394                    match in_flight.as_mut() {
395                        Some(slot) => {
396                            // Mid-session bad line. Charge one dropped event
397                            // to this session; the bar will render the per-
398                            // session summary at SessionDone time.
399                            slot.dropped_events += 1;
400                            if slot.first_drop_reason.is_none() {
401                                slot.first_drop_reason = Some(error.to_string());
402                            }
403                            summary.dropped_events += 1;
404                        }
405                        None => {
406                            // Pre-Session decode failure: no in-flight
407                            // session to attribute to. This is a whole-file
408                            // skip - surface it as a SessionDone with
409                            // session_id=None and status=Skipped.
410                            summary.skipped_files += 1;
411                            on_event(SyncEvent::SessionDone(SessionOutcome {
412                                project: None,
413                                session_id: None,
414                                messages: 0,
415                                status: SyncStatus::Skipped {
416                                    reason: error.to_string(),
417                                },
418                            }));
419                        }
420                    }
421                }
422            }
423        }
424
425        if let Some(prev) = in_flight.take() {
426            pending_dones.push_back(PendingDone {
427                project: prev.project,
428                session_id: prev.session_id,
429                messages: prev.messages,
430                dropped_events: prev.dropped_events,
431                first_drop_reason: prev.first_drop_reason,
432                session_index: prev.session_index,
433            });
434        }
435        if validator.pending_substreams() > 0 {
436            on_event(SyncEvent::Flushing {
437                pending: validator.pending_substreams(),
438            });
439        }
440        let validator_start = std::time::Instant::now();
441        let (final_outcomes, final_counts) = validator.finish(store).await?;
442        validator_total += validator_start.elapsed();
443        validator_count += 1;
444        summary.add_outcomes_errors_only(&final_outcomes);
445        summary.add_batch(&final_counts);
446        drain_pending_dones(&mut pending_dones, &final_outcomes, &mut on_event);
447
448        summary.truncated_values = crate::adapter::extract::truncated_values_count()
449            .saturating_sub(truncations_before) as usize;
450
451        let total = run_started.elapsed();
452        let other = total
453            .saturating_sub(decode_total)
454            .saturating_sub(validator_total);
455        tracing::info!(
456            target: "pond::perf",
457            total_ms = total.as_millis() as u64,
458            decode_ms = decode_total.as_millis() as u64,
459            validator_ms = validator_total.as_millis() as u64,
460            other_ms = other.as_millis() as u64,
461            decode_calls = decode_count,
462            validator_calls = validator_count,
463            rows_inserted = summary.inserted as u64,
464            rows_matched = summary.matched as u64,
465            dropped_events = summary.dropped_events as u64,
466            dropped_sessions = summary.dropped_sessions as u64,
467            skipped_files = summary.skipped_files as u64,
468            skipped_fresh = summary.skipped_fresh as u64,
469            skipped_superseded = summary.skipped_superseded as u64,
470            truncated_values = summary.truncated_values as u64,
471            "ingest_adapter complete"
472        );
473        Ok(summary)
474    }
475
476    /// Match the validator's flush outcomes back to the queued PendingDone
477    /// entries (FIFO; `RowOutcome.index` aligns with `PendingDone.session_index`).
478    /// Each matched PendingDone yields one `SyncEvent::SessionDone`. The queue
479    /// drains in order; if outcomes are missing for any (shouldn't happen with
480    /// a well-formed validator path), the SessionDone is emitted as Ok using
481    /// only the adapter-side drop count.
482    fn drain_pending_dones<F>(
483        queue: &mut std::collections::VecDeque<PendingDone>,
484        outcomes: &[RowOutcome],
485        on_event: &mut F,
486    ) where
487        F: FnMut(SyncEvent),
488    {
489        // Index session-kind outcomes by their `index` value so we can look
490        // them up by `session_index` regardless of relative ordering.
491        let mut session_outcome_by_index: std::collections::HashMap<usize, &RowOutcome> =
492            std::collections::HashMap::new();
493        for outcome in outcomes {
494            if outcome.kind == "session" {
495                session_outcome_by_index.insert(outcome.index, outcome);
496            }
497        }
498
499        while let Some(done) = queue.pop_front() {
500            let session_outcome = session_outcome_by_index.get(&done.session_index).copied();
501            let rejection_reason = session_outcome.and_then(|outcome| {
502                if matches!(outcome.status, OutcomeStatus::Error) {
503                    Some(
504                        outcome
505                            .error
506                            .as_ref()
507                            .map(|err| err.message.clone())
508                            .unwrap_or_else(|| "session-level rejection".to_owned()),
509                    )
510                } else {
511                    None
512                }
513            });
514            let status = if let Some(reason) = rejection_reason {
515                SyncStatus::Rejected { reason }
516            } else if done.dropped_events > 0 {
517                SyncStatus::Partial {
518                    dropped_events: done.dropped_events,
519                    first_drop_reason: done.first_drop_reason,
520                }
521            } else {
522                SyncStatus::Ok
523            };
524            on_event(SyncEvent::SessionDone(SessionOutcome {
525                project: done.project,
526                session_id: Some(done.session_id),
527                messages: done.messages,
528                status,
529            }));
530        }
531    }
532
533    /// The `pond_ingest` wire handler (spec.md#protocol): validate the transport
534    /// envelope, then drive the event batch through [`ingest_events`]. Transport
535    /// failures (bad protocol, unknown namespace, empty or oversized batch) fail
536    /// the whole request via the spec.md#protocol; per-event failures land
537    /// in the response's `results[]` with `status: "error"`.
538    pub async fn pond_ingest(store: &Store, request: IngestRequest) -> IngestEnvelope {
539        if let Err(envelope) = validate_protocol(request.protocol_version) {
540            return IngestEnvelope::Error(envelope);
541        }
542        if let Err(envelope) = super::resolve_namespace(request.namespace.as_deref()) {
543            return IngestEnvelope::Error(envelope);
544        }
545        if request.events.is_empty() {
546            return IngestEnvelope::Error(map_error(crate::Error::validation_field(
547                "events must be a non-empty array",
548                "events",
549                Some(serde_json::json!([])),
550                Some("non-empty array".to_owned()),
551            )));
552        }
553        if request.events.len() > MAX_INGEST_EVENTS {
554            return IngestEnvelope::Error(map_error(crate::Error::validation_field(
555                format!("ingest batch exceeds the event cap: at most {MAX_INGEST_EVENTS} events"),
556                "events",
557                Some(serde_json::json!(request.events.len())),
558                Some(format!("at most {MAX_INGEST_EVENTS} events")),
559            )));
560        }
561
562        match ingest_events(store, request.events).await {
563            Ok(outcomes) => {
564                let mut accepted = 0;
565                let mut rejected = 0;
566                for outcome in &outcomes {
567                    match outcome.status {
568                        OutcomeStatus::Inserted | OutcomeStatus::Matched => accepted += 1,
569                        OutcomeStatus::Error => rejected += 1,
570                    }
571                }
572                let results = outcomes
573                    .into_iter()
574                    .map(outcome_to_result)
575                    .collect::<Vec<_>>();
576                IngestEnvelope::Success(IngestResponse {
577                    accepted,
578                    rejected,
579                    results,
580                })
581            }
582            Err(failure) => IngestEnvelope::Error(map_storage(failure)),
583        }
584    }
585
586    /// Drive a flat event batch through [`IngestValidator`], returning per-row
587    /// outcomes in input-array order. A substream that fails validation has
588    /// every one of its events tagged with [`OutcomeStatus::Error`] (the
589    /// offending event and any others in the same substream); ingest of later
590    /// sessions in the batch continues (spec.md#protocol).
591    pub async fn ingest_events(store: &Store, events: Vec<IngestEvent>) -> Result<Vec<RowOutcome>> {
592        let mut validator = IngestValidator::default();
593        let mut outcomes = Vec::with_capacity(events.len());
594        for (index, event) in events.into_iter().enumerate() {
595            let mut chunk = validator.push(store, index, event).await?;
596            outcomes.append(&mut chunk);
597        }
598        // HTTP wire path keeps using per-row outcomes for `IngestResult`;
599        // the batch counts are CLI-only.
600        let (mut tail, _counts) = validator.finish(store).await?;
601        outcomes.append(&mut tail);
602        outcomes.sort_by_key(|outcome| outcome.index);
603        Ok(outcomes)
604    }
605
606    fn outcome_to_result(outcome: RowOutcome) -> IngestResult {
607        let (status, error) = match (outcome.status, outcome.error) {
608            (OutcomeStatus::Inserted, _) => (IngestStatus::Inserted, None),
609            (OutcomeStatus::Matched, _) => (IngestStatus::Matched, None),
610            (OutcomeStatus::Error, error) => {
611                let body = error
612                    .map(|err| {
613                        let mut details = serde_json::Map::new();
614                        if let Some(field) = err.field {
615                            details.insert("field".to_owned(), serde_json::json!(field));
616                        }
617                        if let Some(reason) = err.reason {
618                            details.insert("reason".to_owned(), serde_json::json!(reason));
619                        }
620                        ErrorBody {
621                            code: ErrorCode::ValidationFailed,
622                            message: err.message,
623                            details: serde_json::Value::Object(details),
624                        }
625                    })
626                    .unwrap_or_else(|| ErrorBody {
627                        code: ErrorCode::ValidationFailed,
628                        message: "ingest failed".to_owned(),
629                        details: serde_json::json!({}),
630                    });
631                (IngestStatus::Error, Some(body))
632            }
633        };
634        IngestResult {
635            index: outcome.index,
636            kind: outcome.kind.to_owned(),
637            pk: outcome.pk,
638            status,
639            error,
640        }
641    }
642}
643
644pub use crate::sessions::{IngestEvent, IngestSummary, IngestValidator, search_text};
645pub use ingest_handler::{
646    MAX_INGEST_EVENTS, SessionOutcome, SyncEvent, SyncStatus, ingest_adapter, ingest_events,
647    pond_ingest,
648};
649
650mod export_handler {
651    //! `pond_export` (spec.md#protocol): walk every session in the store and
652    //! emit its canonical event stream as JSONL - one `IngestEvent` per line.
653    //! The output is byte-identical with what `pond ingest` / `pond_ingest`
654    //! accepts on input, so `export | ingest` is a portable backup loop.
655    //! Sessions are emitted in lexicographic id order; within each session,
656    //! messages run in `(timestamp, message_id)` order and each message's
657    //! parts immediately follow in `ordinal` order. Matches the
658    //! spec.md#adapter-integrity-event-ordering ordering contract so the output
659    //! re-imports without re-ordering.
660
661    use anyhow::{Context, Result};
662    use tokio::io::{AsyncWrite, AsyncWriteExt};
663
664    use crate::sessions::{IngestEvent, Store};
665
666    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
667    pub struct ExportSummary {
668        pub sessions: usize,
669        pub messages: usize,
670        pub parts: usize,
671    }
672
673    pub async fn pond_export<W>(
674        store: &Store,
675        session_filter: Option<&str>,
676        writer: &mut W,
677    ) -> Result<ExportSummary>
678    where
679        W: AsyncWrite + Unpin,
680    {
681        let mut session_ids = match session_filter {
682            Some(id) => vec![id.to_owned()],
683            None => store.session_ids().await?,
684        };
685        session_ids.sort();
686
687        let mut summary = ExportSummary::default();
688        for session_id in session_ids {
689            let Some(stored) = store
690                .get_session(&session_id)
691                .await
692                .with_context(|| format!("export: failed to load session {session_id}"))?
693            else {
694                if session_filter.is_some() {
695                    anyhow::bail!("export: session not found: {session_id}");
696                }
697                continue;
698            };
699            write_event(writer, &IngestEvent::Session(stored.session)).await?;
700            summary.sessions += 1;
701            for message_with_parts in stored.messages {
702                write_event(writer, &IngestEvent::Message(message_with_parts.message)).await?;
703                summary.messages += 1;
704                for part in message_with_parts.parts {
705                    write_event(writer, &IngestEvent::Part(part)).await?;
706                    summary.parts += 1;
707                }
708            }
709        }
710        writer.flush().await.context("export: flush failed")?;
711        Ok(summary)
712    }
713
714    async fn write_event<W>(writer: &mut W, event: &IngestEvent) -> Result<()>
715    where
716        W: AsyncWrite + Unpin,
717    {
718        let line = serde_json::to_string(event).context("export: serialize event")?;
719        writer
720            .write_all(line.as_bytes())
721            .await
722            .context("export: write event")?;
723        writer
724            .write_all(b"\n")
725            .await
726            .context("export: write newline")?;
727        Ok(())
728    }
729}
730
731pub use export_handler::{ExportSummary, pond_export};
732
733mod restore_handler {
734    //! `restore_lineage` (spec.md#adapter-lineage-complete-restore): collect the named
735    //! session plus its direct subagent children for the `pond copy` restore
736    //! path. The spawn graph is one level deep; a collected
737    //! child that is itself a parent means a deeper graph, which is a typed
738    //! error - never a silently flattened restore.
739
740    use anyhow::{Context, Result, bail};
741
742    use crate::sessions::{SessionWithMessages, Store};
743
744    pub async fn restore_lineage(
745        store: &Store,
746        session_id: &str,
747    ) -> Result<Vec<SessionWithMessages>> {
748        let Some(parent) = store.get_session(session_id).await? else {
749            bail!("export: session not found: {session_id}");
750        };
751        let mut sessions = vec![parent];
752        for child in store.child_sessions(session_id).await? {
753            if !store.child_sessions(&child.id).await?.is_empty() {
754                bail!(
755                    "adapter-lineage-complete-restore supports one subagent level; session {} has child sessions",
756                    child.id
757                );
758            }
759            let child_id = child.id;
760            let stored = store
761                .get_session(&child_id)
762                .await?
763                .with_context(|| format!("export: child session disappeared: {child_id}"))?;
764            sessions.push(stored);
765        }
766        Ok(sessions)
767    }
768}
769
770pub use restore_handler::restore_lineage;
771
772mod get_handler {
773    use crate::{
774        sessions::{GetLookup, MessageViewParams, RetrievedMessage, SessionViewParams, Store},
775        wire::{
776            GetEnvelope, GetMessageRequest, GetResponse, GetResult, GetSession, GetSessionRequest,
777            MessageView, PartSummary, ResponsePart, validate_protocol,
778        },
779    };
780
781    use super::{map_error, map_storage};
782
783    /// Project canonical retrieval data into the conversational response DTO:
784    /// `text`/`content` plus one-line part summaries. Full part bodies are never
785    /// inlined here - they ride `GetResult::Message.target_parts`, reached by
786    /// `message_id` scope.
787    fn to_message_view(message: RetrievedMessage) -> MessageView {
788        let parts_summary = message
789            .parts
790            .iter()
791            .filter_map(|part| PartSummary::for_kind(&part.kind))
792            .collect();
793        MessageView {
794            id: message.id,
795            role: message.role,
796            timestamp: message.timestamp,
797            text: message.text,
798            content: message.content,
799            parts_summary,
800        }
801    }
802
803    /// Server response budget, sized to the declared
804    /// `_meta["anthropic/maxResultSizeChars"]` cap (~200KB / ~50k tokens). The
805    /// server stops adding messages (or parts) when the next would exceed it;
806    /// `before_remaining` / `after_remaining` (session) and
807    /// `target_parts_remaining` (message) then signal pagination.
808    const BUDGET_BYTES: usize = 200_000;
809
810    pub async fn pond_get_session(store: &Store, request: GetSessionRequest) -> GetEnvelope {
811        if let Err(error) = validate_protocol(request.protocol_version) {
812            return GetEnvelope::Error(error);
813        }
814        if let Err(envelope) = super::resolve_namespace(request.namespace.as_deref()) {
815            return GetEnvelope::Error(envelope);
816        }
817        match session_result(store, &request).await {
818            Ok(response) => GetEnvelope::Success(response),
819            Err(error) => GetEnvelope::Error(error),
820        }
821    }
822
823    pub async fn pond_get_message(store: &Store, request: GetMessageRequest) -> GetEnvelope {
824        if let Err(error) = validate_protocol(request.protocol_version) {
825            return GetEnvelope::Error(error);
826        }
827        if let Err(envelope) = super::resolve_namespace(request.namespace.as_deref()) {
828            return GetEnvelope::Error(envelope);
829        }
830        match message_result(store, &request).await {
831            Ok(response) => GetEnvelope::Success(response),
832            Err(error) => GetEnvelope::Error(error),
833        }
834    }
835
836    /// Map a stale/unknown pagination anchor to a `validation_failed` naming
837    /// the field and the fix (spec.md#protocol).
838    fn unknown_anchor(field: &str, value: Option<&str>) -> crate::wire::ErrorEnvelope {
839        map_error(crate::Error::validation_field(
840            format!("{field} not found (stale or mistyped pagination anchor)"),
841            field,
842            value.map(|v| serde_json::Value::String(v.to_owned())),
843            Some("a message id from a prior page of this read".to_owned()),
844        ))
845    }
846
847    async fn session_result(
848        store: &Store,
849        request: &GetSessionRequest,
850    ) -> Result<GetResponse, crate::wire::ErrorEnvelope> {
851        if request.after_message_id.is_some() && request.before_message_id.is_some() {
852            return Err(map_error(crate::Error::validation_field(
853                "after_message_id and before_message_id are mutually exclusive",
854                "before_message_id",
855                request
856                    .before_message_id
857                    .clone()
858                    .map(serde_json::Value::String),
859                Some("set only one pagination anchor".to_owned()),
860            )));
861        }
862        let params = SessionViewParams {
863            at_message_id: None,
864            after_message_id: request.after_message_id.as_deref(),
865            before_message_id: request.before_message_id.as_deref(),
866            limit: request.limit,
867            budget_bytes: BUDGET_BYTES,
868            session_from: request.from,
869        };
870        let (session_id, resolved_from) = match store
871            .session_view(&request.id, params.clone())
872            .await
873            .map_err(map_storage)?
874        {
875            GetLookup::NotFound => {
876                // The id may be a message id: resolve it up to its parent
877                // session (intent is unambiguous - the caller asked for a
878                // session) and anchor the page at that message.
879                match store
880                    .session_id_for_message(&request.id)
881                    .await
882                    .map_err(map_storage)?
883                {
884                    Some(session_id) => (session_id, Some(request.id.clone())),
885                    None => {
886                        return Err(map_error(crate::Error::not_found(
887                            "session",
888                            serde_json::json!(request.id),
889                            format!(
890                                "session not found: {} (not a message id either)",
891                                request.id
892                            ),
893                        )));
894                    }
895                }
896            }
897            GetLookup::UnknownAnchor => {
898                let (field, value) = match &request.after_message_id {
899                    Some(value) => ("after_message_id", Some(value.as_str())),
900                    None => ("before_message_id", request.before_message_id.as_deref()),
901                };
902                return Err(unknown_anchor(field, value));
903            }
904            GetLookup::Found(view) => {
905                return Ok(session_response(view, None));
906            }
907        };
908        let anchored = SessionViewParams {
909            at_message_id: resolved_from.as_deref(),
910            ..params
911        };
912        match store
913            .session_view(&session_id, anchored)
914            .await
915            .map_err(map_storage)?
916        {
917            GetLookup::Found(view) => Ok(session_response(view, resolved_from)),
918            // The parent session of a stored message always exists; anchor
919            // misses degrade inside session_view, never to UnknownAnchor.
920            GetLookup::NotFound | GetLookup::UnknownAnchor => Err(map_error(
921                crate::Error::internal("resolved parent session lookup failed"),
922            )),
923        }
924    }
925
926    fn session_response(
927        view: crate::sessions::SessionPage,
928        resolved_from_message_id: Option<String>,
929    ) -> GetResponse {
930        GetResponse {
931            session: GetSession::from_session(&view.session),
932            result: GetResult::Session {
933                messages: view.messages.into_iter().map(to_message_view).collect(),
934                before_remaining: view.before_remaining,
935                after_remaining: view.after_remaining,
936                resolved_from_message_id,
937            },
938        }
939    }
940
941    async fn message_result(
942        store: &Store,
943        request: &GetMessageRequest,
944    ) -> Result<GetResponse, crate::wire::ErrorEnvelope> {
945        let message_id = &request.id;
946        let params = MessageViewParams {
947            context_before: request.context_before,
948            context_after: request.context_after,
949            budget_bytes: BUDGET_BYTES,
950        };
951        let view = match store
952            .message_view(message_id, params)
953            .await
954            .map_err(map_storage)?
955        {
956            GetLookup::NotFound => {
957                // A session id cannot resolve to one message (which one?), so
958                // teach at the failure point instead of resolving.
959                if store
960                    .find_session(message_id)
961                    .await
962                    .map_err(map_storage)?
963                    .is_some()
964                {
965                    return Err(map_error(crate::Error::validation_field(
966                        format!(
967                            "{message_id} is a session id, not a message id - read it with \
968                             pond_get_session (CLI: pond get-session), or pass a message id \
969                             from its transcript"
970                        ),
971                        "id",
972                        Some(serde_json::Value::String(message_id.clone())),
973                        Some("a message id from a search hit or transcript".to_owned()),
974                    )));
975                }
976                return Err(map_error(crate::Error::not_found(
977                    "message",
978                    serde_json::json!(message_id),
979                    format!("message not found: {message_id}"),
980                )));
981            }
982            // message scope has no pagination anchor, so this is unreachable.
983            GetLookup::UnknownAnchor => {
984                return Err(map_error(crate::Error::internal(
985                    "message_view returned UnknownAnchor for an anchorless lookup",
986                )));
987            }
988            GetLookup::Found(view) => view,
989        };
990        // The target's body rides `target_parts` (full); carrying `text`/
991        // `content` on the header too would just duplicate it.
992        let target = MessageView {
993            id: view.target.id,
994            role: view.target.role,
995            timestamp: view.target.timestamp,
996            text: None,
997            content: None,
998            parts_summary: Vec::new(),
999        };
1000        Ok(GetResponse {
1001            session: GetSession::from_session(&view.session),
1002            result: GetResult::Message {
1003                target,
1004                target_parts: view
1005                    .target_parts
1006                    .into_iter()
1007                    .map(ResponsePart::from_part)
1008                    .collect(),
1009                target_parts_remaining: view.target_parts_remaining,
1010                siblings: view.siblings.into_iter().map(to_message_view).collect(),
1011                context_before: request.context_before,
1012                context_after: request.context_after,
1013            },
1014        })
1015    }
1016}
1017
1018pub use get_handler::{pond_get_message, pond_get_session};
1019
1020mod search_handler {
1021    //! The `pond_search` handler: single-arm retrieval at message granularity -
1022    //! `vector` (kNN, default) or `fts` (BM25), chosen per query, no fusion -
1023    //! with filter pushdown and session-grouped responses (spec.md#search).
1024
1025    use crate::{
1026        Clock, SystemClock,
1027        embed::{Embedder, LazyEmbedder, format_query},
1028        sessions::{MessageKey, MessageMeta, SearchHit, Store},
1029        substrate::{Predicate, ScalarValue},
1030        wire::{
1031            ErrorEnvelope, PartSummary, ProjectFilter, Role, SearchEnvelope, SearchFilters,
1032            SearchRequest, SearchResponse, SearchResult, SearchSession, SortBy, validate_protocol,
1033        },
1034    };
1035    use chrono::{DateTime, NaiveDate, Utc};
1036    use std::collections::HashMap;
1037
1038    use super::{map_error, map_storage};
1039
1040    /// Internal retrieval arm. The caller picks per query via the wire `mode`
1041    /// field (`pond search --mode`): `Vector` (default) on meaning, `Fts` on
1042    /// exact whole words. There is no hybrid fusion - one arm per request. A
1043    /// `Vector` request degrades to `Fts` when the store has no embeddings.
1044    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045    pub enum SearchMode {
1046        Fts,
1047        Vector,
1048    }
1049
1050    #[derive(Debug, Clone, PartialEq)]
1051    pub struct SearchPlan {
1052        pub mode: SearchMode,
1053        pub query: String,
1054        /// User filters only. Drives both the arms and `searchable_in_scope`;
1055        /// empty for an unfiltered search so the count reads the FTS `num_docs`
1056        /// stat, not the search_text scan. The subagent exclusion is applied
1057        /// in-memory (`exclude_subagents`), not as a SQL clause, so the arms stay
1058        /// index-only - no `source_agent` materialization on a remote store.
1059        pub filter: Predicate,
1060        pub filters: SearchFilters,
1061        pub sort_by: SortBy,
1062        pub pool: usize,
1063        pub vector_pool: usize,
1064        pub limit: usize,
1065        pub min_score: f64,
1066        /// Drop subagent hits (session id contains `/`) from the arm results in
1067        /// memory - the spec.md#search retrieval default. See `plan_search` for
1068        /// when it is on.
1069        pub exclude_subagents: bool,
1070    }
1071
1072    const LIMIT_CAP: usize = 200;
1073    /// Centered query-windowed body returned on every hit (spec.md#search).
1074    /// Calibrated for the agent-context budget: ~600 code points fits a typical
1075    /// match site without crowding the 10k-token `pond_get_session` page.
1076    const HIT_SNIPPET_CHARS: usize = 600;
1077
1078    /// Recency tiebreaker for `vector` + `relevance` ordering (spec.md#search):
1079    /// an additive bonus of up to [`RECENCY_BOOST_MAGNITUDE`] that decays
1080    /// exponentially over [`RECENCY_BOOST_SCALE_DAYS`]. It is a gentle post-gate
1081    /// nudge so that, among comparably-relevant hits, the more recent
1082    /// conversation wins - it must NOT lift an off-topic recent hit over a
1083    /// strongly-relevant old one.
1084    ///
1085    /// Magnitude is deliberately small. e5 cosines for relevant content cluster
1086    /// tightly (~0.78-0.86), so the typical relevance gap between the right
1087    /// answer and a near-miss is only ~0.02-0.05. The boost must stay below
1088    /// that band or it swamps relevance and tanks recall - measured on
1089    /// ops/search-benchmarks/queries-en.tsv, magnitude 0.1 collapsed
1090    /// Success@3 from 0.33 (no boost) to 0.10; 0.02 keeps it tie-breaking.
1091    const RECENCY_BOOST_MAGNITUDE: f64 = 0.02;
1092    const RECENCY_BOOST_SCALE_DAYS: f64 = 30.0;
1093
1094    /// Run a single-arm search. The caller picks the arm via the wire `mode`
1095    /// field; `vector` degrades to `fts` only when the store has no embeddings.
1096    /// The embedder is `LazyEmbedder`-loaded on the first vector call, so
1097    /// fts-only corpora never pay the model load. The response has no top-level
1098    /// mode field; retriever attribution stays in `explain_search_plan`.
1099    ///
1100    /// Must run on a multi-threaded Tokio runtime: the vector arm embeds the
1101    /// query via `block_in_place`, which panics on a `current_thread` runtime.
1102    pub async fn pond_search(
1103        store: &Store,
1104        embedder: &LazyEmbedder,
1105        request: SearchRequest,
1106        search: &crate::config::SearchConfig,
1107    ) -> SearchEnvelope {
1108        match run_search(store, embedder, request, search, &SystemClock).await {
1109            Ok(response) => SearchEnvelope::Success(response),
1110            Err(envelope) => SearchEnvelope::Error(envelope),
1111        }
1112    }
1113
1114    pub async fn explain_search_plan(
1115        store: &Store,
1116        embedder: &LazyEmbedder,
1117        request: SearchRequest,
1118        search: &crate::config::SearchConfig,
1119    ) -> Result<String, ErrorEnvelope> {
1120        let mut plan = plan_search(request)?;
1121        plan.mode = resolve_effective_mode(store, plan.mode).await?;
1122        let mut out = String::new();
1123        match plan.mode {
1124            SearchMode::Fts => {
1125                let fts = store
1126                    .explain_fts_plan(&plan.query, plan.pool, &plan.filter)
1127                    .await
1128                    .map_err(map_storage)?;
1129                out.push_str("fts:\n");
1130                out.push_str(&fts);
1131                out.push('\n');
1132            }
1133            SearchMode::Vector => {
1134                let backend = load_embedder(embedder).await?;
1135                let vector = embed_query(backend.as_ref(), &plan.query)?;
1136                let vector_plan = store
1137                    .explain_vector_plan(&vector, plan.vector_pool, &plan.filter, Some(search))
1138                    .await
1139                    .map_err(map_storage)?;
1140                out.push_str("vector:\n");
1141                out.push_str(&vector_plan);
1142                out.push('\n');
1143            }
1144        }
1145        Ok(out)
1146    }
1147
1148    async fn run_search(
1149        store: &Store,
1150        embedder: &LazyEmbedder,
1151        request: SearchRequest,
1152        search: &crate::config::SearchConfig,
1153        clock: &dyn Clock,
1154    ) -> Result<SearchResponse, ErrorEnvelope> {
1155        // Per-stage timing for the search hot path. `pond::perf=debug` surfaces
1156        // it; off, each call is a no-op. Cumulative from request start.
1157        let stage_start = std::time::Instant::now();
1158        macro_rules! stage {
1159            ($label:literal) => {
1160                tracing::debug!(
1161                    target: "pond::perf",
1162                    stage = $label,
1163                    elapsed_ms = stage_start.elapsed().as_millis() as u64,
1164                );
1165            };
1166        }
1167        let mut plan = plan_search(request)?;
1168
1169        // A `vector` request degrades to `fts` when the store has no
1170        // embeddings (nothing to match against); `fts` stays `fts`.
1171        plan.mode = resolve_effective_mode(store, plan.mode).await?;
1172        stage!("resolve_mode");
1173
1174        // `min_score` gates raw cosine, so it is meaningful only for `vector`.
1175        // BM25 is unbounded and not comparable across queries; reject a
1176        // non-zero floor on `fts` rather than silently ignore it.
1177        if matches!(plan.mode, SearchMode::Fts) && plan.min_score > 0.0 {
1178            return Err(map_error(crate::Error::validation_field(
1179                "min_score is not supported in fts mode (BM25 scores are unbounded \
1180                 and not comparable across queries); use vector mode or drop min_score",
1181                "min_score",
1182                Some(serde_json::json!(plan.min_score)),
1183                Some("0 in fts mode".to_owned()),
1184            )));
1185        }
1186
1187        // The scope count (spec.md#search-absence-honesty: how many searchable
1188        // messages the filters left in scope, so "no relevant hits" is
1189        // distinguishable from "my filters excluded everything") overlaps
1190        // retrieval instead of preceding it - serialized, its count_rows
1191        // round-trip would be pure added latency on every search, and round
1192        // trips are what object-store backends pay for.
1193        let candidates_fut = async {
1194            match plan.mode {
1195                SearchMode::Fts => {
1196                    let mut hits = store
1197                        .fts_search(&plan.query, plan.pool, &plan.filter)
1198                        .await
1199                        .map_err(map_storage)?;
1200                    retain_non_subagents(&mut hits, plan.exclude_subagents);
1201                    Ok(normalize_fts(hits))
1202                }
1203                // Vector arm (default): embed `plan.query` and run kNN. The
1204                // hit score is raw cosine similarity (`1 - distance`), which
1205                // `min_score` gates and the recency boost later tweaks.
1206                SearchMode::Vector => {
1207                    let backend = load_embedder(embedder).await?;
1208                    let vector = embed_query(backend.as_ref(), &plan.query)?;
1209                    stage!("embed_query(+model)");
1210                    let mut vector_raw = store
1211                        .vector_search(&vector, plan.vector_pool, &plan.filter, Some(search))
1212                        .await
1213                        .map_err(map_storage)?;
1214                    stage!("vector_search");
1215                    retain_non_subagents(&mut vector_raw, plan.exclude_subagents);
1216                    Ok(normalize_vector(vector_raw))
1217                }
1218            }
1219        };
1220        let scope_fut = async {
1221            store
1222                .searchable_in_scope(&plan.filter)
1223                .await
1224                .map_err(map_storage)
1225        };
1226        let (candidates, searchable_in_scope) = tokio::try_join!(candidates_fut, scope_fut)?;
1227        stage!("arms+scope joined");
1228
1229        if candidates.is_empty() {
1230            return Ok(empty_response(searchable_in_scope));
1231        }
1232
1233        // Reduce to the hits the response will actually emit *before* any S3
1234        // hydration, so the metadata/parts/count fetches below are sized to the
1235        // top-`limit` sessions' candidates, not the full arm pool (~150). No
1236        // per-session cap: the surviving candidates are already bounded by the
1237        // arm pool, and the byte budget bounds the rendered output.
1238        let (mut selected, mut total_sessions, mut matched_total) =
1239            select_top_hits(candidates, plan.min_score, plan.limit);
1240        if selected.is_empty() {
1241            return Ok(empty_response(searchable_in_scope));
1242        }
1243
1244        // Hydrate hit metadata (timestamp, role, project, preview source) from
1245        // the `messages` table - the retrievers return only keys (+ rowids). When
1246        // every selected hit carries a stable rowid (row meta map loaded), take
1247        // exactly those rows by id - no `IN x IN` cross-product scan and none of
1248        // its scalar-index page reads. Otherwise fall back to the keyed IN-scan.
1249        let rowids: Option<Vec<u64>> = selected.iter().map(|candidate| candidate.rowid).collect();
1250        let mut metas = match &rowids {
1251            Some(rowids) => store
1252                .message_metas_by_rowids(rowids)
1253                .await
1254                .map_err(map_storage)?,
1255            None => {
1256                let keys = selected
1257                    .iter()
1258                    .map(|candidate| MessageKey {
1259                        session_id: candidate.session_id.clone(),
1260                        message_id: candidate.message_id.clone(),
1261                    })
1262                    .collect::<Vec<_>>();
1263                store
1264                    .message_metas_by_keys(&keys)
1265                    .await
1266                    .map_err(map_storage)?
1267            }
1268        };
1269        stage!("metas_hydrated");
1270
1271        // Authoritative subagent exclusion (spec.md#search). `retain_non_subagents`
1272        // above is the cheap pre-hydration drop, but it keys on a `/` in the
1273        // composite session_id (claude-code-shaped subagent ids) and so misses
1274        // harnesses whose subagent sessions carry a plain id and encode
1275        // subagent-ness only in a `/`-subpath `source_agent` (openclaw). The
1276        // hydrated meta already carries `source_agent` for display, so enforce
1277        // the rule here at zero extra requests. Mirror the id-based retain's
1278        // drop-before-count by subtracting the removed hits and their now-empty
1279        // session roots from the pool-derived counters. Rows outside this
1280        // top-`limit` hydration window keep `source_agent` unmaterialized, so a
1281        // subagent match beyond the window can still sit in `matched_total` (the
1282        // un-hydrated pool tail) - the accepted cost of not re-adding the
1283        // `source_agent` materialization the S3 request-law removed.
1284        if plan.exclude_subagents {
1285            let excluded: std::collections::HashSet<String> = metas
1286                .iter()
1287                .filter(|meta| meta.source_agent.contains('/'))
1288                .map(|meta| meta.session_id.clone())
1289                .collect();
1290            if !excluded.is_empty() {
1291                matched_total = matched_total.saturating_sub(
1292                    selected
1293                        .iter()
1294                        .filter(|candidate| excluded.contains(&candidate.session_id))
1295                        .count(),
1296                );
1297                total_sessions = total_sessions.saturating_sub(
1298                    selected
1299                        .iter()
1300                        .map(|candidate| session_root(&candidate.session_id))
1301                        .filter(|root| excluded.contains(*root))
1302                        .collect::<std::collections::HashSet<_>>()
1303                        .len(),
1304                );
1305                selected.retain(|candidate| !excluded.contains(&candidate.session_id));
1306                metas.retain(|meta| !meta.source_agent.contains('/'));
1307                if selected.is_empty() {
1308                    return Ok(empty_response(searchable_in_scope));
1309                }
1310            }
1311        }
1312
1313        let meta_index = metas
1314            .iter()
1315            .map(|meta| ((meta.session_id.as_str(), meta.message_id.as_str()), meta))
1316            .collect::<std::collections::HashMap<_, _>>();
1317
1318        // Final ordering score (spec.md#search). `relevance` ranks vector hits
1319        // by cosine plus a gentle recency tiebreaker and fts hits by BM25;
1320        // `recency` ranks both strictly newest-first (the timestamp itself is
1321        // the key). The boost is post-gate (the min_score cosine gate already
1322        // ran in select_top_hits), so it only reorders comparably-relevant hits.
1323        let now = clock.now();
1324        let mut scored = Vec::with_capacity(selected.len());
1325        for candidate in selected {
1326            let Some(meta) =
1327                meta_index.get(&(candidate.session_id.as_str(), candidate.message_id.as_str()))
1328            else {
1329                continue;
1330            };
1331            let order_score = match (plan.sort_by, plan.mode) {
1332                (SortBy::Recency, _) => recency_rank(meta.timestamp),
1333                (SortBy::Relevance, SearchMode::Vector) => {
1334                    candidate.base_score + recency_boost(meta.timestamp, now)
1335                }
1336                (SortBy::Relevance, SearchMode::Fts) => candidate.base_score,
1337            };
1338            scored.push(ScoredHit {
1339                meta: (*meta).clone(),
1340                display_score: candidate.base_score,
1341                order_score,
1342            });
1343        }
1344        scored.sort_by(|left, right| {
1345            right
1346                .order_score
1347                .partial_cmp(&left.order_score)
1348                .unwrap_or(std::cmp::Ordering::Equal)
1349                .then_with(|| left.meta.session_id.cmp(&right.meta.session_id))
1350                .then_with(|| left.meta.message_id.cmp(&right.meta.message_id))
1351        });
1352
1353        let sessions = build_sessions(store, &scored, &plan.query).await?;
1354        stage!("build_sessions(parts)");
1355        page_sessions(
1356            sessions,
1357            matched_total,
1358            total_sessions,
1359            searchable_in_scope,
1360            &plan,
1361        )
1362    }
1363
1364    /// Additive recency tiebreaker for `vector` + `relevance` ordering: a bonus
1365    /// of up to [`RECENCY_BOOST_MAGNITUDE`] decaying exponentially over
1366    /// [`RECENCY_BOOST_SCALE_DAYS`]. Future timestamps (clock skew) clamp to age
1367    /// 0 -> full bonus.
1368    fn recency_boost(ts: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
1369        let age_days = (now - ts).num_seconds().max(0) as f64 / 86_400.0;
1370        RECENCY_BOOST_MAGNITUDE * (-age_days / RECENCY_BOOST_SCALE_DAYS).exp()
1371    }
1372
1373    /// Ordering key for `sort_by = recency`: epoch seconds, so a plain
1374    /// descending sort puts the newest message first.
1375    fn recency_rank(ts: DateTime<Utc>) -> f64 {
1376        ts.timestamp() as f64
1377    }
1378
1379    /// Pick the effective retrieval arm. `fts` always stays `fts`. `vector`
1380    /// degrades to `fts` when the store has no embeddings - there is nothing to
1381    /// match against (`has_embeddings()` is the only gate).
1382    async fn resolve_effective_mode(
1383        store: &Store,
1384        requested: SearchMode,
1385    ) -> Result<SearchMode, ErrorEnvelope> {
1386        if matches!(requested, SearchMode::Fts) {
1387            return Ok(SearchMode::Fts);
1388        }
1389        let has = store.has_embeddings().await.map_err(map_storage)?;
1390        Ok(if has {
1391            SearchMode::Vector
1392        } else {
1393            SearchMode::Fts
1394        })
1395    }
1396
1397    /// Materialize the lazy embedder on the first vector branch that needs it.
1398    /// Wraps the load error in an Internal envelope - candle/Metal load failure
1399    /// is a server-side problem, not a caller error.
1400    async fn load_embedder(
1401        embedder: &LazyEmbedder,
1402    ) -> Result<std::sync::Arc<dyn Embedder>, ErrorEnvelope> {
1403        embedder.get().await.map_err(|error| {
1404            map_error(crate::Error::internal(format!(
1405                "embedder load failed: {error}"
1406            )))
1407        })
1408    }
1409
1410    pub fn plan_search(request: SearchRequest) -> Result<SearchPlan, ErrorEnvelope> {
1411        validate_protocol(request.protocol_version)?;
1412
1413        let _ns = super::resolve_namespace(request.namespace.as_deref())?;
1414
1415        let mode = match request.mode {
1416            crate::wire::SearchModeWire::Fts => SearchMode::Fts,
1417            crate::wire::SearchModeWire::Vector => SearchMode::Vector,
1418        };
1419        let sort_by = request.sort_by;
1420        let filters = request.filters;
1421        let query = request.query.trim().to_owned();
1422        if query.is_empty() {
1423            return Err(map_error(crate::Error::validation_field(
1424                "query must be non-empty after trim",
1425                "query",
1426                Some(serde_json::json!(request.query)),
1427                Some("non-empty string after trim".to_owned()),
1428            )));
1429        }
1430        if request.limit == 0 {
1431            return Err(map_error(crate::Error::validation_field(
1432                "limit must be at least 1",
1433                "limit",
1434                Some(serde_json::json!(request.limit)),
1435                Some("integer >= 1".to_owned()),
1436            )));
1437        }
1438        let limit = request.limit.min(LIMIT_CAP);
1439        let min_score = filters.min_score;
1440        let filter = build_scope_filter(&filters)?;
1441        let exclude_subagents = default_excludes_subagents(&filters);
1442        // Retriever candidate pool: wider than `limit` so grouping and the
1443        // recency reorder have material to work with. When excluding subagents
1444        // in-memory, over-fetch by half (subagents are ~30% of the corpus) so
1445        // ~`pool` non-subagent candidates survive the drop.
1446        let mut pool = limit.saturating_mul(5).max(50);
1447        let mut vector_pool = pool.saturating_mul(2);
1448        if exclude_subagents {
1449            pool = pool.saturating_mul(3) / 2;
1450            vector_pool = vector_pool.saturating_mul(3) / 2;
1451        }
1452        Ok(SearchPlan {
1453            mode,
1454            query,
1455            filter,
1456            filters,
1457            sort_by,
1458            pool,
1459            vector_pool,
1460            limit,
1461            min_score,
1462            exclude_subagents,
1463        })
1464    }
1465
1466    /// Conversation root for grouping. The Claude Code adapter
1467    /// stores sub-agent sessions under ids of the form `<parent-uuid>/agent-<id>`;
1468    /// stripping at the first `/` yields the user-facing conversation root. Other
1469    /// adapters (codex, etc.) use ids without `/` and pass through unchanged.
1470    fn session_root(session_id: &str) -> &str {
1471        match session_id.find('/') {
1472            Some(idx) => &session_id[..idx],
1473            None => session_id,
1474        }
1475    }
1476
1477    /// Early, pre-hydration half of the subagent exclusion: drop hits whose
1478    /// composite session id carries a `/` (claude-code-shaped `<parent>/agent-x`
1479    /// ids, the same marker `session_root` splits on). This is the cheap path -
1480    /// it needs only the retriever's keys, no `messages` read. It is NOT
1481    /// authoritative on its own: a harness whose subagent sessions have plain
1482    /// ids and mark subagent-ness only via a `/`-subpath `source_agent`
1483    /// (openclaw) slips through here and is caught by the authoritative
1484    /// `source_agent`-subpath check at the hydrated-meta stage in `pond_search`.
1485    /// The two together replace the old `NOT source_agent LIKE` SQL prefilter,
1486    /// which forced a `source_agent` materialization that cost scattered GETs on
1487    /// a remote store.
1488    fn retain_non_subagents(hits: &mut Vec<SearchHit>, exclude: bool) {
1489        if exclude {
1490            hits.retain(|hit| !hit.key.session_id.contains('/'));
1491        }
1492    }
1493
1494    /// Minimum query-term length considered "informative" for snippet
1495    /// anchoring. Shorter terms ("how", "the", "is", "my", "at") attract the
1496    /// `.min()` anchor to offset-near-0 because they occur very early in any
1497    /// text, masking the real match site.
1498    const ANCHOR_MIN_TERM_CHARS: usize = 4;
1499
1500    /// Build a hit's `text` payload (spec.md#search): the message body when
1501    /// it fits within the snippet window, otherwise a query-windowed slice
1502    /// centered on the first informative term. Bounded for the agent-context
1503    /// budget; callers fetch the full body via `pond_get_message`.
1504    pub fn hit_payload(text: &str, query: &str) -> String {
1505        let chars_len = text.chars().count();
1506        if chars_len <= HIT_SNIPPET_CHARS {
1507            return text.to_owned();
1508        }
1509        query_snippet(text, query)
1510    }
1511
1512    /// A snippet windowed around the first informative query term found in
1513    /// `text`, capped at [`HIT_SNIPPET_CHARS`] code points. Falls back to the
1514    /// text head when no term matches.
1515    ///
1516    /// Terms shorter than [`ANCHOR_MIN_TERM_CHARS`] are excluded from anchor
1517    /// selection because they pull the window to offset-0 (a snippet audit on
1518    /// the live corpus found ~25-30% of conversational queries had their
1519    /// anchor degraded by short stop-word-like terms like "how", "the", "my").
1520    /// If every term is short, the filter is bypassed.
1521    ///
1522    /// TODO(snippet-anchor): reassess for vector-only hits (paraphrase queries
1523    /// where no literal term matches): the fallback to offset-0 is OK but not
1524    /// great. Possible upgrades: ngram match overlap, or
1525    /// skip-window-around-most-distinctive-substring. See snippet audit in
1526    /// tier-0 findings.
1527    fn query_snippet(text: &str, query: &str) -> String {
1528        let lower_text = text.to_lowercase();
1529        let terms: Vec<String> = query
1530            .split_whitespace()
1531            .filter(|term| !term.is_empty())
1532            .map(str::to_lowercase)
1533            .collect();
1534        let any_informative = terms
1535            .iter()
1536            .any(|term| term.chars().count() >= ANCHOR_MIN_TERM_CHARS);
1537        let hit = terms
1538            .iter()
1539            .filter(|term| !any_informative || term.chars().count() >= ANCHOR_MIN_TERM_CHARS)
1540            .filter_map(|term| lower_text.find(term.as_str()))
1541            .min();
1542        let chars: Vec<char> = text.chars().collect();
1543        // `find` returned a byte offset into the lowercased copy; index that
1544        // copy, not `text` - lowercasing can change byte length, so the offset
1545        // is not necessarily a valid char boundary in the original.
1546        let center = hit
1547            .map(|byte| lower_text[..byte].chars().count())
1548            .unwrap_or(0);
1549        let half = HIT_SNIPPET_CHARS / 2;
1550        let start = center.saturating_sub(half);
1551        let end = (start + HIT_SNIPPET_CHARS).min(chars.len());
1552        let start = end.saturating_sub(HIT_SNIPPET_CHARS);
1553        // Truncation markers carry the omitted-char counts so the reader knows
1554        // this is a windowed slice and roughly how much it's missing. The fetch
1555        // verb is left to the transcript's key line (surface-specific: `pond_get_message`
1556        // for MCP, `pond get-message` for the CLI); naming it here would be
1557        // redundant and wrong on one surface.
1558        let mut snippet = String::new();
1559        if start > 0 {
1560            snippet.push_str(&format!("[{start} chars before] "));
1561        }
1562        snippet.extend(&chars[start..end]);
1563        if end < chars.len() {
1564            snippet.push_str(&format!(" [+{} more chars]", chars.len() - end));
1565        }
1566        snippet
1567    }
1568
1569    struct Candidate {
1570        rowid: Option<u64>,
1571        session_id: String,
1572        message_id: String,
1573        base_score: f64,
1574    }
1575
1576    struct ScoredHit {
1577        meta: MessageMeta,
1578        /// Shown to the caller: raw cosine (vector) or pool-normalized BM25
1579        /// (fts). Relative within one response - not a cross-query threshold.
1580        display_score: f64,
1581        /// Internal ranking key: cosine + recency boost (vector/relevance),
1582        /// BM25 (fts/relevance), or epoch seconds (recency). Drives both the
1583        /// global sort and the per-session rank.
1584        order_score: f64,
1585    }
1586
1587    impl ScoredHit {
1588        fn to_search_result(
1589            &self,
1590            query: &str,
1591            summaries: &HashMap<(String, String), Vec<PartSummary>>,
1592        ) -> Result<SearchResult, ErrorEnvelope> {
1593            let text = hit_payload(&self.meta.search_text, query);
1594            let role = match self.meta.role.as_str() {
1595                "system" => Role::System,
1596                "user" => Role::User,
1597                "assistant" => Role::Assistant,
1598                "tool" => Role::Tool,
1599                other => {
1600                    return Err(map_error(crate::Error::internal(format!(
1601                        "stored message has unknown role: {other}"
1602                    ))));
1603                }
1604            };
1605            // Only user hits earn a parts_summary (FilePart signal); see the
1606            // rationale in spec.md#search.
1607            let parts_summary = if matches!(role, Role::User) {
1608                summaries
1609                    .get(&(self.meta.session_id.clone(), self.meta.message_id.clone()))
1610                    .cloned()
1611                    .unwrap_or_default()
1612            } else {
1613                Vec::new()
1614            };
1615            Ok(SearchResult {
1616                message_id: self.meta.message_id.clone(),
1617                role,
1618                timestamp: self.meta.timestamp,
1619                text,
1620                score: self.display_score.clamp(0.0, 1.0),
1621                parts_summary,
1622            })
1623        }
1624    }
1625
1626    fn normalize_fts(hits: Vec<SearchHit>) -> Vec<Candidate> {
1627        let max = hits.iter().map(|hit| hit.score).fold(0.0_f32, f32::max);
1628        hits.into_iter()
1629            .map(|hit| Candidate {
1630                rowid: hit.rowid,
1631                session_id: hit.key.session_id,
1632                message_id: hit.key.message_id,
1633                base_score: if max > 0.0 {
1634                    f64::from(hit.score / max)
1635                } else {
1636                    0.0
1637                },
1638            })
1639            .collect()
1640    }
1641
1642    // Cosine similarity (`1 - distance`): raw, bounded [0, 1], so `min_score`
1643    // gates it directly and the value is stable across pool sizes (unlike the
1644    // old rank-norm `1 - idx/n`, which shifted whenever `limit` changed).
1645    fn normalize_vector(hits: Vec<SearchHit>) -> Vec<Candidate> {
1646        hits.into_iter()
1647            .map(|hit| Candidate {
1648                rowid: hit.rowid,
1649                session_id: hit.key.session_id,
1650                message_id: hit.key.message_id,
1651                base_score: 1.0 - f64::from(hit.score),
1652            })
1653            .collect()
1654    }
1655
1656    fn embed_query(embedder: &dyn Embedder, query: &str) -> Result<Vec<f32>, ErrorEnvelope> {
1657        let prompt = format_query(query);
1658        // Model inference is synchronous and CPU-bound; `block_in_place` keeps
1659        // it from stalling other tasks on the async worker thread. (Requires a
1660        // multi-threaded runtime - see `pond_search`.)
1661        let vectors =
1662            tokio::task::block_in_place(|| embedder.embed(&[prompt])).map_err(|error_value| {
1663                map_error(crate::Error::internal(format!(
1664                    "failed to embed query: {error_value}"
1665                )))
1666            })?;
1667        vectors.into_iter().next().ok_or_else(|| {
1668            map_error(crate::Error::internal(
1669                "embedder returned no vector for query",
1670            ))
1671        })
1672    }
1673
1674    /// Pick the candidates that will actually be hydrated, using only the keys
1675    /// and `base_score` the arm already produced - no S3. Keeps every candidate
1676    /// belonging to the top-`limit` session roots (no per-session cap: the arm
1677    /// pool already bounds the count, and the byte budget bounds the rendered
1678    /// output). Hydration and rendering then touch those rows instead of the
1679    /// full arm pool (~150). The min_score gate runs here on raw `base_score`
1680    /// (cosine for vector). Returns the selected candidates, the total
1681    /// distinct-session-root count (for `has_more`), and the count of
1682    /// candidates above `min_score` (for `matched_total`).
1683    fn select_top_hits(
1684        mut candidates: Vec<Candidate>,
1685        min_score: f64,
1686        limit: usize,
1687    ) -> (Vec<Candidate>, usize, usize) {
1688        // `min_score` gates raw cosine only when set above 0. The default 0 is
1689        // "no gate" - the absence honesty comes from `searchable_in_scope`, not
1690        // from dropping low-cosine hits (present and absent content overlap in
1691        // the cosine band; see docs/researches/embeddings.md). A literal `>= 0`
1692        // filter would also drop legitimately weak (near-orthogonal) hits.
1693        if min_score > 0.0 {
1694            candidates.retain(|candidate| candidate.base_score >= min_score);
1695        }
1696        let matched_total = candidates.len();
1697        candidates.sort_by(|left, right| {
1698            right
1699                .base_score
1700                .partial_cmp(&left.base_score)
1701                .unwrap_or(std::cmp::Ordering::Equal)
1702                .then_with(|| left.session_id.cmp(&right.session_id))
1703                .then_with(|| left.message_id.cmp(&right.message_id))
1704        });
1705        // Distinct session roots in best-score order (candidates are sorted),
1706        // then keep the top `limit` - the most sessions the response can emit.
1707        let (total_sessions, keep) = {
1708            let mut order: Vec<&str> = Vec::new();
1709            let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1710            for candidate in &candidates {
1711                let root = session_root(&candidate.session_id);
1712                if seen.insert(root) {
1713                    order.push(root);
1714                }
1715            }
1716            let total = order.len();
1717            let keep: std::collections::HashSet<String> =
1718                order.into_iter().take(limit).map(str::to_owned).collect();
1719            (total, keep)
1720        };
1721        let selected = candidates
1722            .into_iter()
1723            .filter(|candidate| keep.contains(session_root(&candidate.session_id)))
1724            .collect();
1725        (selected, total_sessions, matched_total)
1726    }
1727
1728    async fn build_sessions(
1729        store: &Store,
1730        scored: &[ScoredHit],
1731        query: &str,
1732    ) -> Result<Vec<SearchSession>, ErrorEnvelope> {
1733        use std::collections::BTreeMap;
1734
1735        struct Acc {
1736            project: String,
1737            source_agent: String,
1738            matched_count: usize,
1739            /// Highest `order_score` among the session's matches - the session's
1740            /// rank. Sessions sort on this; matches sort newest-first within.
1741            rank: f64,
1742            matches: Vec<(DateTime<Utc>, SearchResult)>,
1743        }
1744        // Precompute part summaries for user-role hits, grouped by their actual
1745        // session id (a subagent hit's parts live under `root/agent-...`, not
1746        // the grouping root).
1747        let mut user_ids_by_session: BTreeMap<String, Vec<String>> = BTreeMap::new();
1748        for hit in scored {
1749            if hit.meta.role == "user" {
1750                user_ids_by_session
1751                    .entry(hit.meta.session_id.clone())
1752                    .or_default()
1753                    .push(hit.meta.message_id.clone());
1754            }
1755        }
1756        // Per-session parts scans are independent S3 round trips - run them
1757        // concurrently, not in a sequential await loop (latency would sum).
1758        let summary_futs = user_ids_by_session
1759            .iter()
1760            .map(|(session_id, message_ids)| async move {
1761                store
1762                    .summary_parts_for_messages(session_id, message_ids)
1763                    .await
1764                    .map_err(map_storage)
1765            });
1766        let mut summaries: HashMap<(String, String), Vec<PartSummary>> = HashMap::new();
1767        for parts_by_message in futures::future::try_join_all(summary_futs).await? {
1768            for (key, parts) in parts_by_message {
1769                summaries.insert(
1770                    key,
1771                    parts
1772                        .iter()
1773                        .filter_map(|part| PartSummary::for_kind(&part.kind))
1774                        .collect(),
1775                );
1776            }
1777        }
1778
1779        let mut groups: BTreeMap<String, Acc> = BTreeMap::new();
1780        for hit in scored {
1781            let root = session_root(&hit.meta.session_id).to_owned();
1782            let entry = groups.entry(root).or_insert_with(|| Acc {
1783                project: hit.meta.project.clone(),
1784                source_agent: hit.meta.source_agent.clone(),
1785                matched_count: 0,
1786                rank: f64::NEG_INFINITY,
1787                matches: Vec::new(),
1788            });
1789            entry.matched_count += 1;
1790            entry.rank = entry.rank.max(hit.order_score);
1791            entry
1792                .matches
1793                .push((hit.meta.timestamp, hit.to_search_result(query, &summaries)?));
1794        }
1795
1796        let session_ids = groups.keys().cloned().collect::<Vec<_>>();
1797        let counts = store
1798            .session_message_counts(&session_ids)
1799            .await
1800            .map_err(map_storage)?;
1801
1802        // Within a session, matches render newest-first: the latest message
1803        // most likely carries the session's current conclusion (intra-session
1804        // supersession). Sessions themselves sort by `rank` (best order_score),
1805        // so a session's lead match need not be its newest.
1806        let mut result = groups
1807            .into_iter()
1808            .map(|(session_id, mut acc)| {
1809                acc.matches.sort_by(|left, right| {
1810                    right
1811                        .0
1812                        .cmp(&left.0)
1813                        .then_with(|| left.1.message_id.cmp(&right.1.message_id))
1814                });
1815                let matches = acc.matches.into_iter().map(|(_, result)| result).collect();
1816                (
1817                    acc.rank,
1818                    SearchSession {
1819                        session_messages_count: counts
1820                            .get(&session_id)
1821                            .copied()
1822                            .unwrap_or_default(),
1823                        session_id,
1824                        project: acc.project,
1825                        source_agent: acc.source_agent,
1826                        matched_message_count: acc.matched_count,
1827                        matches,
1828                    },
1829                )
1830            })
1831            .collect::<Vec<_>>();
1832        result.sort_by(|left, right| {
1833            right
1834                .0
1835                .partial_cmp(&left.0)
1836                .unwrap_or(std::cmp::Ordering::Equal)
1837                .then_with(|| left.1.session_id.cmp(&right.1.session_id))
1838        });
1839        Ok(result.into_iter().map(|(_, session)| session).collect())
1840    }
1841
1842    fn page_sessions(
1843        sessions: Vec<SearchSession>,
1844        matched_total: usize,
1845        total_sessions: usize,
1846        searchable_in_scope: usize,
1847        plan: &SearchPlan,
1848    ) -> Result<SearchResponse, ErrorEnvelope> {
1849        // Emit the top `limit` sessions with all their matches (no per-session
1850        // cap). The structured response carries the full ranked set (bounded by
1851        // the arm pool); the rendered-transcript char budget (transport) is the
1852        // only output limiter, so `limit` sessions always render at least their
1853        // top hit. `has_more` warns the ranked set was cut by `limit` - there
1854        // is no pagination cursor (a wider `limit` dominates page-walking).
1855        let emitted: Vec<SearchSession> = sessions.into_iter().take(plan.limit).collect();
1856        let has_more = total_sessions > emitted.len();
1857
1858        Ok(SearchResponse {
1859            sessions: emitted,
1860            matched_total,
1861            searchable_in_scope,
1862            has_more,
1863        })
1864    }
1865
1866    /// Escape regex metacharacters so a `source_agent` brand is matched as a
1867    /// literal inside the anchored `regexp_like` subpath predicate (the `^` and
1868    /// `(/|$)` anchors stay live; the value between them is inert).
1869    fn regex_escape_literal(value: &str) -> String {
1870        let mut out = String::with_capacity(value.len());
1871        for ch in value.chars() {
1872            if matches!(
1873                ch,
1874                '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\'
1875            ) {
1876                out.push('\\');
1877            }
1878            out.push(ch);
1879        }
1880        out
1881    }
1882
1883    /// User-scope clauses (project/session/date) shared by the arm and
1884    /// `searchable_in_scope`. The subagent exclusion is not here, nor a SQL
1885    /// clause anywhere - it is applied in-memory (see `retain_non_subagents`).
1886    fn build_scope_clauses(filters: &SearchFilters) -> Result<Vec<Predicate>, ErrorEnvelope> {
1887        let mut clauses = Vec::new();
1888
1889        match &filters.project {
1890            None => {}
1891            Some(ProjectFilter::Contains(value)) => {
1892                clauses.push(Predicate::LikeContains("project", value.clone()));
1893            }
1894            Some(ProjectFilter::Regex(pattern)) => {
1895                clauses.push(Predicate::Regex("project", pattern.clone()));
1896            }
1897        }
1898
1899        if let Some(session_id) = &filters.session_id {
1900            clauses.push(Predicate::Eq("session_id", session_id.clone().into()));
1901        }
1902        if let Some(source_agent) = &filters.source_agent {
1903            // Exact-or-subpath as an anchored regex: matches `<value>` and its
1904            // `<value>/...` subpaths, never a sibling brand like `openclaw-x`. A
1905            // LIKE-prefix would be rejected by the bitmap index on `source_agent`
1906            // (Lance: "LIKE prefix queries are not supported for bitmap
1907            // indexes"); `regexp_like` is a scan-side predicate the bitmap index
1908            // does not intercept, so it evaluates correctly.
1909            clauses.push(Predicate::Regex(
1910                "source_agent",
1911                format!("^{}(/|$)", regex_escape_literal(source_agent)),
1912            ));
1913        }
1914        if let Some(from_date) = &filters.from_date {
1915            clauses.push(Predicate::Gte(
1916                "timestamp",
1917                ScalarValue::Raw(date_bound(from_date, "filters.from_date", false)?),
1918            ));
1919        }
1920        if let Some(to_date) = &filters.to_date {
1921            clauses.push(Predicate::Lte(
1922                "timestamp",
1923                ScalarValue::Raw(date_bound(to_date, "filters.to_date", true)?),
1924            ));
1925        }
1926
1927        Ok(clauses)
1928    }
1929
1930    /// Scope predicate for `searchable_in_scope`: user filters only. Empty
1931    /// `And` for an unfiltered search, which lets the count read the FTS
1932    /// `num_docs` stat instead of the ~133 MB search_text scan.
1933    pub fn build_scope_filter(filters: &SearchFilters) -> Result<Predicate, ErrorEnvelope> {
1934        Ok(Predicate::And(build_scope_clauses(filters)?))
1935    }
1936
1937    /// spec.md#search: subagents are excluded from `pond_search` results -
1938    /// always, except when the caller scopes to a subagent deliberately. That
1939    /// means a `session_id` (which may itself be a subagent session) or a
1940    /// `source_agent` value naming a subpath (`"openclaw/subagent"`,
1941    /// `"claude-code/general-purpose"`) - there the exclusion would fight the
1942    /// explicit filter. A root `source_agent` (`"openclaw"`, `"claude-code"`)
1943    /// keeps the default exclusion: its exact-or-subpath match still reaches
1944    /// only the harness's main sessions, matching core `sessions_search` UX.
1945    /// Subagents are otherwise reachable via `pond_sql`
1946    /// (`parent_session_id`).
1947    pub fn default_excludes_subagents(filters: &SearchFilters) -> bool {
1948        filters.session_id.is_none()
1949            && !filters
1950                .source_agent
1951                .as_deref()
1952                .is_some_and(|agent| agent.contains('/'))
1953    }
1954
1955    /// Parse a `YYYY-MM-DD` filter date into a timestamp literal. `end_of_day`
1956    /// pushes `to_date` to the inclusive end of the day.
1957    fn date_bound(date: &str, field: &str, end_of_day: bool) -> Result<String, ErrorEnvelope> {
1958        NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|_| {
1959            map_error(crate::Error::validation_field(
1960                format!("{field} must be in YYYY-MM-DD format; got {date}"),
1961                field,
1962                Some(serde_json::json!(date)),
1963                Some("YYYY-MM-DD".to_owned()),
1964            ))
1965        })?;
1966        let time = if end_of_day { "23:59:59" } else { "00:00:00" };
1967        Ok(format!("timestamp '{date} {time}'"))
1968    }
1969
1970    fn empty_response(searchable_in_scope: usize) -> SearchResponse {
1971        SearchResponse {
1972            sessions: Vec::new(),
1973            matched_total: 0,
1974            searchable_in_scope,
1975            has_more: false,
1976        }
1977    }
1978
1979    #[cfg(test)]
1980    mod grouping_helpers_tests {
1981        #![allow(clippy::expect_used, clippy::unwrap_used)]
1982
1983        use super::*;
1984
1985        #[test]
1986        fn session_root_strips_agent_suffix_for_claude_code_subagents() {
1987            assert_eq!(
1988                session_root("94a50f23-1234-5678-9abc-def012345678"),
1989                "94a50f23-1234-5678-9abc-def012345678",
1990            );
1991            assert_eq!(
1992                session_root("94a50f23-1234-5678-9abc-def012345678/agent-abc123"),
1993                "94a50f23-1234-5678-9abc-def012345678",
1994            );
1995            // Multiple slashes: still cut at the first one (defensive).
1996            assert_eq!(session_root("root/a/b"), "root");
1997        }
1998
1999        #[test]
2000        fn retain_non_subagents_drops_slash_ids_only_when_excluding() {
2001            let hit = |sid: &str| SearchHit {
2002                rowid: None,
2003                key: crate::sessions::MessageKey {
2004                    session_id: sid.to_owned(),
2005                    message_id: "m1".to_owned(),
2006                },
2007                score: 1.0_f32,
2008            };
2009            let base = vec![hit("root-a"), hit("root-b/agent-x"), hit("root-c")];
2010
2011            let mut excluded = base.clone();
2012            retain_non_subagents(&mut excluded, true);
2013            let ids: Vec<&str> = excluded
2014                .iter()
2015                .map(|hit| hit.key.session_id.as_str())
2016                .collect();
2017            assert_eq!(ids, ["root-a", "root-c"]);
2018
2019            let mut kept = base;
2020            retain_non_subagents(&mut kept, false);
2021            assert_eq!(kept.len(), 3);
2022        }
2023    }
2024}
2025
2026pub use search_handler::{
2027    SearchMode, SearchPlan, build_scope_filter, default_excludes_subagents, explain_search_plan,
2028    hit_payload, plan_search, pond_search,
2029};
2030
2031#[cfg(test)]
2032mod tests {
2033    #![allow(clippy::expect_used, clippy::unwrap_used)]
2034
2035    use super::*;
2036    use crate::wire::{ProjectFilter, SearchFilters, SearchRequest};
2037    use chrono::Utc;
2038
2039    fn search_request(query: &str) -> SearchRequest {
2040        SearchRequest {
2041            protocol_version: crate::PROTOCOL_VERSION,
2042            namespace: Some("local".to_owned()),
2043            query: query.to_owned(),
2044            mode: crate::wire::SearchModeWire::Vector,
2045            sort_by: crate::wire::SortBy::Relevance,
2046            filters: SearchFilters::default(),
2047            limit: 20,
2048        }
2049    }
2050
2051    #[test]
2052    fn hit_payload_returns_short_text_in_full() {
2053        let short = "a short message body";
2054        let text = hit_payload(short, "message");
2055        assert_eq!(text, short, "small text is returned as-is");
2056    }
2057
2058    #[test]
2059    fn hit_payload_windows_long_text_around_the_query_term() {
2060        // ~2400 chars: filler head, query term mid-body, filler tail.
2061        let body = format!("{}NEEDLE{}", "a".repeat(2000), "b".repeat(394));
2062        let text = hit_payload(&body, "needle");
2063        assert!(
2064            text.contains("NEEDLE"),
2065            "text is the match-windowed snippet: {text}"
2066        );
2067        // The <=600-char window is wrapped with truncation markers
2068        // ("[N chars before] " / " [+N more chars]"); allow for their length.
2069        assert!(
2070            text.chars().count() <= 600 + 64,
2071            "snippet window is bounded by HIT_SNIPPET_CHARS plus markers: {}",
2072            text.chars().count()
2073        );
2074    }
2075
2076    #[test]
2077    fn hit_payload_snippet_survives_case_folding_that_changes_byte_length() {
2078        // `to_lowercase` of 'İ' is two code points, so the lowercased copy has
2079        // a different byte layout than the original. A query offset taken from
2080        // that copy must never be sliced into the original text.
2081        let body = format!("İÉÉÉ{}", "a".repeat(2100));
2082        let text = hit_payload(&body, "ééé");
2083        assert!(
2084            text.contains("ÉÉÉ"),
2085            "snippet windows on the matched term: {text}"
2086        );
2087    }
2088
2089    #[tokio::test]
2090    async fn restore_lineage_rejects_a_graph_nesting_deeper_than_one_level() {
2091        use crate::adapter::Extracted;
2092        use crate::sessions::Store;
2093        use crate::wire::{ProviderOptions, Session};
2094        use tempfile::TempDir;
2095
2096        let session = |id: &str, parent: Option<&str>| Session {
2097            id: id.to_owned(),
2098            parent_session_id: parent.map(str::to_owned),
2099            parent_message_id: None,
2100            source_agent: "claude-code".to_owned(),
2101            created_at: Utc::now(),
2102            project: Extracted::from_test_value("/tmp/pond".to_owned()),
2103            options: ProviderOptions::new(),
2104        };
2105
2106        let dir = TempDir::new().unwrap();
2107        let store = Store::open_local(dir.path()).await.unwrap();
2108        // A -> B -> C is a two-level spawn graph; spec 6.2 caps lineage at one.
2109        store
2110            .upsert_sessions(&[
2111                session("a", None),
2112                session("b", Some("a")),
2113                session("c", Some("b")),
2114            ])
2115            .await
2116            .unwrap();
2117
2118        // Restoring A reaches child B, then finds B is itself a parent of C.
2119        let err = restore_lineage(&store, "a").await.unwrap_err();
2120        assert!(
2121            err.to_string().contains("one subagent level"),
2122            "expected the deeper-graph error, got: {err}"
2123        );
2124
2125        // Restoring B is a clean one-level graph: B plus its single child C.
2126        let lineage = restore_lineage(&store, "b").await.unwrap();
2127        let ids: Vec<&str> = lineage.iter().map(|s| s.session.id.as_str()).collect();
2128        assert_eq!(ids, ["b", "c"]);
2129    }
2130
2131    #[test]
2132    fn build_scope_filter_pushes_down_each_predicate_and_handles_empty() {
2133        let filters = SearchFilters {
2134            project: Some(ProjectFilter::Contains("/Users/me/pond".to_owned())),
2135            session_id: Some("01HXY".to_owned()),
2136            source_agent: None,
2137            from_date: Some("2026-01-01".to_owned()),
2138            to_date: Some("2026-05-01".to_owned()),
2139            min_score: 0.0,
2140        };
2141        let sql = build_scope_filter(&filters).unwrap().to_lance();
2142        assert!(sql.contains("project LIKE '%/Users/me/pond%'"));
2143        assert!(sql.contains("session_id = '01HXY'"));
2144        assert!(sql.contains("timestamp >="));
2145        assert!(sql.contains("timestamp <="));
2146        // The subagent exclusion is never a SQL clause; it is applied in memory.
2147        assert!(!sql.contains("source_agent"));
2148
2149        // Unfiltered: empty `And` so `searchable_in_scope` reads the FTS num_docs
2150        // stat instead of the ~133 MB search_text scan.
2151        assert_eq!(
2152            build_scope_filter(&SearchFilters::default())
2153                .unwrap()
2154                .to_lance(),
2155            "",
2156        );
2157    }
2158
2159    #[test]
2160    fn build_scope_filter_rejects_bad_date() {
2161        let bad_date = SearchFilters {
2162            from_date: Some("01-01-2026".to_owned()),
2163            ..SearchFilters::default()
2164        };
2165        assert!(build_scope_filter(&bad_date).is_err());
2166    }
2167
2168    #[test]
2169    fn build_scope_filter_escapes_like_wildcards() {
2170        let filters = SearchFilters {
2171            project: Some(ProjectFilter::Contains("/Users/me/my_project".to_owned())),
2172            ..SearchFilters::default()
2173        };
2174        let sql = build_scope_filter(&filters).unwrap().to_lance();
2175        // `_` is a LIKE wildcard and is everywhere in real paths; it must be escaped
2176        // so `my_project` matches literally, with an ESCAPE clause naming the char.
2177        assert!(
2178            sql.contains(r"my\_project"),
2179            "underscore must be escaped: {sql}"
2180        );
2181        assert!(
2182            sql.contains(r"ESCAPE '\'"),
2183            "predicate must declare the escape char: {sql}"
2184        );
2185    }
2186
2187    #[test]
2188    fn source_agent_filter_is_exact_or_subpath_never_a_sibling_prefix() {
2189        let filters = SearchFilters {
2190            source_agent: Some("openclaw".to_owned()),
2191            ..SearchFilters::default()
2192        };
2193        let sql = build_scope_filter(&filters).unwrap().to_lance();
2194        // Anchored regex: matches `openclaw` exactly and `openclaw/<subpath>`,
2195        // never a sibling like `openclaw-x`. A LIKE prefix is rejected by the
2196        // source_agent bitmap index, so this is the correct index-safe form.
2197        assert_eq!(
2198            sql, "regexp_like(source_agent, '^openclaw(/|$)')",
2199            "anchored exact-or-subpath: {sql}"
2200        );
2201        // Never a LIKE form (prefix errors on the bitmap; contains leaks).
2202        assert!(!sql.contains("LIKE"), "no LIKE form: {sql}");
2203
2204        // A subpath value targets exactly that kind and its own children.
2205        let sub = SearchFilters {
2206            source_agent: Some("openclaw/subagent".to_owned()),
2207            ..SearchFilters::default()
2208        };
2209        let sql = build_scope_filter(&sub).unwrap().to_lance();
2210        assert_eq!(
2211            sql, "regexp_like(source_agent, '^openclaw/subagent(/|$)')",
2212            "{sql}"
2213        );
2214
2215        // A brand carrying a regex metacharacter is escaped so it stays literal.
2216        let meta = SearchFilters {
2217            source_agent: Some("a.b".to_owned()),
2218            ..SearchFilters::default()
2219        };
2220        let sql = build_scope_filter(&meta).unwrap().to_lance();
2221        assert_eq!(sql, "regexp_like(source_agent, '^a\\.b(/|$)')", "{sql}");
2222    }
2223
2224    #[test]
2225    fn source_agent_subpath_disables_exclusion_but_root_value_keeps_it() {
2226        // No scope -> subagents excluded by default.
2227        assert!(default_excludes_subagents(&SearchFilters::default()));
2228        // Naming a subpath explicitly is deliberate subagent scoping -> return
2229        // those rows.
2230        assert!(!default_excludes_subagents(&SearchFilters {
2231            source_agent: Some("openclaw/subagent".to_owned()),
2232            ..SearchFilters::default()
2233        }));
2234        // A root value keeps the exclusion: its exact-or-subpath match still
2235        // reaches only the harness's main sessions (the OpenClaw plugin passes
2236        // "openclaw" on every call; it must not flood callers with subagent/
2237        // cron/hook/probe noise).
2238        assert!(default_excludes_subagents(&SearchFilters {
2239            source_agent: Some("openclaw".to_owned()),
2240            ..SearchFilters::default()
2241        }));
2242    }
2243
2244    #[test]
2245    fn plan_search_shapes_request_for_each_planning_input() {
2246        let mut request = search_request("  vector memory  ");
2247        request.limit = 500;
2248        request.filters.min_score = 0.42;
2249        // Default request mode is vector.
2250        let plan = plan_search(request).unwrap();
2251        assert_eq!(plan.mode, SearchMode::Vector);
2252        assert_eq!(plan.query, "vector memory");
2253        assert_eq!(plan.limit, 200);
2254        // Default filters exclude subagents, so the pools over-fetch by half
2255        // (200*5=1000 -> 1500, *2 -> 3000) to survive the in-memory drop.
2256        assert!(plan.exclude_subagents);
2257        assert_eq!(plan.pool, 1500);
2258        assert_eq!(plan.vector_pool, 3000);
2259        assert_eq!(plan.min_score, 0.42);
2260
2261        // Case 2: an explicit fts mode + a tiny limit floors the pools so the
2262        // arm doesn't starve (50 floor -> 75 after the over-fetch).
2263        let mut request = search_request("tiny pool");
2264        request.mode = crate::wire::SearchModeWire::Fts;
2265        request.limit = 1;
2266        let plan = plan_search(request).unwrap();
2267        assert_eq!(plan.mode, SearchMode::Fts);
2268        assert_eq!(plan.limit, 1);
2269        assert_eq!(plan.pool, 75);
2270        assert_eq!(plan.vector_pool, 150);
2271
2272        // Case 3: a session_id scope turns the exclusion off (the scope may
2273        // itself be a subagent session), so no over-fetch - base pools
2274        // (20*5=100, *2=200) - and the filter plumbs through.
2275        let mut request = search_request("filtered");
2276        request.filters.project = Some(ProjectFilter::Contains("/Users/me/pond".to_owned()));
2277        request.filters.session_id = Some("01HXY".to_owned());
2278        let plan = plan_search(request).unwrap();
2279        assert!(!plan.exclude_subagents);
2280        assert_eq!(plan.pool, 100);
2281        assert_eq!(plan.vector_pool, 200);
2282        let sql = plan.filter.to_lance();
2283        assert!(sql.contains("project LIKE"));
2284        assert!(sql.contains("session_id = '01HXY'"));
2285    }
2286
2287    #[test]
2288    fn plan_search_rejects_invalid_composition_before_execution() {
2289        let mut blank = search_request("   ");
2290        let error = plan_search(blank.clone()).unwrap_err().error;
2291        assert_eq!(error.code, crate::wire::ErrorCode::ValidationFailed);
2292        assert_eq!(error.details["field"], "query");
2293
2294        blank.query = "valid".to_owned();
2295        blank.limit = 0;
2296        let error = plan_search(blank.clone()).unwrap_err().error;
2297        assert_eq!(error.details["field"], "limit");
2298
2299        blank.limit = 1;
2300        blank.namespace = Some("remote".to_owned());
2301        let error = plan_search(blank).unwrap_err().error;
2302        assert_eq!(error.code, crate::wire::ErrorCode::NamespaceUnknown);
2303        assert_eq!(error.details["namespace"], "remote");
2304    }
2305}
2306
2307#[cfg(test)]
2308mod get_tests {
2309    #![allow(clippy::expect_used, clippy::unwrap_used)]
2310
2311    use crate::sessions::Store;
2312    use crate::wire::{
2313        GetEnvelope, GetResult, GetSessionRequest, IngestEnvelope, IngestRequest, Message, Part,
2314        PartKind, Provenance, ProviderOptions, Session, SessionFrom,
2315    };
2316    use chrono::{TimeZone, Utc};
2317    use tempfile::TempDir;
2318
2319    fn text_part(session_id: &str, message_id: &str, part_id: &str, body: &str) -> Part {
2320        Part {
2321            session_id: session_id.to_owned(),
2322            id: part_id.to_owned(),
2323            message_id: message_id.to_owned(),
2324            ordinal: 0,
2325            provenance: Provenance::Conversational,
2326            options: ProviderOptions::new(),
2327            kind: PartKind::Text {
2328                text: crate::adapter::extract_str(&serde_json::json!({ "x": body }), "x"),
2329            },
2330        }
2331    }
2332
2333    async fn ingest(store: &Store, events: Vec<super::IngestEvent>) {
2334        let envelope = super::pond_ingest(
2335            store,
2336            IngestRequest {
2337                protocol_version: crate::PROTOCOL_VERSION,
2338                namespace: Some("local".to_owned()),
2339                events,
2340            },
2341        )
2342        .await;
2343        assert!(
2344            matches!(envelope, IngestEnvelope::Success(_)),
2345            "ingest should succeed: {envelope:?}"
2346        );
2347    }
2348
2349    fn session(id: &str, project_marker: &str) -> Session {
2350        Session {
2351            id: id.to_owned(),
2352            parent_session_id: None,
2353            parent_message_id: None,
2354            source_agent: "claude-code".to_owned(),
2355            created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2356            project: crate::adapter::extract_str(&serde_json::json!({ "x": project_marker }), "x")
2357                .unwrap(),
2358            options: ProviderOptions::new(),
2359        }
2360    }
2361
2362    /// `pond_get_session` paginates over the response byte budget: a session
2363    /// whose `search_text` exceeds the budget reports `after_remaining > 0`,
2364    /// and re-requesting with `after_message_id` set to the last returned id
2365    /// surfaces the rest, disjoint from the first page.
2366    #[tokio::test(flavor = "multi_thread")]
2367    async fn pond_get_paginates_session_via_after_message_id() -> anyhow::Result<()> {
2368        let temp = TempDir::new()?;
2369        let store = Store::open_local(temp.path()).await?;
2370        let session_id = "paginate-session";
2371
2372        // ~80KB per message; three exceed the ~200KB page budget so the first
2373        // page stops mid-session.
2374        let huge_text = "abc def ghi jkl ".repeat(5000);
2375        let mut events = vec![super::IngestEvent::Session(session(
2376            session_id,
2377            "pond-paginate",
2378        ))];
2379        for index in 0..3 {
2380            let message_id = format!("paginate-msg-{index}");
2381            events.push(super::IngestEvent::Message(Message::User {
2382                id: message_id.clone(),
2383                session_id: session_id.to_owned(),
2384                timestamp: Utc
2385                    .with_ymd_and_hms(2026, 1, 1, 0, index as u32 + 1, 0)
2386                    .unwrap(),
2387                options: ProviderOptions::new(),
2388            }));
2389            events.push(super::IngestEvent::Part(text_part(
2390                session_id,
2391                &message_id,
2392                &format!("paginate-part-{index}"),
2393                &huge_text,
2394            )));
2395        }
2396        ingest(&store, events).await;
2397
2398        let page_request = |after: Option<String>| GetSessionRequest {
2399            protocol_version: crate::PROTOCOL_VERSION,
2400            namespace: Some("local".to_owned()),
2401            id: session_id.to_owned(),
2402            limit: 1000,
2403            from: SessionFrom::Start,
2404            after_message_id: after,
2405            before_message_id: None,
2406        };
2407
2408        let GetEnvelope::Success(first) = super::pond_get_session(&store, page_request(None)).await
2409        else {
2410            panic!("first page must succeed");
2411        };
2412        let GetResult::Session {
2413            messages: first_messages,
2414            after_remaining,
2415            ..
2416        } = first.result
2417        else {
2418            panic!("first page is session-scope");
2419        };
2420        assert!(after_remaining > 0, "long corpus must trip the page budget");
2421        let after = first_messages.last().expect("non-empty page").id.clone();
2422
2423        let GetEnvelope::Success(second) =
2424            super::pond_get_session(&store, page_request(Some(after))).await
2425        else {
2426            panic!("continuation page must succeed");
2427        };
2428        let GetResult::Session {
2429            messages: second_messages,
2430            ..
2431        } = second.result
2432        else {
2433            panic!("continuation is session-scope");
2434        };
2435        assert!(
2436            !second_messages.is_empty(),
2437            "continuation surfaces the rest"
2438        );
2439        let first_ids: std::collections::HashSet<&str> =
2440            first_messages.iter().map(|m| m.id.as_str()).collect();
2441        assert!(
2442            second_messages
2443                .iter()
2444                .all(|m| !first_ids.contains(m.id.as_str())),
2445            "after_message_id pages must be disjoint"
2446        );
2447        Ok(())
2448    }
2449
2450    /// `pond_get_session(from = "end")` returns the newest `limit` messages
2451    /// chronologically (the compaction-recovery path) with the older messages
2452    /// reported as `before_remaining`; `start` returns the oldest with the
2453    /// newer ones as `after_remaining`. The two are disjoint ends.
2454    #[tokio::test(flavor = "multi_thread")]
2455    async fn pond_get_session_from_end_returns_the_recent_tail() -> anyhow::Result<()> {
2456        let temp = TempDir::new()?;
2457        let store = Store::open_local(temp.path()).await?;
2458        let session_id = "tail-session";
2459
2460        let mut events = vec![super::IngestEvent::Session(session(
2461            session_id,
2462            "pond-tail",
2463        ))];
2464        for index in 0..5u32 {
2465            let message_id = format!("tail-msg-{index}");
2466            events.push(super::IngestEvent::Message(Message::User {
2467                id: message_id.clone(),
2468                session_id: session_id.to_owned(),
2469                timestamp: Utc.with_ymd_and_hms(2026, 1, 1, 0, index + 1, 0).unwrap(),
2470                options: ProviderOptions::new(),
2471            }));
2472            events.push(super::IngestEvent::Part(text_part(
2473                session_id,
2474                &message_id,
2475                &format!("tail-part-{index}"),
2476                &format!("message {index}"),
2477            )));
2478        }
2479        ingest(&store, events).await;
2480
2481        let request = |from: SessionFrom| GetSessionRequest {
2482            protocol_version: crate::PROTOCOL_VERSION,
2483            namespace: Some("local".to_owned()),
2484            id: session_id.to_owned(),
2485            limit: 2,
2486            from,
2487            after_message_id: None,
2488            before_message_id: None,
2489        };
2490        let page = |envelope: GetEnvelope| -> (Vec<String>, usize, usize) {
2491            let GetEnvelope::Success(response) = envelope else {
2492                panic!("get must succeed");
2493            };
2494            let GetResult::Session {
2495                messages,
2496                before_remaining,
2497                after_remaining,
2498                ..
2499            } = response.result
2500            else {
2501                panic!("session-scope result expected");
2502            };
2503            (
2504                messages.into_iter().map(|m| m.id).collect(),
2505                before_remaining,
2506                after_remaining,
2507            )
2508        };
2509
2510        let (end_ids, end_before, _) =
2511            page(super::pond_get_session(&store, request(SessionFrom::End)).await);
2512        assert_eq!(
2513            end_ids,
2514            ["tail-msg-3", "tail-msg-4"],
2515            "end returns the newest two, chronologically"
2516        );
2517        assert_eq!(end_before, 3, "three older messages precede the tail");
2518
2519        let (start_ids, _, start_after) =
2520            page(super::pond_get_session(&store, request(SessionFrom::Start)).await);
2521        assert_eq!(
2522            start_ids,
2523            ["tail-msg-0", "tail-msg-1"],
2524            "start returns the oldest two"
2525        );
2526        assert_eq!(start_after, 3, "three newer messages follow the head");
2527        Ok(())
2528    }
2529
2530    /// The id-misuse paths: `pond_get_session` given a message id resolves up
2531    /// to the parent session with the page anchored at that message and the
2532    /// resolution recorded; `pond_get_message` given a session id rejects with
2533    /// a hint naming `pond_get_session` (a session cannot pick one message).
2534    #[tokio::test(flavor = "multi_thread")]
2535    async fn get_session_resolves_message_id_and_get_message_rejects_session_id()
2536    -> anyhow::Result<()> {
2537        let temp = TempDir::new()?;
2538        let store = Store::open_local(temp.path()).await?;
2539        let session_id = "resolve-session";
2540
2541        let mut events = vec![super::IngestEvent::Session(session(
2542            session_id,
2543            "pond-resolve",
2544        ))];
2545        for index in 0..4u32 {
2546            let message_id = format!("resolve-msg-{index}");
2547            events.push(super::IngestEvent::Message(Message::User {
2548                id: message_id.clone(),
2549                session_id: session_id.to_owned(),
2550                timestamp: Utc.with_ymd_and_hms(2026, 1, 1, 0, index + 1, 0).unwrap(),
2551                options: ProviderOptions::new(),
2552            }));
2553            events.push(super::IngestEvent::Part(text_part(
2554                session_id,
2555                &message_id,
2556                &format!("resolve-part-{index}"),
2557                &format!("message {index}"),
2558            )));
2559        }
2560        ingest(&store, events).await;
2561
2562        let by_message = GetSessionRequest {
2563            protocol_version: crate::PROTOCOL_VERSION,
2564            namespace: Some("local".to_owned()),
2565            id: "resolve-msg-2".to_owned(),
2566            limit: 20,
2567            from: SessionFrom::Start,
2568            after_message_id: None,
2569            before_message_id: None,
2570        };
2571        let GetEnvelope::Success(response) = super::pond_get_session(&store, by_message).await
2572        else {
2573            panic!("message id must resolve to its parent session");
2574        };
2575        assert_eq!(response.session.id, session_id);
2576        let GetResult::Session {
2577            messages,
2578            before_remaining,
2579            resolved_from_message_id,
2580            ..
2581        } = response.result
2582        else {
2583            panic!("session-scope result expected");
2584        };
2585        assert_eq!(resolved_from_message_id.as_deref(), Some("resolve-msg-2"));
2586        assert_eq!(
2587            messages.first().map(|m| m.id.as_str()),
2588            Some("resolve-msg-2"),
2589            "the page is anchored at the resolved message (inclusive)"
2590        );
2591        assert_eq!(before_remaining, 2, "the two earlier messages page up");
2592
2593        let by_session = crate::wire::GetMessageRequest {
2594            protocol_version: crate::PROTOCOL_VERSION,
2595            namespace: Some("local".to_owned()),
2596            id: session_id.to_owned(),
2597            context_before: 3,
2598            context_after: 3,
2599        };
2600        let GetEnvelope::Error(error) = super::pond_get_message(&store, by_session).await else {
2601            panic!("a session id must not resolve to one message");
2602        };
2603        assert!(
2604            error.error.message.contains("pond_get_session"),
2605            "the rejection teaches the session read: {}",
2606            error.error.message
2607        );
2608        Ok(())
2609    }
2610}