Skip to main content

pond/
wire.rs

1use std::collections::BTreeMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use uuid::Uuid;
7
8use crate::PROTOCOL_VERSION;
9use crate::adapter::Extracted;
10
11pub type ProviderOptions = BTreeMap<String, Value>;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Session {
15    pub id: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub parent_session_id: Option<String>,
18    /// spec.md#model-parent-pointer-coherence: when set, `parent_session_id`
19    /// MUST also be set. Spawn-only sources (claude-code subagents,
20    /// nanoclaw) leave this `None`; fork-with-cut-point sources
21    /// (pi-coding-agent) populate both pointers together.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub parent_message_id: Option<String>,
24    pub source_agent: String,
25    pub created_at: DateTime<Utc>,
26    pub project: Extracted<String>,
27    #[serde(default)]
28    pub options: ProviderOptions,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "role", rename_all = "snake_case")]
33pub enum Message {
34    System {
35        id: String,
36        session_id: String,
37        timestamp: DateTime<Utc>,
38        /// `None` when the source row carried no content. The seal on
39        /// `Extracted<String>` means adapters CANNOT pass a synthesized
40        /// or sentinel string here - the value either flows from a
41        /// `Source` extraction or the field is `None`. Distinguishes
42        /// "source said content=''" (Some(extracted_empty)) from
43        /// "source had no content field" (None).
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        content: Option<Extracted<String>>,
46        #[serde(default)]
47        options: ProviderOptions,
48    },
49    User {
50        id: String,
51        session_id: String,
52        timestamp: DateTime<Utc>,
53        #[serde(default)]
54        options: ProviderOptions,
55    },
56    Assistant {
57        id: String,
58        session_id: String,
59        timestamp: DateTime<Utc>,
60        #[serde(default)]
61        options: ProviderOptions,
62    },
63    Tool {
64        id: String,
65        session_id: String,
66        timestamp: DateTime<Utc>,
67        #[serde(default)]
68        options: ProviderOptions,
69    },
70}
71
72impl Message {
73    pub fn id(&self) -> &str {
74        match self {
75            Self::System { id, .. }
76            | Self::User { id, .. }
77            | Self::Assistant { id, .. }
78            | Self::Tool { id, .. } => id,
79        }
80    }
81
82    pub fn session_id(&self) -> &str {
83        match self {
84            Self::System { session_id, .. }
85            | Self::User { session_id, .. }
86            | Self::Assistant { session_id, .. }
87            | Self::Tool { session_id, .. } => session_id,
88        }
89    }
90
91    pub fn role(&self) -> Role {
92        match self {
93            Self::System { .. } => Role::System,
94            Self::User { .. } => Role::User,
95            Self::Assistant { .. } => Role::Assistant,
96            Self::Tool { .. } => Role::Tool,
97        }
98    }
99
100    pub fn timestamp(&self) -> DateTime<Utc> {
101        match self {
102            Self::System { timestamp, .. }
103            | Self::User { timestamp, .. }
104            | Self::Assistant { timestamp, .. }
105            | Self::Tool { timestamp, .. } => *timestamp,
106        }
107    }
108
109    pub fn options(&self) -> &ProviderOptions {
110        match self {
111            Self::System { options, .. }
112            | Self::User { options, .. }
113            | Self::Assistant { options, .. }
114            | Self::Tool { options, .. } => options,
115        }
116    }
117
118    pub fn options_mut(&mut self) -> &mut ProviderOptions {
119        match self {
120            Self::System { options, .. }
121            | Self::User { options, .. }
122            | Self::Assistant { options, .. }
123            | Self::Tool { options, .. } => options,
124        }
125    }
126
127    pub fn system_content(&self) -> Option<&str> {
128        match self {
129            // Two layers of `as_deref`: the outer `Option<Extracted<String>>`
130            // becomes `Option<&Extracted<String>>`, then `Extracted: Deref`
131            // unwraps to `&str`.
132            Self::System { content, .. } => content.as_deref().map(|e| &**e),
133            Self::User { .. } | Self::Assistant { .. } | Self::Tool { .. } => None,
134        }
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum Role {
141    System,
142    User,
143    Assistant,
144    Tool,
145}
146
147impl Role {
148    pub fn as_str(self) -> &'static str {
149        match self {
150            Self::System => "system",
151            Self::User => "user",
152            Self::Assistant => "assistant",
153            Self::Tool => "tool",
154        }
155    }
156}
157
158/// Whether a Part's content is conversation or harness-injected scaffolding
159/// (spec.md#model-part-provenance). No `Default` and no `#[serde(default)]` on the
160/// `Part.provenance` field below: constructing a Part without classifying it
161/// MUST be a compile error (spec.md#adapter-provenance-required).
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum Provenance {
165    Conversational,
166    Injected,
167}
168
169impl Provenance {
170    pub fn as_str(self) -> &'static str {
171        match self {
172            Self::Conversational => "conversational",
173            Self::Injected => "injected",
174        }
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct Part {
180    pub session_id: String,
181    pub id: String,
182    pub message_id: String,
183    pub ordinal: i32,
184    /// Conversation vs harness-injected (spec.md#model-part-provenance). Mandatory,
185    /// no serde default - search reads it to exclude injected scaffolding.
186    pub provenance: Provenance,
187    #[serde(default)]
188    pub options: ProviderOptions,
189    #[serde(flatten)]
190    pub kind: PartKind,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194#[serde(tag = "type", rename_all = "snake_case")]
195pub enum PartKind {
196    Text {
197        /// `None` when the source row had no text field. The seal on
198        /// `Extracted<String>` means adapters CANNOT pass a synthesized
199        /// empty string or any other placeholder here - the value either
200        /// flows from a `Source` extraction or the field is `None`.
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        text: Option<Extracted<String>>,
203    },
204    Reasoning {
205        /// `None` when the source row had no reasoning text. Type-system
206        /// guard against `unwrap_or_default()`-style fallbacks: the
207        /// `Extracted<String>` seal forces the adapter to either get the
208        /// value from a `Source` or admit it is absent.
209        #[serde(default, skip_serializing_if = "Option::is_none")]
210        text: Option<Extracted<String>>,
211    },
212    File {
213        /// `None` when the source row carried no MIME hint. Sealed against
214        /// `unwrap_or("application/octet-stream")`-style fallbacks: an absent
215        /// type is faithfully absent, not a synthesized default
216        /// (spec.md#model-no-synthesis).
217        #[serde(default, skip_serializing_if = "Option::is_none")]
218        media_type: Option<String>,
219        #[serde(skip_serializing_if = "Option::is_none")]
220        file_name: Option<String>,
221        data: FileData,
222    },
223    ToolCall {
224        /// `None` when the source carried no call_id (rare; malformed).
225        /// Sealed via `Extracted<String>` - empty-string sentinels are
226        /// not constructable from adapter code.
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        call_id: Option<Extracted<String>>,
229        /// `None` when the source carried no tool name. claude-code
230        /// always carries it on `tool_use` rows; codex-cli sometimes
231        /// has placeholder shapes. The seal makes synthesized names
232        /// unconstructable from adapter code (spec.md#model-no-synthesis).
233        #[serde(default, skip_serializing_if = "Option::is_none")]
234        name: Option<Extracted<String>>,
235        params: Value,
236        provider_executed: bool,
237    },
238    ToolResult {
239        /// `None` when the source carried no `tool_use_id` link.
240        #[serde(default, skip_serializing_if = "Option::is_none")]
241        call_id: Option<Extracted<String>>,
242        /// `None` when the adapter could not resolve the tool name.
243        /// In claude-code, name lives only on the prior `tool_use` row;
244        /// the adapter resolves via a per-file `tool_use_id -> name`
245        /// map and surfaces a miss (e.g. compaction pruned the originating
246        /// call) as `None`, never as a fabricated string
247        /// (spec.md#model-no-synthesis).
248        #[serde(default, skip_serializing_if = "Option::is_none")]
249        name: Option<Extracted<String>>,
250        is_failure: bool,
251        result: Value,
252    },
253    ToolApprovalRequest {
254        approval_id: String,
255        tool_call_id: String,
256    },
257    ToolApprovalResponse {
258        approval_id: String,
259        approved: bool,
260        #[serde(skip_serializing_if = "Option::is_none")]
261        reason: Option<String>,
262    },
263}
264
265impl PartKind {
266    pub fn type_name(&self) -> &'static str {
267        match self {
268            Self::Text { .. } => "text",
269            Self::Reasoning { .. } => "reasoning",
270            Self::File { .. } => "file",
271            Self::ToolCall { .. } => "tool_call",
272            Self::ToolResult { .. } => "tool_result",
273            Self::ToolApprovalRequest { .. } => "tool_approval_request",
274            Self::ToolApprovalResponse { .. } => "tool_approval_response",
275        }
276    }
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
281pub enum FileData {
282    String(String),
283    Bytes(Vec<u8>),
284    Url(String),
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum ErrorCode {
290    ValidationFailed,
291    VersionUnsupported,
292    NotFound,
293    NamespaceUnknown,
294    StorageUnavailable,
295    Conflict,
296    Internal,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct ErrorBody {
301    pub code: ErrorCode,
302    pub message: String,
303    #[serde(default)]
304    pub details: Value,
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct ErrorEnvelope {
309    pub error: ErrorBody,
310}
311
312// The success/error size gap is fine here: a `GetEnvelope` is one per-request
313// return value, serialized immediately - never stored in bulk where the gap
314// would waste memory.
315#[allow(clippy::large_enum_variant)]
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[serde(untagged)]
318pub enum GetEnvelope {
319    Success(GetResponse),
320    Error(ErrorEnvelope),
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct GetRequest {
325    pub protocol_version: u16,
326    #[serde(default)]
327    pub namespace: Option<String>,
328    // Mutually exclusive scopes. The prefixed params self-document which scope
329    // they belong to (agents read names, not descriptions).
330    #[serde(default)]
331    pub session_id: Option<String>,
332    #[serde(default)]
333    pub message_id: Option<String>,
334    /// Session scope: max messages per page.
335    #[serde(default = "default_get_limit")]
336    pub session_limit: usize,
337    /// Session scope: which end to read the first page from - `start` (oldest,
338    /// default) or `end` (most recent, e.g. post-compaction recovery). Pages
339    /// stay chronological. Ignored once an anchor below is set.
340    #[serde(default)]
341    pub session_from: SessionFrom,
342    /// Session scope: page forward - messages strictly after this id.
343    #[serde(default)]
344    pub session_after_message_id: Option<String>,
345    /// Session scope: page backward - messages strictly before this id.
346    #[serde(default)]
347    pub session_before_message_id: Option<String>,
348    /// Message scope: conversational sibling messages before the target
349    /// (mirrors `grep -B`).
350    #[serde(default = "default_context")]
351    pub message_context_before: usize,
352    /// Message scope: conversational sibling messages after the target
353    /// (mirrors `grep -A`).
354    #[serde(default = "default_context")]
355    pub message_context_after: usize,
356}
357
358/// Which end of a session `pond_get` reads its first page from
359/// (spec.md#protocol).
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
361#[serde(rename_all = "lowercase")]
362pub enum SessionFrom {
363    /// Oldest messages first (the session's start).
364    #[default]
365    Start,
366    /// Most recent messages (the session's tail), still chronological.
367    End,
368}
369
370/// The session header is always present; `result` carries the mode-specific
371/// payload, discriminated by a `scope` tag (spec.md#protocol). Flattened so a
372/// client reads `session` / `scope` / payload fields off one object - no
373/// `session.session` nesting.
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375pub struct GetResponse {
376    pub session: GetSession,
377    #[serde(flatten)]
378    pub result: GetResult,
379}
380
381/// Trimmed session header (spec.md#protocol): adapter-redundant `options`,
382/// parent pointers (served by `restore_lineage`), and per-message session id
383/// dropped to keep `pond_get` responses lean for agent context windows.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct GetSession {
386    pub id: String,
387    pub source_agent: String,
388    pub project: String,
389    pub created_at: DateTime<Utc>,
390}
391
392impl GetSession {
393    pub fn from_session(session: &Session) -> Self {
394        Self {
395            id: session.id.clone(),
396            source_agent: session.source_agent.clone(),
397            project: (*session.project).clone(),
398            created_at: session.created_at,
399        }
400    }
401}
402
403/// Per-message view in a `pond_get` response (spec.md#protocol). Always
404/// conversational: `text`/`content` plus one-line part summaries. Full part
405/// bodies ride `GetResult::Message.target_parts`, reached by `message_id`
406/// scope - a session view never inlines them.
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct MessageView {
409    pub id: String,
410    pub role: Role,
411    pub timestamp: DateTime<Utc>,
412    /// Conversational text (`search_text`); absent for carrier rows.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub text: Option<String>,
415    /// System-message content string, when the source carried one.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub content: Option<String>,
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub parts_summary: Vec<PartSummary>,
420}
421
422/// Compact per-part descriptor (spec.md#protocol): enough to tell what a
423/// message carries without paying for full content. `call_id` is populated
424/// for `tool_call` / `tool_result` only.
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
426pub struct PartSummary {
427    pub kind: String,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub label: Option<String>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub call_id: Option<String>,
432}
433
434impl PartSummary {
435    /// Project a canonical [`PartKind`] into its compact response descriptor, or
436    /// `None` for a kind that does not earn a summary. Exhaustive on purpose - a
437    /// new `PartKind` variant must decide here. `call_id` is carried for
438    /// `tool_call` / `tool_result` only.
439    ///
440    /// `text` and `reasoning` return `None`: a text part's content already rides
441    /// the message's `text`/`content` (a summary would duplicate it), and
442    /// reasoning is deliberately not surfaced in the session/conversational view
443    /// (its full body is still rendered when a message is fetched by `message_id`
444    /// scope). The kinds that survive are exactly [`SUMMARY_PART_TYPES`].
445    pub fn for_kind(kind: &PartKind) -> Option<Self> {
446        let (label, call_id) = match kind {
447            PartKind::Text { .. } | PartKind::Reasoning { .. } => return None,
448            PartKind::File {
449                media_type,
450                file_name,
451                ..
452            } => (file_name.clone().or_else(|| media_type.clone()), None),
453            PartKind::ToolCall { name, call_id, .. } => {
454                (name.as_deref().cloned(), call_id.as_deref().cloned())
455            }
456            PartKind::ToolResult {
457                name,
458                call_id,
459                is_failure,
460                ..
461            } => {
462                let label = name.as_deref().map(|name| {
463                    if *is_failure {
464                        format!("{name} (failed)")
465                    } else {
466                        name.clone()
467                    }
468                });
469                (label, call_id.as_deref().cloned())
470            }
471            PartKind::ToolApprovalRequest { approval_id, .. } => (Some(approval_id.clone()), None),
472            PartKind::ToolApprovalResponse {
473                approval_id,
474                approved,
475                ..
476            } => {
477                let verb = if *approved { "approved" } else { "denied" };
478                (Some(format!("{approval_id} ({verb})")), None)
479            }
480        };
481        Some(Self {
482            kind: kind.type_name().to_owned(),
483            label,
484            call_id,
485        })
486    }
487}
488
489/// Canonical part `type` names that yield a [`PartSummary`] - every kind except
490/// `text` and `reasoning` (see [`PartSummary::for_kind`], the source of truth).
491/// The summary read paths filter the parts scan to these so a text/reasoning
492/// heavy session never loads parts that would summarize to nothing.
493pub const SUMMARY_PART_TYPES: &[&str] = &[
494    "file",
495    "tool_call",
496    "tool_result",
497    "tool_approval_request",
498    "tool_approval_response",
499];
500
501/// A `Part` as it rides a `pond_get` response (spec.md#protocol): the canonical
502/// part minus `session_id` / `message_id`, which the enclosing session and
503/// message already identify. Built from a canonical [`Part`] in the handler.
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct ResponsePart {
506    pub id: String,
507    pub ordinal: i32,
508    pub provenance: Provenance,
509    #[serde(default, skip_serializing_if = "ProviderOptions::is_empty")]
510    pub options: ProviderOptions,
511    #[serde(flatten)]
512    pub kind: PartKind,
513}
514
515impl ResponsePart {
516    pub fn from_part(part: Part) -> Self {
517        Self {
518            id: part.id,
519            ordinal: part.ordinal,
520            provenance: part.provenance,
521            options: part.options,
522            kind: part.kind,
523        }
524    }
525}
526
527/// Mode-specific `pond_get` payload, tagged by `scope` and flattened into
528/// `GetResponse` alongside the shared session header.
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530#[serde(tag = "scope", rename_all = "snake_case")]
531pub enum GetResult {
532    Session {
533        messages: Vec<MessageView>,
534        /// Conversational messages before the emitted page (the top marker's
535        /// `session_before_message_id` cursor exists when this is > 0).
536        before_remaining: usize,
537        /// Conversational messages after the emitted page (the bottom marker's
538        /// `session_after_message_id` cursor exists when this is > 0).
539        after_remaining: usize,
540    },
541    Message {
542        target: MessageView,
543        target_parts: Vec<ResponsePart>,
544        target_parts_remaining: usize,
545        /// `message_context_before` + `message_context_after` conversational
546        /// messages around the target (target excluded).
547        siblings: Vec<MessageView>,
548    },
549}
550
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552#[serde(untagged)]
553pub enum SearchEnvelope {
554    Success(SearchResponse),
555    Error(ErrorEnvelope),
556}
557
558/// JSON shape is externally tagged: `{"contains": "pond"}` or
559/// `{"regex": "^/Users/.*"}`.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561#[serde(rename_all = "snake_case")]
562pub enum ProjectFilter {
563    Contains(String),
564    Regex(String),
565}
566
567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
568pub struct SearchRequest {
569    pub protocol_version: u16,
570    #[serde(default)]
571    pub namespace: Option<String>,
572    pub query: String,
573    /// Retrieval arm (spec.md#search). `vector` (default) matches on meaning;
574    /// `fts` matches exact whole words via BM25. The agent picks per query -
575    /// there is no server-side fusion. If `vector` is asked of a store with no
576    /// embeddings, the server falls back to `fts`.
577    #[serde(default)]
578    pub mode: SearchModeWire,
579    /// Result ordering. `relevance` (default) ranks by match strength (vector:
580    /// cosine + a gentle recency tiebreaker; fts: BM25); `recency` ranks
581    /// strictly newest-first. A recency-sorted response is labeled so the
582    /// caller does not misread rank-1 as the best match.
583    #[serde(default)]
584    pub sort_by: SortBy,
585    #[serde(default)]
586    pub filters: SearchFilters,
587    #[serde(default = "default_limit")]
588    pub limit: usize,
589}
590
591/// Wire-level retrieval arm (spec.md#search). The agent chooses per query:
592/// `vector` for concepts/meaning (default), `fts` for known exact words
593/// (BM25). The old server-side hybrid fusion is gone - one arm per request.
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
595#[serde(rename_all = "lowercase")]
596pub enum SearchModeWire {
597    Fts,
598    #[default]
599    Vector,
600}
601
602/// Result ordering for `pond_search` (spec.md#search).
603#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
604#[serde(rename_all = "lowercase")]
605pub enum SortBy {
606    /// Match strength: vector = cosine + recency tiebreaker, fts = BM25.
607    #[default]
608    Relevance,
609    /// Strictly newest-first; the response is labeled as recency-sorted.
610    Recency,
611}
612
613#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
614pub struct SearchFilters {
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub project: Option<ProjectFilter>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub session_id: Option<String>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub from_date: Option<String>,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub to_date: Option<String>,
623    /// Raw-cosine score floor for `vector` mode; hits below it are dropped.
624    /// Not an absence signal: present and absent content score in overlapping
625    /// bands (see `docs/researches/embeddings.md`), so the default stays 0 and
626    /// the response's `searchable_in_scope` carries the honesty instead.
627    /// Disallowed in `fts` mode (BM25 is unbounded and not comparable across
628    /// queries) - the handler rejects a non-zero value there.
629    // Skip the default 0.0 so an unfiltered request stays compact.
630    #[serde(default, skip_serializing_if = "is_zero_f64")]
631    pub min_score: f64,
632}
633
634fn is_zero_f64(value: &f64) -> bool {
635    *value == 0.0
636}
637
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639pub struct SearchResponse {
640    pub sessions: Vec<SearchSession>,
641    pub matched_total: usize,
642    /// How many messages with conversational text the caller's filters left
643    /// in scope - the universe the search actually ran over. The absence
644    /// signal: 0 means the filters excluded everything before retrieval, and
645    /// a small value warns that "no relevant hits" covers a thin slice.
646    #[serde(default)]
647    pub searchable_in_scope: usize,
648    pub has_more: bool,
649}
650
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
652pub struct SearchSession {
653    pub session_id: String,
654    pub project: String,
655    pub source_agent: String,
656    pub session_messages_count: usize,
657    pub matched_message_count: usize,
658    pub matches: Vec<SearchResult>,
659}
660
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
662pub struct SearchResult {
663    pub message_id: String,
664    pub role: Role,
665    pub timestamp: DateTime<Utc>,
666    pub text: String,
667    pub score: f64,
668    /// Populated only for user-role hits: distinguishes a plain-text prompt
669    /// from one carrying file attachments or multi-part scaffolding.
670    #[serde(default, skip_serializing_if = "Vec::is_empty")]
671    pub parts_summary: Vec<PartSummary>,
672}
673
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[serde(untagged)]
676pub enum IngestEnvelope {
677    Success(IngestResponse),
678    Error(ErrorEnvelope),
679}
680
681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct IngestRequest {
683    pub protocol_version: u16,
684    #[serde(default)]
685    pub namespace: Option<String>,
686    pub events: Vec<crate::sessions::IngestEvent>,
687}
688
689/// `pond_ingest` response (spec.md#protocol). `accepted = inserted + matched`,
690/// `rejected = error`; both derived from `results`. Per-row `results[]` is
691/// the contract clients rely on to reconcile retries (the PK is echoed so
692/// the client can match outcomes back to its input even when `index` is not
693/// enough). Each result reports the input event's `index`, `kind`, `pk`,
694/// `status`, and an `error` body when `status = "error"`.
695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
696pub struct IngestResponse {
697    pub accepted: usize,
698    pub rejected: usize,
699    pub results: Vec<IngestResult>,
700}
701
702/// One row of `pond_ingest` per-row output (spec.md#protocol).
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704pub struct IngestResult {
705    /// Position in the request's `events` array (0-based).
706    pub index: usize,
707    /// `"session"` | `"message"` | `"part"`, matching `IngestEvent::kind`.
708    pub kind: String,
709    /// Echoed primary key: scalar for session, `[session_id, message_id]` for
710    /// message, `[session_id, message_id, part_id]` for part. Lets clients reconcile
711    /// against their own state on retry.
712    pub pk: Value,
713    pub status: IngestStatus,
714    /// Set only when `status = "error"`. Carries the same shape as the
715    /// envelope-level error body.
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub error: Option<ErrorBody>,
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
721#[serde(rename_all = "snake_case")]
722pub enum IngestStatus {
723    /// New PK; `merge_insert` wrote a fresh row.
724    Inserted,
725    /// PK existed; `merge_insert` matched it (no-op per spec.md#adapter-integrity-additive-sync).
726    Matched,
727    /// Per-row failure: validation or storage error. See `error` field.
728    Error,
729}
730
731fn default_limit() -> usize {
732    10
733}
734
735pub fn new_request_id() -> String {
736    format!("req_{}", Uuid::now_v7())
737}
738
739pub const DEFAULT_NAMESPACE: &str = "local";
740
741pub fn default_namespace() -> String {
742    DEFAULT_NAMESPACE.to_owned()
743}
744
745fn default_get_limit() -> usize {
746    20
747}
748
749fn default_context() -> usize {
750    3
751}
752
753pub fn validate_protocol(version: u16) -> Result<(), ErrorEnvelope> {
754    if version == PROTOCOL_VERSION {
755        return Ok(());
756    }
757
758    Err(error(
759        ErrorCode::VersionUnsupported,
760        "unsupported protocol_version",
761        serde_json::json!({
762            "received": version,
763            "supported": [PROTOCOL_VERSION],
764        }),
765    ))
766}
767
768pub fn error(code: ErrorCode, message: impl Into<String>, details: Value) -> ErrorEnvelope {
769    ErrorEnvelope {
770        error: ErrorBody {
771            code,
772            message: message.into(),
773            details,
774        },
775    }
776}
777
778impl From<crate::Error> for ErrorEnvelope {
779    fn from(error_value: crate::Error) -> Self {
780        match error_value {
781            crate::Error::Validation {
782                message,
783                field,
784                value,
785                expected,
786            } => error(
787                ErrorCode::ValidationFailed,
788                message,
789                validation_details(field, value, expected),
790            ),
791            crate::Error::NotFound { message, kind, pk } => error(
792                ErrorCode::NotFound,
793                message,
794                serde_json::json!({ "kind": kind, "pk": pk }),
795            ),
796            crate::Error::NamespaceUnknown { namespace } => error(
797                ErrorCode::NamespaceUnknown,
798                "namespace unknown",
799                serde_json::json!({ "namespace": namespace }),
800            ),
801            crate::Error::Conflict { attempts } => error(
802                ErrorCode::Conflict,
803                "commit conflict after retries exhausted",
804                serde_json::json!({ "attempts": attempts }),
805            ),
806            crate::Error::Storage(error_value) => storage_error(error_value),
807            crate::Error::Internal(message) => {
808                error(ErrorCode::Internal, message, serde_json::json!({}))
809            }
810        }
811    }
812}
813
814fn validation_details(
815    field: Option<String>,
816    value: Option<Value>,
817    expected: Option<String>,
818) -> Value {
819    let mut details = Map::new();
820    if let Some(field) = field {
821        details.insert("field".to_owned(), Value::String(field));
822    }
823    if let Some(value) = value {
824        details.insert("value".to_owned(), value);
825    }
826    if let Some(expected) = expected {
827        details.insert("expected".to_owned(), Value::String(expected));
828    }
829    Value::Object(details)
830}
831
832pub fn storage_error(error_value: anyhow::Error) -> ErrorEnvelope {
833    error(
834        ErrorCode::StorageUnavailable,
835        "storage operation failed",
836        serde_json::json!({ "underlying": error_value.to_string() }),
837    )
838}
839
840#[cfg(test)]
841mod tests {
842    #![allow(clippy::expect_used, clippy::unwrap_used)]
843
844    use super::*;
845    use serde_json::json;
846
847    #[test]
848    fn wire_envelope_carries_conflict_code_and_attempts_detail() {
849        let envelope: ErrorEnvelope = crate::Error::Conflict { attempts: 3 }.into();
850        assert_eq!(envelope.error.code, ErrorCode::Conflict);
851        assert_eq!(envelope.error.details, json!({ "attempts": 3 }));
852    }
853}