Skip to main content

wenlan_types/
responses.rs

1// SPDX-License-Identifier: Apache-2.0
2//! API response types for all HTTP endpoints.
3
4use crate::entities::{Entity, EntitySearchResult};
5use crate::memory::{IndexedFileInfo, MemoryItem, MemoryStats, SearchResult};
6use crate::pages::Page;
7use crate::repair::RepairDigest;
8use crate::{Space, WriteOutcome, WriteSpaceSource};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12// ===== Memory CRUD =====
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct NearDuplicate {
16    pub source_id: String,
17    pub similarity: f64,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct StoreMemoryResponse {
22    pub source_id: String,
23    pub chunks_created: usize,
24    /// Memory type at the moment of persistence. If caller did not supply
25    /// one and enrichment is pending, this is a placeholder (`"fact"`) —
26    /// check `enrichment` field to know whether to expect it to change.
27    pub memory_type: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub entity_id: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub quality: Option<String>,
32    /// Schema-validation issues — actionable by the agent.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub warnings: Vec<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub near_duplicate: Option<NearDuplicate>,
37    /// True when the write was staged for human review instead of taking effect.
38    /// Only a store carrying `supersedes` from an agent whose `trust_level` is not
39    /// `"full"` stages. `source_id` is the staged revision's id, and it is what
40    /// `POST /api/memory/revision/{id}/accept` takes.
41    #[serde(default, skip_serializing_if = "is_false")]
42    pub gated: bool,
43    /// How structured fields were populated. "agent" | "llm" | "none" | "unknown" (forward-compat default).
44    #[serde(default = "default_extraction_method")]
45    pub extraction_method: String,
46    /// Enrichment state for the memory. `"pending"` when the quiet/cooldown-
47    /// gated ambient scheduler has authorized derived work remaining;
48    /// `"paused"` when no source is pinned or the pinned source is unavailable.
49    /// `"not_needed"` remains accepted as a legacy wire value from older
50    /// daemons. Machine-readable — Tauri app uses this to drive
51    /// polling / live-update UI, MCP callers can choose to relay state.
52    /// Defaulted for backward compatibility with older clients.
53    #[serde(default)]
54    pub enrichment: String,
55    /// Prose cue for caller agents — safe to relay to the user verbatim.
56    /// Communicates that Wenlan is compiling the memory into reusable
57    /// context in the background, so callers don't treat `None` enriched
58    /// fields as failure. Empty when the store completed fully sync.
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    pub hint: String,
61    #[serde(default)]
62    pub space: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub space_source: Option<WriteSpaceSource>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub write_outcome: Option<WriteOutcome>,
67}
68
69fn default_extraction_method() -> String {
70    "unknown".to_string()
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct SearchMemoryResponse {
75    pub results: Vec<SearchResult>,
76    pub took_ms: f64,
77    /// Distilled pages surfaced by the page channel during reranked search.
78    /// Absent when no page rows were returned (back-compat: old daemons never
79    /// set this field; old consumers that don't read it are unaffected).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub supplemental_pages: Option<Vec<SearchResult>>,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85pub struct ListMemoriesResponse {
86    pub memories: Vec<IndexedFileInfo>,
87}
88
89/// Shared wire format for any `deleted: bool` response.
90///
91/// Reused by:
92/// - `DELETE /api/memory/delete/{id}` (server/memory.rs)
93/// - `DELETE /api/documents/{source}/{source_id}` (server/ingest.rs)
94#[derive(Debug, Serialize, Deserialize)]
95pub struct DeleteResponse {
96    pub deleted: bool,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct ConfirmResponse {
101    pub confirmed: bool,
102    /// Whether a memory row actually matched the requested id. Defaulted to
103    /// `true` so a newer client reads an older daemon's 200 as a real update.
104    #[serde(default = "crate::requests::default_true")]
105    pub updated: bool,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109pub struct ReclassifyMemoryResponse {
110    pub source_id: String,
111    pub memory_type: String,
112}
113
114#[derive(Debug, Serialize, Deserialize)]
115pub struct MemoryStatsResponse {
116    pub stats: MemoryStats,
117}
118
119#[derive(Debug, Serialize, Deserialize)]
120pub struct NurtureCardsResponse {
121    pub cards: Vec<MemoryItem>,
122}
123
124// ===== General search/context =====
125
126#[derive(Debug, Serialize, Deserialize)]
127pub struct HealthResponse {
128    pub status: String,
129    pub db_initialized: bool,
130    pub version: String,
131}
132
133/// Cross-encoder reranker state, surfaced on `/api/status` so operators can see
134/// whether an opt-in reranker is actually wired vs. silently degraded.
135#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "state", rename_all = "snake_case")]
137pub enum RerankerStatus {
138    /// `WENLAN_RERANKER_ENABLED` was not `1` — no reranker requested.
139    #[default]
140    Disabled,
141    /// Reranker initialized and wired.
142    Active { model_id: String },
143    /// Reranker was requested but init failed (e.g. model download error);
144    /// search silently falls back to embedding+FTS ordering.
145    Failed { reason: String },
146}
147
148/// Background document-enrichment queue state, surfaced on `/api/status` so
149/// operators can see whether folder-ingest enrichment is progressing, idle, or
150/// paused on an LLM failure (waiting for a backoff retry). Mirrors the
151/// [`RerankerStatus`] tagged-enum shape.
152#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "state", rename_all = "snake_case")]
154pub enum QueueStatus {
155    /// No documents are queued for enrichment (nothing pending, in-progress, or paused).
156    #[default]
157    Idle,
158    /// Documents are queued or being enriched. `pending` counts rows not yet done.
159    Active { pending: u64 },
160    /// At least one document is paused after an LLM failure. `reason` is that
161    /// pause's failure reason, `next_retry_at` the earliest Unix-secs retry time
162    /// (the scheduler auto-resumes once it elapses — no daemon restart needed),
163    /// and `pending` counts rows not yet done.
164    Paused {
165        reason: String,
166        pending: u64,
167        #[serde(default, skip_serializing_if = "Option::is_none")]
168        next_retry_at: Option<i64>,
169    },
170}
171
172/// Observable runtime selected for the local llama.cpp model.
173///
174/// This is additive to `/api/status`: older daemons omit it and deserialize as
175/// `backend = "disabled"`. A GPU failure is visible through
176/// `fallback_reason` even when the provider recovered and is serving on CPU.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct OnDeviceInferenceStatus {
179    #[serde(default = "default_disabled_backend")]
180    pub backend: String,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub device: Option<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub device_index: Option<usize>,
185    #[serde(default)]
186    pub gpu_layers: u32,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub fallback_reason: Option<String>,
189}
190
191fn default_disabled_backend() -> String {
192    "disabled".to_string()
193}
194
195impl Default for OnDeviceInferenceStatus {
196    fn default() -> Self {
197        Self {
198            backend: default_disabled_backend(),
199            device: None,
200            device_index: None,
201            gpu_layers: 0,
202            fallback_reason: None,
203        }
204    }
205}
206
207/// Where this daemon stands on the M5 truth cutover.
208///
209/// The one thing a client cannot otherwise ask. Until this shipped, the durable
210/// cutover generation was reachable only from the daemon's own maintenance
211/// subcommand, so an app had no way to tell an enforcing daemon from a
212/// pass-through one — and the M5 review action, whose whole precondition is
213/// "the cutover is live", had nothing to gate itself on.
214///
215/// Carries no page identity, no title and no prose, which is why `/api/status`
216/// keeps its `page_bearing: no` classification in the reader manifest. It
217/// describes the daemon, not anything the daemon stores.
218///
219/// Additive on both sides of the wire, and **present only when the cutover is
220/// live**. A daemon that predates the field omits it, a daemon at generation 0
221/// omits it, and a daemon that could not read its own generation omits it —
222/// three situations with one honest reading, which is why they share one
223/// spelling. `None` is that reading: a client that cannot confirm the cutover is
224/// live must behave as though it is not.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226pub struct TruthStatus {
227    /// The durable cutover generation, always `> 0` as served by this daemon —
228    /// `0` means every truth adapter is pass-through, and that state is reported
229    /// by omitting the whole object rather than by sending a zero.
230    pub cutover_generation: i64,
231    /// The newest truth-contract version this daemon serves. A client declaring
232    /// a higher version is treated as legacy, so a client that reads this can
233    /// declare what the daemon actually understands instead of guessing.
234    pub contract_version: u32,
235}
236
237impl TruthStatus {
238    /// Is fail-closed provisional enforcement live?
239    ///
240    /// Presence of the object is the signal; this is the redundant floor for a
241    /// client holding a `TruthStatus` from somewhere that did send a zero.
242    pub const fn cutover_live(&self) -> bool {
243        self.cutover_generation > 0
244    }
245}
246
247#[derive(Debug, Serialize, Deserialize)]
248pub struct StatusResponse {
249    pub is_running: bool,
250    pub files_indexed: u64,
251    pub files_total: u64,
252    pub sources_connected: Vec<String>,
253    /// Background document-enrichment queue state (folder-ingest). Additive:
254    /// defaults to `Idle` so older daemons (which omit it) deserialize cleanly.
255    #[serde(default)]
256    pub queue: QueueStatus,
257    /// Compile-routing queue depth (spec §3.1/§7): clusters the last routed
258    /// compile left pending because no lane (cloud or healthy on-device) was
259    /// available. `Active { pending }` when nonzero, `Idle` otherwise; never
260    /// `Paused` (no retry/backoff concept for this gauge). Additive: defaults
261    /// to `Idle` so older daemons (which omit it) deserialize cleanly.
262    #[serde(default)]
263    pub compile_queue: QueueStatus,
264    /// Reranker on the DEEP path (`/api/memory/search` with `rerank=true`). Legacy
265    /// field — for `WENLAN_RERANKER_ENABLED=1` it is the configured model, exactly as before.
266    #[serde(default)]
267    pub reranker: RerankerStatus,
268    /// Reranker on the LIGHT paths — quick (`/api/search`) + context
269    /// (`/api/context`). Populated when `WENLAN_RERANKER_MODE` is `lite`/`full`.
270    /// Additive: defaults to `Disabled` so older daemons (which omit it) deserialize cleanly.
271    #[serde(default)]
272    pub reranker_light: RerankerStatus,
273    /// Resolved reranker mode: `"off"` | `"lite"` | `"full"`. Empty string for older
274    /// daemons that predate `WENLAN_RERANKER_MODE`.
275    #[serde(default)]
276    pub reranker_mode: String,
277    /// llama.cpp runtime used by the selected on-device provider. Additive:
278    /// defaults to `disabled` for older daemons and for installations without
279    /// a local model.
280    #[serde(default)]
281    pub on_device_inference: OnDeviceInferenceStatus,
282    /// Additive daemon capabilities. Clients must negotiate against this list
283    /// instead of inferring support from a version string.
284    #[serde(default)]
285    pub capabilities: Vec<String>,
286    /// M5 truth-cutover state. Absent on daemons that predate it, which a
287    /// client must read as "not live" rather than "unknown, proceed anyway".
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub truth: Option<TruthStatus>,
290}
291
292#[derive(Debug, Serialize, Deserialize)]
293pub struct SearchResponse {
294    pub results: Vec<SearchResult>,
295    pub took_ms: f64,
296    /// Distilled pages surfaced by the shared visibility gate on `/api/search`.
297    /// Absent when no page rows passed the space-scope + effective-tier gate
298    /// (back-compat: old daemons never set this field; old consumers that
299    /// don't read it are unaffected). Mirrors `SearchMemoryResponse`.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub supplemental_pages: Option<Vec<SearchResult>>,
302}
303
304#[doc(hidden)]
305#[derive(Debug, Serialize, Deserialize)]
306pub struct ContextSuggestion {
307    pub content: String,
308    pub score: f32,
309    pub source: String,
310}
311
312#[doc(hidden)]
313#[derive(Debug, Serialize, Deserialize)]
314pub struct ContextResponse {
315    pub suggestions: Vec<ContextSuggestion>,
316    pub took_ms: f64,
317}
318
319#[derive(Debug, Default, Serialize, Deserialize)]
320pub struct TierTokenEstimates {
321    pub tier1_identity: usize,
322    pub tier2_project: usize,
323    pub tier3_relevant: usize,
324    pub total: usize,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328pub struct ProfileContext {
329    pub narrative: String,
330    pub identity: Vec<String>,
331    pub preferences: Vec<String>,
332    /// Deprecated: goal taxonomy folded into Identity by migration 45 (Phase 0).
333    /// Always empty — daemon does not emit goal-typed memories. Field stays for
334    /// wire backward compat; will be removed in 0.4.
335    #[deprecated(
336        since = "0.3.2",
337        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
338                Always empty. Will be removed in 0.4."
339    )]
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub goals: Vec<String>,
342}
343
344#[derive(Debug, Serialize, Deserialize)]
345pub struct KnowledgeContext {
346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
347    pub pages: Vec<String>,
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub decisions: Vec<String>,
350    #[serde(default)]
351    pub relevant_memories: Vec<SearchResult>,
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub graph_context: Vec<String>,
354}
355
356#[derive(Debug, Serialize, Deserialize)]
357pub struct ChatContextResponse {
358    pub context: String,
359    pub profile: ProfileContext,
360    pub knowledge: KnowledgeContext,
361    pub took_ms: f64,
362    pub token_estimates: TierTokenEstimates,
363}
364
365// ===== Profile & Agents =====
366
367#[derive(Debug, Serialize, Deserialize)]
368pub struct ProfileResponse {
369    pub id: String,
370    pub name: String,
371    pub display_name: Option<String>,
372    pub email: Option<String>,
373    pub bio: Option<String>,
374    pub avatar_path: Option<String>,
375    pub created_at: i64,
376    pub updated_at: i64,
377}
378
379#[derive(Debug, Serialize, Deserialize)]
380pub struct AgentResponse {
381    pub id: String,
382    pub name: String,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub display_name: Option<String>,
385    pub agent_type: String,
386    pub description: Option<String>,
387    pub enabled: bool,
388    pub trust_level: String,
389    pub last_seen_at: Option<i64>,
390    pub memory_count: i64,
391    pub created_at: i64,
392    pub updated_at: i64,
393}
394
395// ===== Knowledge graph =====
396
397#[derive(Debug, Serialize, Deserialize)]
398pub struct CreateEntityResponse {
399    pub id: String,
400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
401    pub warnings: Vec<String>,
402    #[serde(default)]
403    pub space: Option<String>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub space_source: Option<WriteSpaceSource>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub write_outcome: Option<WriteOutcome>,
408}
409
410#[doc(hidden)]
411#[derive(Debug, Serialize, Deserialize)]
412pub struct CreateRelationResponse {
413    pub id: String,
414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
415    pub warnings: Vec<String>,
416}
417
418#[derive(Debug, Serialize, Deserialize)]
419pub struct AddObservationResponse {
420    pub id: String,
421    #[serde(default, skip_serializing_if = "Vec::is_empty")]
422    pub warnings: Vec<String>,
423}
424
425#[doc(hidden)]
426#[derive(Debug, Serialize, Deserialize)]
427pub struct CreatePageResponse {
428    pub id: String,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub attached_to: Option<String>,
431    #[serde(default, skip_serializing_if = "Vec::is_empty")]
432    pub warnings: Vec<String>,
433    #[serde(default)]
434    pub space: Option<String>,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub space_source: Option<WriteSpaceSource>,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub write_outcome: Option<WriteOutcome>,
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442pub struct ListEntitiesResponse {
443    pub entities: Vec<Entity>,
444}
445
446#[derive(Debug, Serialize, Deserialize)]
447pub struct SearchEntitiesResponse {
448    pub results: Vec<EntitySearchResult>,
449}
450
451/// `POST /api/memory/entities/{id}/merge` response. `applied` is `false`
452/// for a `dry_run` preview (nothing mutated) and `true` once the merge ran.
453#[derive(Debug, Serialize, Deserialize)]
454pub struct MergeEntityResponse {
455    pub canonical_id: String,
456    pub canonical_name: String,
457    pub loser_id: String,
458    pub loser_name: String,
459    /// Distinct memories newly linked to the canonical (both link sources
460    /// the merge moves). The graph hides superseded memories and pending
461    /// revisions, so it can gain fewer links than this count.
462    pub memory_links: u64,
463    pub observations: u64,
464    /// Distinct re-pointed edges newly landing on the canonical; a
465    /// loser↔canonical edge is retired, not re-pointed, and is not
466    /// counted.
467    pub edges: u64,
468    pub aliases_added: Vec<String>,
469    pub applied: bool,
470}
471
472/// `POST /api/memory/entities/{id}/aliases` response.
473#[derive(Debug, Serialize, Deserialize)]
474pub struct EntityAliasesResponse {
475    pub entity_id: String,
476    pub aliases: Vec<String>,
477}
478
479#[derive(Debug, Serialize, Deserialize)]
480pub struct SearchPagesResponse {
481    pub pages: Vec<Page>,
482}
483
484/// Wikilink graph centered on a single page. Outbound = labels parsed
485/// out of this page's body; `target_page_id` is `None` for orphans.
486/// Inbound = active pages whose body cites this title.
487#[derive(Debug, Serialize, Deserialize)]
488pub struct PageLinksResponse {
489    pub outbound: Vec<PageLinkOutbound>,
490    pub inbound: Vec<PageLinkInbound>,
491}
492
493#[derive(Debug, Serialize, Deserialize)]
494pub struct PageLinkOutbound {
495    pub label: String,
496    /// `None` when the resolver couldn't find a matching active page —
497    /// surfaces in the orphan-by-count feed via /api/pages/orphan-links.
498    pub target_page_id: Option<String>,
499}
500
501#[derive(Debug, Serialize, Deserialize)]
502pub struct PageLinkInbound {
503    pub source_page_id: String,
504    pub label: String,
505}
506
507// ===== Import =====
508
509#[derive(Debug, Serialize, Deserialize)]
510pub struct ImportMemoriesResponse {
511    pub imported: usize,
512    pub skipped: usize,
513    pub breakdown: HashMap<String, usize>,
514    pub entities_created: usize,
515    pub observations_added: usize,
516    pub relations_created: usize,
517    pub batch_id: String,
518    #[serde(default)]
519    pub space: Option<String>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub space_source: Option<WriteSpaceSource>,
522}
523
524#[derive(Debug, Serialize, Deserialize)]
525pub struct DefaultSpaceResponse {
526    pub space: Option<Space>,
527}
528
529// ===== Steep =====
530
531/// How loud Wenlan should be about a phase's output.
532#[doc(hidden)]
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
534pub enum Nudge {
535    Silent,
536    Ambient,
537    Notable,
538    Wow,
539}
540
541/// Result of a single phase within a steep cycle.
542#[doc(hidden)]
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct PhaseResult {
545    pub name: String,
546    pub duration_ms: u64,
547    pub items_processed: usize,
548    pub error: Option<String>,
549    pub nudge: Nudge,
550    pub headline: Option<String>,
551}
552
553#[doc(hidden)]
554#[derive(Debug, Serialize, Deserialize)]
555pub struct SteepResponse {
556    pub memories_decayed: u64,
557    pub recaps_generated: u32,
558    pub distilled: u32,
559    pub pending_remaining: u32,
560    pub phases: Vec<PhaseResult>,
561}
562
563// ===== Config =====
564
565#[derive(Debug, Serialize, Deserialize)]
566pub struct ConfigResponse {
567    pub skip_apps: Vec<String>,
568    pub skip_title_patterns: Vec<String>,
569    pub private_browsing_detection: bool,
570    pub setup_completed: bool,
571    pub clipboard_enabled: bool,
572    pub screen_capture_enabled: bool,
573    pub remote_access_enabled: bool,
574    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub routine_model: Option<String>,
577    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub synthesis_model: Option<String>,
580    /// Base URL for an OpenAI-compatible external LLM endpoint.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub external_llm_endpoint: Option<String>,
583    /// Model identifier to use with the external LLM endpoint.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub external_llm_model: Option<String>,
586    /// Whether an external-LLM API key is stored. The key value itself is
587    /// never serialized anywhere.
588    #[serde(default)]
589    pub external_llm_api_key_configured: bool,
590    /// Everyday-job source pin (raw config value): `"anthropic"` | `"external"`
591    /// | `"on_device"`, or absent/null when unpinned. Lets the app show the
592    /// pin the user chose distinctly from the resolved source.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub everyday_source: Option<String>,
595    /// Synthesis-job source pin (raw config value): `"anthropic"` |
596    /// `"external"`, or absent/null when unpinned.
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub synthesis_source: Option<String>,
599    /// Whether the proactive Page-Map suggestion phase is enabled. Default true.
600    #[serde(default)]
601    pub page_map_auto_suggest: bool,
602}
603
604// ===== On-device model =====
605
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
607pub struct OnDeviceModelEntry {
608    pub id: String,
609    pub display_name: String,
610    pub param_count: String,
611    pub ram_required_gb: f64,
612    pub file_size_gb: f64,
613    pub cached: bool,
614}
615
616/// Response envelope for `GET /api/on-device-model`.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618pub struct OnDeviceModelResponse {
619    /// ID of the model currently loaded in the daemon, if any.
620    pub loaded: Option<String>,
621    /// ID the user has selected in config; may differ from loaded when a
622    /// download is pending or a restart is needed.
623    pub selected: Option<String>,
624    /// All available models with per-model cache/download state.
625    pub models: Vec<OnDeviceModelEntry>,
626}
627
628// ===== Indexed files / chunks =====
629
630#[derive(Debug, Serialize, Deserialize)]
631pub struct IndexedFilesResponse {
632    pub files: Vec<IndexedFileInfo>,
633}
634
635#[derive(Debug, Serialize, Deserialize)]
636pub struct DeleteCountResponse {
637    pub deleted: usize,
638}
639
640// ===== Entity / Observation =====
641
642#[derive(Debug, Serialize, Deserialize)]
643pub struct SuccessResponse {
644    pub ok: bool,
645}
646
647fn is_false(value: &bool) -> bool {
648    !*value
649}
650
651#[derive(Debug, Serialize, Deserialize)]
652pub struct PageWriteResponse {
653    pub ok: bool,
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub revision_card_id: Option<String>,
656    #[serde(default, skip_serializing_if = "is_false")]
657    pub gated: bool,
658}
659
660/// Page draft create, update, and publish response envelope.
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct PageDraftResponse {
663    pub page: Page,
664}
665
666// ===== Memory detail =====
667
668#[derive(Debug, Serialize, Deserialize)]
669pub struct MemoryDetailResponse {
670    pub memory: Option<MemoryItem>,
671}
672
673/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct MemoryDetail {
676    pub id: String,
677    pub content: String,
678    pub title: String,
679    pub source_id: String,
680    pub chunk_index: i32,
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub chunk_type: Option<String>,
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub language: Option<String>,
685    #[serde(skip_serializing_if = "Option::is_none")]
686    pub semantic_unit: Option<String>,
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub byte_start: Option<i64>,
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub byte_end: Option<i64>,
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub summary: Option<String>,
693}
694
695/// A pending revision waiting for human approval (Protected tier supersede).
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct PendingRevision {
698    pub source_id: String,
699    pub content: String,
700    pub source_agent: Option<String>,
701}
702
703#[derive(Debug, Serialize, Deserialize)]
704pub struct VersionChainResponse {
705    pub versions: Vec<crate::memory::MemoryVersionItem>,
706}
707
708// ===== Tags =====
709
710#[derive(Debug, Serialize, Deserialize)]
711pub struct TagsResponse {
712    pub tags: Vec<String>,
713    #[serde(default)]
714    pub document_tags: HashMap<String, Vec<String>>,
715}
716
717// ===== Activity =====
718
719#[derive(Debug, Serialize, Deserialize)]
720pub struct ActivityResponse {
721    pub activities: Vec<crate::memory::AgentActivityRow>,
722}
723
724// ===== Decisions =====
725
726#[derive(Debug, Serialize, Deserialize)]
727pub struct DecisionsResponse {
728    pub decisions: Vec<MemoryItem>,
729}
730
731#[derive(Debug, Serialize, Deserialize)]
732pub struct DecisionDomainsResponse {
733    /// Kept as `domains` for one-release back-compat with callers of
734    /// `/api/decisions/domains`; rename to `spaces` in PR-A+1.
735    pub domains: Vec<String>,
736}
737
738// ===== Pinned =====
739
740#[derive(Debug, Serialize, Deserialize)]
741pub struct PinnedMemoriesResponse {
742    pub memories: Vec<MemoryItem>,
743}
744
745// ===== Ingest =====
746
747#[derive(Debug, Serialize, Deserialize)]
748pub struct IngestResponse {
749    pub chunks_created: usize,
750    pub document_id: String,
751}
752
753// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
754// `DeleteResponse { deleted: bool }` defined above — same wire format.
755
756// ===== Concept Export =====
757
758/// Statistics from a bulk page export operation (POST /api/pages/export).
759#[derive(Debug, Default, Serialize, Deserialize)]
760pub struct ExportStats {
761    pub exported: usize,
762    pub skipped: usize,
763    pub failed: usize,
764}
765
766#[derive(Debug, Deserialize, Serialize)]
767pub struct ExportPageResponse {
768    pub path: String,
769}
770
771// ===== Knowledge Directory =====
772
773#[derive(Debug, Deserialize, Serialize)]
774pub struct KnowledgePathResponse {
775    pub path: String,
776}
777
778#[derive(Debug, Deserialize, Serialize)]
779pub struct KnowledgeCountResponse {
780    pub count: u64,
781}
782
783// ===== Revision history =====
784
785/// One entry in a memory's supersede chain, returned by `/api/memory/{id}/revisions`.
786///
787/// `depth = 0` is the current (most-recent) memory; higher depths are older
788/// predecessors. `delta_summary` is `None` for the deepest entry (no predecessor
789/// to diff against) and computed heuristically for all shallower entries.
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct MemoryRevisionEntry {
792    pub source_id: String,
793    pub depth: i64,
794    pub title: String,
795    pub content_preview: String,
796    pub last_modified: i64,
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub source_agent: Option<String>,
799    #[serde(skip_serializing_if = "Option::is_none")]
800    pub supersede_mode: Option<String>,
801    #[serde(skip_serializing_if = "Option::is_none")]
802    pub delta_summary: Option<String>,
803}
804
805/// Response envelope for `/api/memory/{id}/revisions`.
806#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct ListMemoryRevisionsResponse {
808    pub current_source_id: String,
809    pub chain_depth: i64,
810    pub entries: Vec<MemoryRevisionEntry>,
811}
812
813/// One entry in a page's version changelog, returned by `/api/pages/{id}/revisions`.
814#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct PageChangelogEntry {
816    pub version: i64,
817    pub at: i64,
818    pub edited_by: String,
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub delta_summary: Option<String>,
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub incoming_source_ids: Option<Vec<String>>,
823    /// Human-readable summary of citation verification for this revision
824    /// (e.g. "3 verified, 1 unverified, 2 stripped"). None for revisions that
825    /// didn't touch citations.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub citations_summary: Option<String>,
828}
829
830/// Response envelope for `/api/pages/{id}/revisions`.
831#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct ListPageRevisionsResponse {
833    pub page_id: String,
834    pub current_version: i64,
835    pub user_edited: bool,
836    #[serde(skip_serializing_if = "Option::is_none")]
837    pub stale_reason: Option<String>,
838    pub entries: Vec<PageChangelogEntry>,
839}
840
841// ===== Sources =====
842
843#[doc(hidden)]
844#[derive(Debug, Clone, Serialize, Deserialize)]
845pub struct SyncStatsResponse {
846    pub files_found: usize,
847    pub ingested: usize,
848    pub skipped: usize,
849    pub errors: usize,
850}
851
852// ===== Refinement proposals =====
853
854/// The action type for a background-refinery proposal.
855///
856/// Used as the `action` tag in [`RefinementPayload`].
857#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
858#[serde(rename_all = "snake_case")]
859pub enum ProposalAction {
860    EntityMerge,
861    RelationConflict,
862    DetectContradiction,
863    SuggestEntity,
864    DedupMerge,
865    PageMerge,
866    CrossSpaceDiscovery,
867    PageKeepOrArchive,
868    LintRepairReview,
869    VocabPromote,
870    /// ponytail: deserialize-only catch-all. Lets a stale client decode a
871    /// newer daemon's action tag instead of failing the whole list. NEVER
872    /// constructed or serialized by the daemon (it only ever holds real
873    /// variants), so serialize-of-Unknown never happens in practice.
874    #[serde(other)]
875    Unknown,
876}
877
878#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
879#[serde(rename_all = "snake_case")]
880pub enum RefinementCardAction {
881    Accept,
882    Dismiss,
883    PickSpace,
884}
885
886/// Tagged-union payload emitted by the background refinery.
887///
888/// Each variant carries exactly the fields needed for that action type.
889/// Decoded at the route boundary so downstream consumers (MCP wrappers,
890/// agent skills) can pattern-match instead of inspecting raw JSON strings.
891#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
892#[serde(tag = "action", rename_all = "snake_case")]
893pub enum RefinementPayload {
894    EntityMerge {
895        existing_id: String,
896        new_id: String,
897        similarity: f64,
898    },
899    RelationConflict {
900        existing_id: String,
901        new_id: String,
902        from: String,
903        to: String,
904        old_type: String,
905        new_type: String,
906    },
907    DetectContradiction,
908    SuggestEntity {
909        #[serde(default, skip_serializing_if = "Option::is_none")]
910        name_hint: Option<String>,
911    },
912    DedupMerge,
913    PageMerge {
914        left_page_id: String,
915        right_page_id: String,
916        #[serde(default, skip_serializing_if = "Option::is_none")]
917        similarity: Option<f64>,
918        source_overlap: usize,
919        source_overlap_ratio: f64,
920    },
921    CrossSpaceDiscovery {
922        memory_count: usize,
923        spaces: Vec<String>,
924        allowed_actions: Vec<RefinementCardAction>,
925    },
926    PageKeepOrArchive {
927        page_id: String,
928        source_count: usize,
929        allowed_actions: Vec<RefinementCardAction>,
930    },
931    LintRepairReview {
932        check_id: String,
933        occurrence_digest: RepairDigest,
934        owner_binding_digest: RepairDigest,
935        issue: String,
936        choices: Vec<String>,
937        suggested_research_queries: Vec<String>,
938    },
939    VocabPromote {
940        kind: String,
941        old_value: String,
942        #[serde(default, skip_serializing_if = "Option::is_none")]
943        category: Option<String>,
944    },
945}
946
947#[derive(Debug, Serialize, Deserialize, Clone)]
948pub struct RefinementProposalSummary {
949    pub id: String,
950    pub action: ProposalAction,
951    pub source_ids: Vec<String>,
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub payload: Option<RefinementPayload>,
954    pub confidence: f64,
955    pub created_at: String,
956}
957
958#[derive(Debug, Serialize, Deserialize, Clone, Default)]
959pub struct ListRefinementsResponse {
960    pub proposals: Vec<RefinementProposalSummary>,
961}
962
963#[derive(Debug, Serialize, Deserialize, Clone)]
964pub struct RejectRefinementResponse {
965    pub id: String,
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize)]
969pub struct AcceptRefinementResponse {
970    pub id: String,
971    pub action_applied: String,
972}
973
974#[cfg(test)]
975mod refinement_wire_tests {
976    use super::*;
977
978    #[test]
979    fn proposal_action_serde_round_trip() {
980        let cases = [
981            ("\"entity_merge\"", ProposalAction::EntityMerge),
982            ("\"relation_conflict\"", ProposalAction::RelationConflict),
983            (
984                "\"detect_contradiction\"",
985                ProposalAction::DetectContradiction,
986            ),
987            ("\"suggest_entity\"", ProposalAction::SuggestEntity),
988            ("\"dedup_merge\"", ProposalAction::DedupMerge),
989            (
990                "\"cross_space_discovery\"",
991                ProposalAction::CrossSpaceDiscovery,
992            ),
993            (
994                "\"page_keep_or_archive\"",
995                ProposalAction::PageKeepOrArchive,
996            ),
997            ("\"vocab_promote\"", ProposalAction::VocabPromote),
998        ];
999        for (json, expected) in cases {
1000            let parsed: ProposalAction = serde_json::from_str(json).unwrap();
1001            assert_eq!(parsed, expected, "deserialize {json}");
1002            let back = serde_json::to_string(&expected).unwrap();
1003            assert_eq!(back, json, "serialize {expected:?}");
1004        }
1005    }
1006
1007    #[test]
1008    fn vocab_promote_payload_round_trips() {
1009        let p = RefinementPayload::VocabPromote {
1010            kind: "relation".into(),
1011            old_value: "design_inspiration".into(),
1012            category: None,
1013        };
1014        let json = serde_json::to_string(&p).unwrap();
1015        let back: RefinementPayload = serde_json::from_str(&json).unwrap();
1016        assert_eq!(p, back);
1017        assert!(json.contains("\"action\":\"vocab_promote\""));
1018    }
1019
1020    #[test]
1021    fn proposal_action_unknown_future_variant_deserializes() {
1022        // A stale client must decode a NEWER daemon's action tag to Unknown,
1023        // never error the whole ListRefinementsResponse.
1024        let parsed: ProposalAction = serde_json::from_str("\"future_unshipped_action\"").unwrap();
1025        assert_eq!(parsed, ProposalAction::Unknown);
1026        let parsed2: ProposalAction = serde_json::from_str("\"totally_new_action\"").unwrap();
1027        assert_eq!(parsed2, ProposalAction::Unknown);
1028    }
1029
1030    #[test]
1031    fn refinement_payload_entity_merge_round_trip() {
1032        let json =
1033            r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
1034        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1035        match parsed {
1036            RefinementPayload::EntityMerge {
1037                ref existing_id,
1038                ref new_id,
1039                similarity,
1040            } => {
1041                assert_eq!(existing_id, "e1");
1042                assert_eq!(new_id, "e2");
1043                assert!((similarity - 0.87).abs() < 1e-9);
1044            }
1045            _ => panic!("expected EntityMerge variant"),
1046        }
1047        let back = serde_json::to_value(&parsed).unwrap();
1048        assert_eq!(back["action"], "entity_merge");
1049        assert_eq!(back["existing_id"], "e1");
1050    }
1051
1052    #[test]
1053    fn refinement_payload_dedup_merge_no_fields() {
1054        let json = r#"{"action":"dedup_merge"}"#;
1055        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1056        assert!(matches!(parsed, RefinementPayload::DedupMerge));
1057    }
1058
1059    #[test]
1060    fn refinement_payload_cross_space_discovery_round_trip() {
1061        let json = r#"{"action":"cross_space_discovery","memory_count":3,"spaces":["personal","work"],"allowed_actions":["dismiss","pick_space"]}"#;
1062        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1063        match parsed {
1064            RefinementPayload::CrossSpaceDiscovery {
1065                memory_count,
1066                ref spaces,
1067                ref allowed_actions,
1068            } => {
1069                assert_eq!(memory_count, 3);
1070                assert_eq!(spaces, &vec!["personal".to_string(), "work".to_string()]);
1071                assert_eq!(
1072                    allowed_actions,
1073                    &vec![
1074                        RefinementCardAction::Dismiss,
1075                        RefinementCardAction::PickSpace
1076                    ]
1077                );
1078            }
1079            _ => panic!("expected CrossSpaceDiscovery"),
1080        }
1081        let back = serde_json::to_value(&parsed).unwrap();
1082        assert_eq!(back["action"], "cross_space_discovery");
1083        assert_eq!(back["memory_count"], 3);
1084    }
1085
1086    #[test]
1087    fn refinement_payload_page_keep_or_archive_round_trip() {
1088        let json = r#"{"action":"page_keep_or_archive","page_id":"page_stub","source_count":1,"allowed_actions":["dismiss","accept"]}"#;
1089        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1090        match parsed {
1091            RefinementPayload::PageKeepOrArchive {
1092                ref page_id,
1093                source_count,
1094                ref allowed_actions,
1095            } => {
1096                assert_eq!(page_id, "page_stub");
1097                assert_eq!(source_count, 1);
1098                assert_eq!(
1099                    allowed_actions,
1100                    &vec![RefinementCardAction::Dismiss, RefinementCardAction::Accept]
1101                );
1102            }
1103            _ => panic!("expected PageKeepOrArchive"),
1104        }
1105        let back = serde_json::to_value(&parsed).unwrap();
1106        assert_eq!(back["action"], "page_keep_or_archive");
1107        assert_eq!(back["source_count"], 1);
1108    }
1109
1110    #[test]
1111    fn refinement_payload_relation_conflict_round_trip() {
1112        let json = r#"{"action":"relation_conflict","existing_id":"r1","new_id":"r2","from":"e_a","to":"e_b","old_type":"works_at","new_type":"founded"}"#;
1113        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1114        match parsed {
1115            RefinementPayload::RelationConflict {
1116                ref existing_id,
1117                ref new_id,
1118                ref from,
1119                ref to,
1120                ref old_type,
1121                ref new_type,
1122            } => {
1123                assert_eq!(existing_id, "r1");
1124                assert_eq!(new_id, "r2");
1125                assert_eq!(from, "e_a");
1126                assert_eq!(to, "e_b");
1127                assert_eq!(old_type, "works_at");
1128                assert_eq!(new_type, "founded");
1129            }
1130            _ => panic!("expected RelationConflict"),
1131        }
1132        let back = serde_json::to_value(&parsed).unwrap();
1133        assert_eq!(back["from"], "e_a");
1134        assert_eq!(back["to"], "e_b");
1135    }
1136
1137    #[test]
1138    fn refinement_payload_detect_contradiction_unit_variant() {
1139        let json = r#"{"action":"detect_contradiction"}"#;
1140        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1141        assert!(matches!(parsed, RefinementPayload::DetectContradiction));
1142    }
1143
1144    #[test]
1145    fn refinement_payload_suggest_entity_with_name_hint() {
1146        let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
1147        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1148        match parsed {
1149            RefinementPayload::SuggestEntity { ref name_hint } => {
1150                assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
1151            }
1152            _ => panic!("expected SuggestEntity"),
1153        }
1154    }
1155
1156    #[test]
1157    fn refinement_payload_suggest_entity_without_name_hint() {
1158        let json = r#"{"action":"suggest_entity"}"#;
1159        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1160        assert!(matches!(
1161            parsed,
1162            RefinementPayload::SuggestEntity { name_hint: None }
1163        ));
1164    }
1165
1166    #[test]
1167    fn list_refinements_response_round_trip() {
1168        let resp = ListRefinementsResponse {
1169            proposals: vec![RefinementProposalSummary {
1170                id: "ref_1".into(),
1171                action: ProposalAction::EntityMerge,
1172                source_ids: vec!["a".into(), "b".into()],
1173                payload: Some(RefinementPayload::EntityMerge {
1174                    existing_id: "a".into(),
1175                    new_id: "b".into(),
1176                    similarity: 0.86,
1177                }),
1178                confidence: 0.86,
1179                created_at: "2026-05-12T00:00:00Z".into(),
1180            }],
1181        };
1182        let json = serde_json::to_string(&resp).unwrap();
1183        let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
1184        assert_eq!(parsed.proposals.len(), 1);
1185        assert_eq!(parsed.proposals[0].id, "ref_1");
1186        assert!(matches!(
1187            parsed.proposals[0].action,
1188            ProposalAction::EntityMerge
1189        ));
1190    }
1191
1192    #[test]
1193    fn reject_refinement_response_round_trip() {
1194        let resp = RejectRefinementResponse { id: "ref_x".into() };
1195        let json = serde_json::to_string(&resp).unwrap();
1196        let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
1197        assert_eq!(parsed.id, "ref_x");
1198    }
1199}
1200
1201#[cfg(test)]
1202mod on_device_model_response_tests {
1203    use super::*;
1204
1205    #[test]
1206    fn on_device_model_response_preserves_selected_loaded_and_models() {
1207        let response = OnDeviceModelResponse {
1208            loaded: Some("qwen3-4b".to_string()),
1209            selected: Some("qwen3-4b".to_string()),
1210            models: vec![OnDeviceModelEntry {
1211                id: "qwen3-4b".to_string(),
1212                display_name: "Qwen3 4B".to_string(),
1213                param_count: "4B".to_string(),
1214                ram_required_gb: 6.0,
1215                file_size_gb: 2.7,
1216                cached: true,
1217            }],
1218        };
1219
1220        let value = serde_json::to_value(&response).unwrap();
1221
1222        assert_eq!(value["loaded"], "qwen3-4b");
1223        assert_eq!(value["selected"], "qwen3-4b");
1224        assert_eq!(value["models"][0]["id"], "qwen3-4b");
1225        assert_eq!(value["models"][0]["cached"], true);
1226
1227        let parsed: OnDeviceModelResponse = serde_json::from_value(value).unwrap();
1228        assert_eq!(parsed.loaded.as_deref(), Some("qwen3-4b"));
1229        assert_eq!(parsed.selected.as_deref(), Some("qwen3-4b"));
1230        assert_eq!(parsed.models.len(), 1);
1231        assert!(parsed.models[0].cached);
1232    }
1233
1234    #[test]
1235    fn on_device_model_response_allows_null_loaded_and_selected() {
1236        let parsed: OnDeviceModelResponse =
1237            serde_json::from_str(r#"{"loaded":null,"selected":null,"models":[]}"#).unwrap();
1238
1239        assert!(parsed.loaded.is_none());
1240        assert!(parsed.selected.is_none());
1241        assert!(parsed.models.is_empty());
1242    }
1243}
1244
1245/// One orphaned page link label aggregated across sources.
1246///
1247/// `count` is how many distinct source pages reference this label
1248/// without a matching target page existing.
1249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1250pub struct OrphanLink {
1251    pub label: String,
1252    pub count: i64,
1253}
1254
1255/// Response for `GET /api/pages/orphan-links`.
1256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1257pub struct OrphanLinksResponse {
1258    pub min_count: usize,
1259    pub orphan_labels: Vec<OrphanLink>,
1260}
1261
1262/// What kind of thing a staged revision proposes to rewrite.
1263///
1264/// A reader needs this to render the revision at all: the "before" side of a
1265/// memory revision is a memory, and of a page revision is a page, and the two
1266/// live in different tables behind different reads. Without it a client is
1267/// left sniffing id prefixes, which is not a contract.
1268#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1269#[serde(rename_all = "lowercase")]
1270pub enum RevisionTargetKind {
1271    /// `target_source_id` names a `memories.source_id`.
1272    #[default]
1273    Memory,
1274    /// `target_source_id` names a `pages.id`.
1275    Page,
1276}
1277
1278/// One pending revision awaiting human accept/dismiss.
1279///
1280/// `target_source_id` is the thing being revised; pass it to
1281/// `accept_pending_revision` or `dismiss_pending_revision`. It is a memory or
1282/// a page according to `target_kind` -- both resolve through the same two
1283/// verbs, but a reader must know which one to fetch for the "before" side.
1284/// `revision_source_id` is the staged revision row itself, exposed
1285/// for diagnostics and round-tripping (not for the action call).
1286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1287pub struct PendingRevisionItem {
1288    pub target_source_id: String,
1289    pub revision_source_id: String,
1290    pub revision_content: String,
1291    pub source_agent: Option<String>,
1292    pub last_modified: i64,
1293    /// Which table `target_source_id` points into. Defaults to `memory` so a
1294    /// response from a daemon that predates the field still deserializes --
1295    /// every producer before it staged memory targets only.
1296    #[serde(default)]
1297    pub target_kind: RevisionTargetKind,
1298    /// Doc file source_id that grounds a doc-grounded revision (L3); None for
1299    /// other revision producers. Read from structured_fields.grounded_in.
1300    #[serde(default, skip_serializing_if = "Option::is_none")]
1301    pub grounded_in: Option<String>,
1302}
1303
1304/// Response returned by `POST /api/memory/revision/{id}/accept`.
1305/// Carries the now-consumed revision row id so agents can correlate with
1306/// their `list_pending_revisions` cache.
1307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1308pub struct RevisionAcceptResponse {
1309    pub target_source_id: String,
1310    pub revision_source_id: String,
1311    pub wrote: bool,
1312}
1313
1314/// Response returned by `POST /api/memory/revision/{id}/dismiss`.
1315/// `wrote: true` always (404 on missing).
1316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1317pub struct RevisionDismissResponse {
1318    pub target_source_id: String,
1319    pub wrote: bool,
1320}
1321
1322/// Response returned by `POST /api/memory/contradiction/{source_id}/dismiss`.
1323/// `wrote: true` is best-effort: the daemon's underlying DB method silently
1324/// no-ops when no rows match. Wrapper cannot distinguish dismiss-of-existing
1325/// from dismiss-of-nothing without an extra SELECT (out of scope).
1326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1327pub struct ContradictionDismissResponse {
1328    pub source_id: String,
1329    pub wrote: bool,
1330}
1331
1332#[cfg(test)]
1333mod mutation_response_tests {
1334    use super::*;
1335
1336    #[test]
1337    fn revision_accept_response_serializes_byte_identical() {
1338        let r = RevisionAcceptResponse {
1339            target_source_id: "mem_target".into(),
1340            revision_source_id: "mem_rev".into(),
1341            wrote: true,
1342        };
1343        assert_eq!(
1344            serde_json::to_string(&r).unwrap(),
1345            r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
1346        );
1347    }
1348
1349    #[test]
1350    fn revision_dismiss_response_serializes_byte_identical() {
1351        let r = RevisionDismissResponse {
1352            target_source_id: "mem_target".into(),
1353            wrote: true,
1354        };
1355        assert_eq!(
1356            serde_json::to_string(&r).unwrap(),
1357            r#"{"target_source_id":"mem_target","wrote":true}"#
1358        );
1359    }
1360
1361    #[test]
1362    fn contradiction_dismiss_response_serializes_byte_identical() {
1363        let r = ContradictionDismissResponse {
1364            source_id: "mem_abc".into(),
1365            wrote: true,
1366        };
1367        assert_eq!(
1368            serde_json::to_string(&r).unwrap(),
1369            r#"{"source_id":"mem_abc","wrote":true}"#
1370        );
1371    }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376    use super::*;
1377
1378    #[test]
1379    fn store_memory_response_deserializes_without_extraction_method() {
1380        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
1381        let json = r#"{
1382            "source_id": "mem_abc",
1383            "chunks_created": 3,
1384            "memory_type": "fact"
1385        }"#;
1386        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1387        assert_eq!(parsed.source_id, "mem_abc");
1388        assert_eq!(parsed.chunks_created, 3);
1389        assert_eq!(parsed.memory_type, "fact");
1390        assert_eq!(parsed.extraction_method, "unknown");
1391        assert!(parsed.warnings.is_empty());
1392    }
1393
1394    #[test]
1395    fn store_memory_response_deserializes_with_all_fields() {
1396        let json = r#"{
1397            "source_id": "mem_abc",
1398            "chunks_created": 3,
1399            "memory_type": "fact",
1400            "warnings": ["decision memory missing claim"],
1401            "extraction_method": "llm"
1402        }"#;
1403        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1404        assert_eq!(parsed.warnings.len(), 1);
1405        assert_eq!(parsed.extraction_method, "llm");
1406    }
1407
1408    #[test]
1409    fn store_memory_response_exposes_enrichment_and_hint() {
1410        // The daemon returns immediately after upsert and reports quiet
1411        // deferred enrichment via `enrichment` + `hint`.
1412        let json = r#"{
1413            "source_id": "mem_xyz",
1414            "chunks_created": 1,
1415            "memory_type": "fact",
1416            "warnings": [],
1417            "extraction_method": "unknown",
1418            "enrichment": "pending",
1419            "hint": "Stored. Recall is available now; Wenlan will quietly enrich classification and page links in the background."
1420        }"#;
1421        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1422        assert_eq!(parsed.enrichment, "pending");
1423        assert!(parsed.hint.contains("quietly enrich"));
1424    }
1425
1426    #[test]
1427    fn store_memory_response_defaults_enrichment_for_older_responses() {
1428        // Backward-compat: existing clients (wenlan-mcp, Tauri app) that
1429        // deserialize pre-async-refactor responses must keep working.
1430        let json = r#"{
1431            "source_id": "mem_old",
1432            "chunks_created": 1,
1433            "memory_type": "fact"
1434        }"#;
1435        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1436        assert_eq!(parsed.enrichment, ""); // default
1437        assert_eq!(parsed.hint, ""); // default
1438    }
1439
1440    #[test]
1441    fn tags_response_defaults_document_tags_for_older_responses() {
1442        let json = r#"{"tags":["rust","tauri"]}"#;
1443        let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1444
1445        assert_eq!(parsed.tags, vec!["rust", "tauri"]);
1446        assert!(parsed.document_tags.is_empty());
1447    }
1448
1449    #[test]
1450    fn tags_response_deserializes_document_tag_map() {
1451        let json = r#"{
1452            "tags":["rust","tauri"],
1453            "document_tags":{"memory::mem1":["rust"],"page::page1":["tauri"]}
1454        }"#;
1455        let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1456
1457        assert_eq!(
1458            parsed.document_tags.get("memory::mem1"),
1459            Some(&vec!["rust".to_string()])
1460        );
1461        assert_eq!(
1462            parsed.document_tags.get("page::page1"),
1463            Some(&vec!["tauri".to_string()])
1464        );
1465    }
1466
1467    #[test]
1468    fn store_memory_response_roundtrips_not_needed_state() {
1469        // Keep accepting the legacy state emitted by older daemons.
1470        let response = StoreMemoryResponse {
1471            source_id: "mem_no_llm".into(),
1472            chunks_created: 1,
1473            memory_type: "fact".into(),
1474            entity_id: None,
1475            quality: None,
1476            warnings: vec![],
1477            near_duplicate: None,
1478            gated: false,
1479            extraction_method: "none".into(),
1480            enrichment: "not_needed".into(),
1481            hint: String::new(),
1482            space: None,
1483            space_source: None,
1484            write_outcome: None,
1485        };
1486        let json = serde_json::to_string(&response).unwrap();
1487        assert!(json.contains("\"enrichment\":\"not_needed\""));
1488        assert!(
1489            !json.contains("\"hint\""),
1490            "empty hint must be skipped on the wire, got: {json}"
1491        );
1492        assert!(
1493            !json.contains("\"near_duplicate\""),
1494            "near_duplicate: None must be skipped on the wire, got: {json}"
1495        );
1496        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1497        assert_eq!(parsed.enrichment, "not_needed");
1498        assert_eq!(parsed.hint, "");
1499    }
1500
1501    #[test]
1502    fn gated_flag_is_absent_from_the_wire_when_false() {
1503        let response = StoreMemoryResponse {
1504            source_id: "mem_not_gated".into(),
1505            chunks_created: 1,
1506            memory_type: "fact".into(),
1507            entity_id: None,
1508            quality: None,
1509            warnings: vec![],
1510            near_duplicate: None,
1511            gated: false,
1512            extraction_method: "none".into(),
1513            enrichment: String::new(),
1514            hint: String::new(),
1515            space: None,
1516            space_source: None,
1517            write_outcome: None,
1518        };
1519        let json = serde_json::to_string(&response).unwrap();
1520        assert!(
1521            !json.contains("\"gated\""),
1522            "gated: false must be skipped on the wire, got: {json}"
1523        );
1524        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1525        assert!(!parsed.gated);
1526    }
1527
1528    #[test]
1529    fn store_memory_response_roundtrips_paused_state_with_hint() {
1530        let response = StoreMemoryResponse {
1531            source_id: "mem_paused".into(),
1532            chunks_created: 1,
1533            memory_type: "fact".into(),
1534            entity_id: None,
1535            quality: None,
1536            warnings: vec![],
1537            near_duplicate: None,
1538            gated: false,
1539            extraction_method: "none".into(),
1540            enrichment: "paused".into(),
1541            hint: "Stored; choose a model source to enable enrichment.".into(),
1542            space: None,
1543            space_source: None,
1544            write_outcome: None,
1545        };
1546        let json = serde_json::to_string(&response).unwrap();
1547        assert!(json.contains("\"enrichment\":\"paused\""));
1548        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1549        assert_eq!(parsed.enrichment, "paused");
1550        assert!(parsed.hint.contains("choose a model source"));
1551    }
1552
1553    #[test]
1554    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1555        // ProfileContext.goals is deprecated; constructing it directly here
1556        // for wire roundtrip coverage until 0.4 drops the field entirely.
1557        #[allow(deprecated)]
1558        let profile = ProfileContext {
1559            narrative: "n".into(),
1560            identity: vec![],
1561            preferences: vec![],
1562            goals: vec![],
1563        };
1564        let response = ChatContextResponse {
1565            context: "context".into(),
1566            profile,
1567            knowledge: KnowledgeContext {
1568                pages: vec![],
1569                decisions: vec![],
1570                relevant_memories: vec![],
1571                graph_context: vec![],
1572            },
1573            took_ms: 1.0,
1574            token_estimates: TierTokenEstimates {
1575                tier1_identity: 1,
1576                tier2_project: 2,
1577                tier3_relevant: 3,
1578                total: 6,
1579            },
1580        };
1581
1582        let json = serde_json::to_string(&response).unwrap();
1583        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1584        assert!(parsed.knowledge.pages.is_empty());
1585        assert!(parsed.knowledge.decisions.is_empty());
1586        assert!(parsed.knowledge.relevant_memories.is_empty());
1587        assert!(parsed.knowledge.graph_context.is_empty());
1588    }
1589
1590    #[test]
1591    fn orphan_links_response_golden_string() {
1592        let resp = OrphanLinksResponse {
1593            min_count: 2,
1594            orphan_labels: vec![OrphanLink {
1595                label: "Rust".to_string(),
1596                count: 3,
1597            }],
1598        };
1599        let s = serde_json::to_string(&resp).unwrap();
1600        assert_eq!(
1601            s,
1602            r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1603        );
1604    }
1605
1606    #[test]
1607    fn orphan_links_response_empty_round_trip() {
1608        let resp = OrphanLinksResponse {
1609            min_count: 1,
1610            orphan_labels: vec![],
1611        };
1612        let decoded: OrphanLinksResponse =
1613            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1614        assert_eq!(decoded, resp);
1615    }
1616
1617    #[test]
1618    fn pending_revision_item_round_trip() {
1619        let item = PendingRevisionItem {
1620            target_source_id: "mem_target".into(),
1621            revision_source_id: "mem_rev".into(),
1622            revision_content: "new body".into(),
1623            source_agent: Some("claude-code".into()),
1624            last_modified: 1_715_000_000,
1625            target_kind: RevisionTargetKind::Memory,
1626            grounded_in: None,
1627        };
1628        let json = serde_json::to_value(&item).unwrap();
1629        assert_eq!(json["target_source_id"], "mem_target");
1630        assert_eq!(json["revision_source_id"], "mem_rev");
1631        assert_eq!(json["revision_content"], "new body");
1632        assert_eq!(json["target_kind"], "memory");
1633        let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1634        assert_eq!(decoded, item);
1635    }
1636
1637    /// A page card must say so on the wire, and a payload from a daemon that
1638    /// predates the field must still read as a memory revision rather than
1639    /// failing to deserialize.
1640    #[test]
1641    fn pending_revision_item_carries_the_target_kind() {
1642        let page_item = PendingRevisionItem {
1643            target_source_id: "page_abc".into(),
1644            revision_source_id: "mem_rev".into(),
1645            revision_content: "new page body".into(),
1646            source_agent: Some("page_write".into()),
1647            last_modified: 1_715_000_000,
1648            target_kind: RevisionTargetKind::Page,
1649            grounded_in: None,
1650        };
1651        let json = serde_json::to_value(&page_item).unwrap();
1652        assert_eq!(json["target_kind"], "page");
1653        let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1654        assert_eq!(decoded, page_item);
1655
1656        let legacy = serde_json::json!({
1657            "target_source_id": "mem_target",
1658            "revision_source_id": "mem_rev",
1659            "revision_content": "new body",
1660            "source_agent": null,
1661            "last_modified": 1_715_000_000i64,
1662        });
1663        let decoded: PendingRevisionItem = serde_json::from_value(legacy).unwrap();
1664        assert_eq!(decoded.target_kind, RevisionTargetKind::Memory);
1665    }
1666}
1667
1668#[cfg(test)]
1669mod queue_status_tests {
1670    use super::*;
1671
1672    #[test]
1673    fn status_response_defaults_queue_to_idle_when_absent() {
1674        // Old daemons omit `queue` entirely — it must default to Idle so the
1675        // wire change stays additive (a new client reads an old response).
1676        let json =
1677            r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1678        let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1679        assert_eq!(parsed.queue, QueueStatus::Idle);
1680        assert_eq!(
1681            parsed.on_device_inference,
1682            OnDeviceInferenceStatus::default()
1683        );
1684    }
1685
1686    #[test]
1687    fn on_device_inference_status_round_trips_vulkan_device_and_fallback() {
1688        let status = OnDeviceInferenceStatus {
1689            backend: "vulkan".into(),
1690            device: Some("NVIDIA GeForce RTX 3060 Laptop GPU".into()),
1691            device_index: Some(2),
1692            gpu_layers: 99,
1693            fallback_reason: None,
1694        };
1695        let json = serde_json::to_string(&status).unwrap();
1696        let parsed: OnDeviceInferenceStatus = serde_json::from_str(&json).unwrap();
1697
1698        assert_eq!(parsed, status);
1699        assert!(json.contains("\"backend\":\"vulkan\""));
1700        assert!(json.contains("RTX 3060"));
1701
1702        let fallback: OnDeviceInferenceStatus = serde_json::from_str(
1703            r#"{"backend":"cpu","gpu_layers":0,"fallback_reason":"Vulkan context creation failed"}"#,
1704        )
1705        .unwrap();
1706        assert_eq!(fallback.backend, "cpu");
1707        assert_eq!(
1708            fallback.fallback_reason.as_deref(),
1709            Some("Vulkan context creation failed")
1710        );
1711    }
1712
1713    #[test]
1714    fn queue_status_paused_round_trips_with_reason_and_retry() {
1715        let s = QueueStatus::Paused {
1716            reason: "analysis LLM failed".into(),
1717            pending: 2,
1718            next_retry_at: Some(1_712_678_400),
1719        };
1720        let json = serde_json::to_string(&s).unwrap();
1721        assert!(json.contains("\"state\":\"paused\""), "got: {json}");
1722        assert!(
1723            json.contains("\"reason\":\"analysis LLM failed\""),
1724            "got: {json}"
1725        );
1726        assert!(json.contains("\"next_retry_at\":1712678400"), "got: {json}");
1727        assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1728    }
1729
1730    #[test]
1731    fn queue_status_active_round_trips() {
1732        let s = QueueStatus::Active { pending: 3 };
1733        let json = serde_json::to_string(&s).unwrap();
1734        assert!(json.contains("\"state\":\"active\""), "got: {json}");
1735        assert!(json.contains("\"pending\":3"), "got: {json}");
1736        assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1737    }
1738
1739    #[test]
1740    fn queue_status_idle_serializes_state_only() {
1741        let json = serde_json::to_string(&QueueStatus::Idle).unwrap();
1742        assert_eq!(json, r#"{"state":"idle"}"#);
1743    }
1744}
1745
1746#[cfg(test)]
1747mod reranker_status_tests {
1748    use super::*;
1749
1750    #[test]
1751    fn status_response_defaults_reranker_to_disabled() {
1752        // Old daemons omit reranker AND the newer reranker_light/reranker_mode fields
1753        // entirely — all three must default cleanly (additive wire change, no break).
1754        let json =
1755            r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1756        let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1757        assert_eq!(parsed.reranker, RerankerStatus::Disabled);
1758        assert_eq!(parsed.reranker_light, RerankerStatus::Disabled);
1759        assert_eq!(parsed.reranker_mode, "");
1760    }
1761
1762    #[test]
1763    fn status_response_roundtrips_per_path_reranker() {
1764        let s = StatusResponse {
1765            is_running: true,
1766            files_indexed: 0,
1767            files_total: 0,
1768            sources_connected: vec![],
1769            queue: QueueStatus::Idle,
1770            compile_queue: QueueStatus::Idle,
1771            reranker: RerankerStatus::Active {
1772                model_id: "BGERerankerBase".into(),
1773            },
1774            reranker_light: RerankerStatus::Active {
1775                model_id: "JINARerankerV1TurboEn".into(),
1776            },
1777            reranker_mode: "full".into(),
1778            on_device_inference: OnDeviceInferenceStatus::default(),
1779            capabilities: vec!["default_save_space".into()],
1780            truth: None,
1781        };
1782        let json = serde_json::to_string(&s).unwrap();
1783        let parsed: StatusResponse = serde_json::from_str(&json).unwrap();
1784        assert_eq!(parsed.reranker, s.reranker);
1785        assert_eq!(parsed.reranker_light, s.reranker_light);
1786        assert_eq!(parsed.reranker_mode, "full");
1787    }
1788
1789    #[test]
1790    fn reranker_status_active_roundtrips() {
1791        let s = RerankerStatus::Active {
1792            model_id: "BGERerankerBase".into(),
1793        };
1794        let json = serde_json::to_string(&s).unwrap();
1795        assert_eq!(serde_json::from_str::<RerankerStatus>(&json).unwrap(), s);
1796        assert!(json.contains("\"state\":\"active\""));
1797    }
1798}
1799
1800#[cfg(test)]
1801mod search_memory_response_tests {
1802    use super::SearchMemoryResponse;
1803
1804    /// Old daemon responses (no `supplemental_pages` key) must deserialize
1805    /// successfully with `supplemental_pages == None`.  This locks in the
1806    /// back-compat guarantee: clients talking to an older daemon never see a
1807    /// deserialization error.
1808    #[test]
1809    fn back_compat_missing_supplemental_pages_is_none() {
1810        let json = r#"{"results":[],"took_ms":1.0}"#;
1811        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1812        assert!(
1813            resp.supplemental_pages.is_none(),
1814            "should be None when key absent"
1815        );
1816        assert_eq!(resp.took_ms, 1.0);
1817    }
1818
1819    /// `supplemental_pages` absent means the field is omitted on the wire
1820    /// (skip_serializing_if = "Option::is_none").
1821    #[test]
1822    fn none_supplemental_pages_not_serialized() {
1823        let resp = SearchMemoryResponse {
1824            results: vec![],
1825            took_ms: 2.0,
1826            supplemental_pages: None,
1827        };
1828        let json = serde_json::to_string(&resp).expect("serialize");
1829        assert!(
1830            !json.contains("supplemental_pages"),
1831            "None field must be omitted from wire: {}",
1832            json
1833        );
1834    }
1835
1836    /// When pages are present they round-trip correctly.
1837    #[test]
1838    fn some_supplemental_pages_round_trips() {
1839        let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1840        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1841        assert!(
1842            resp.supplemental_pages.is_some(),
1843            "supplemental_pages should be Some"
1844        );
1845        assert!(
1846            resp.supplemental_pages.unwrap().is_empty(),
1847            "empty array should deserialize to empty vec"
1848        );
1849    }
1850}
1851
1852#[cfg(test)]
1853mod search_response_tests {
1854    use super::SearchResponse;
1855
1856    /// Old daemon responses (no `supplemental_pages` key) must deserialize
1857    /// successfully with `supplemental_pages == None`. Back-compat guarantee:
1858    /// a client built against the new wire type can still read an older
1859    /// `/api/search` response that predates the additive page field.
1860    #[test]
1861    fn back_compat_missing_supplemental_pages_is_none() {
1862        let json = r#"{"results":[],"took_ms":1.0}"#;
1863        let resp: SearchResponse = serde_json::from_str(json).expect("should deserialize");
1864        assert!(
1865            resp.supplemental_pages.is_none(),
1866            "should be None when key absent"
1867        );
1868        assert_eq!(resp.took_ms, 1.0);
1869    }
1870
1871    /// `supplemental_pages == None` is omitted on the wire
1872    /// (skip_serializing_if = "Option::is_none").
1873    #[test]
1874    fn none_supplemental_pages_not_serialized() {
1875        let resp = SearchResponse {
1876            results: vec![],
1877            took_ms: 2.0,
1878            supplemental_pages: None,
1879        };
1880        let json = serde_json::to_string(&resp).expect("serialize");
1881        assert!(
1882            !json.contains("supplemental_pages"),
1883            "None field must be omitted from wire: {}",
1884            json
1885        );
1886    }
1887
1888    /// When pages are present they round-trip correctly.
1889    #[test]
1890    fn some_supplemental_pages_round_trips() {
1891        let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1892        let resp: SearchResponse = serde_json::from_str(json).expect("deserialize");
1893        assert!(
1894            resp.supplemental_pages.is_some(),
1895            "supplemental_pages should be Some"
1896        );
1897        assert!(
1898            resp.supplemental_pages.unwrap().is_empty(),
1899            "empty array should deserialize to empty vec"
1900        );
1901    }
1902}
1903
1904/// What a successful page review records, and what a retry of it replays.
1905///
1906/// This is the whole of §7's allowed payload and nothing else: protocol
1907/// version, nonce **digest**, verification time, the page version and content
1908/// digest the human actually read, and caller/operation identity. No HMAC, no
1909/// raw nonce, no secret — which is why this type can be stored as the receipt,
1910/// returned in the response, and logged, all from one shape.
1911#[derive(Debug, Clone, Serialize, Deserialize)]
1912pub struct PageReviewReceipt {
1913    pub page_id: String,
1914    pub human_reviewed: bool,
1915    /// The version the digest below belongs to, read from the row rather than
1916    /// taken from the request.
1917    pub reviewed_page_version: i64,
1918    pub reviewed_page_digest: String,
1919    pub protocol_version: u32,
1920    pub nonce_digest: String,
1921    pub verified_at: i64,
1922    pub caller_id: String,
1923    pub operation_id: String,
1924}