Skip to main content

subcog/services/
capture.rs

1//! Memory capture service.
2//!
3//! Handles capturing new memories, including validation, redaction, and storage.
4//! Now also generates embeddings and indexes memories for searchability.
5//!
6//! # Examples
7//!
8//! Basic capture:
9//!
10//! ```
11//! use subcog::services::CaptureService;
12//! use subcog::models::{CaptureRequest, Namespace, Domain};
13//!
14//! let service = CaptureService::default();
15//! let request = CaptureRequest {
16//!     content: "Use PostgreSQL for primary storage".to_string(),
17//!     namespace: Namespace::Decisions,
18//!     domain: Domain::default(),
19//!     tags: vec!["database".to_string()],
20//!     source: Some("ARCHITECTURE.md".to_string()),
21//!     skip_security_check: false,
22//!     ttl_seconds: None,
23//!     scope: None,
24//!     ..Default::default()
25//! };
26//!
27//! let result = service.capture(request).expect("capture should succeed");
28//! assert!(result.urn.starts_with("subcog://"));
29//! ```
30//!
31//! Validation before capture:
32//!
33//! ```
34//! use subcog::services::CaptureService;
35//! use subcog::models::{CaptureRequest, Namespace, Domain};
36//!
37//! let service = CaptureService::default();
38//! let request = CaptureRequest {
39//!     content: "Important decision".to_string(),
40//!     namespace: Namespace::Decisions,
41//!     domain: Domain::default(),
42//!     tags: vec![],
43//!     source: None,
44//!     skip_security_check: false,
45//!     ttl_seconds: None,
46//!     scope: None,
47//!     ..Default::default()
48//! };
49//!
50//! let validation = service.validate(&request).expect("validation should succeed");
51//! if validation.is_valid {
52//!     let _result = service.capture(request);
53//! }
54//! ```
55
56use crate::config::Config;
57use crate::context::GitContext;
58use crate::embedding::Embedder;
59use crate::gc::{ExpirationConfig, ExpirationService};
60use crate::models::{
61    CaptureRequest, CaptureResult, EventMeta, Memory, MemoryEvent, MemoryId, MemoryStatus,
62};
63use crate::observability::current_request_id;
64use crate::security::{ContentRedactor, SecretDetector, record_event};
65use crate::services::deduplication::ContentHasher;
66use crate::storage::index::{SqliteBackend, get_user_data_dir};
67use crate::storage::traits::{IndexBackend, VectorBackend};
68use crate::{Error, Result};
69use std::path::Path;
70use std::sync::Arc;
71use std::time::{Instant, SystemTime, UNIX_EPOCH};
72use tracing::{info_span, instrument};
73
74/// Callback type for post-capture entity extraction.
75///
76/// Called after successful capture with the memory content and ID.
77/// Should extract entities and store them in the knowledge graph.
78/// Errors are logged but do not fail the capture operation.
79pub type EntityExtractionCallback =
80    Arc<dyn Fn(&str, &MemoryId) -> Result<EntityExtractionStats> + Send + Sync>;
81
82/// Statistics from entity extraction during capture.
83#[derive(Debug, Clone, Default)]
84pub struct EntityExtractionStats {
85    /// Number of entities extracted and stored.
86    pub entities_stored: usize,
87    /// Number of relationships extracted and stored.
88    pub relationships_stored: usize,
89    /// Whether the extraction used fallback (no LLM).
90    pub used_fallback: bool,
91}
92
93/// Runs entity extraction and logs results/metrics.
94///
95/// Extracted to a separate function to support both async (`tokio::spawn`) and
96/// sync (inline) execution paths.
97fn run_entity_extraction(callback: &EntityExtractionCallback, content: &str, memory_id: &MemoryId) {
98    let _span = info_span!(
99        "subcog.memory.capture.entity_extraction",
100        memory_id = %memory_id
101    )
102    .entered();
103
104    match callback(content, memory_id) {
105        Ok(stats) => {
106            tracing::debug!(
107                memory_id = %memory_id,
108                entities = stats.entities_stored,
109                relationships = stats.relationships_stored,
110                fallback = stats.used_fallback,
111                "Entity extraction completed"
112            );
113            metrics::counter!(
114                "entity_extraction_total",
115                "status" => "success",
116                "fallback" => if stats.used_fallback { "true" } else { "false" }
117            )
118            .increment(1);
119        },
120        Err(e) => {
121            tracing::warn!(
122                memory_id = %memory_id,
123                error = %e,
124                "Entity extraction failed"
125            );
126            metrics::counter!("entity_extraction_total", "status" => "error").increment(1);
127        },
128    }
129}
130
131/// Service for capturing memories.
132///
133/// # Storage Layers
134///
135/// When fully configured, captures are written to two layers:
136/// 1. **Index** (`SQLite` FTS5) - Authoritative storage with full-text search via BM25
137/// 2. **Vector** (usearch) - Semantic similarity search
138///
139/// # Entity Extraction
140///
141/// When configured with an entity extraction callback and the feature is enabled,
142/// entities (people, organizations, technologies, concepts) are automatically
143/// extracted from captured content and stored in the knowledge graph for
144/// graph-augmented retrieval (Graph RAG).
145///
146/// # Graceful Degradation
147///
148/// If embedder or vector backend is unavailable:
149/// - Capture still succeeds (Index layer is authoritative)
150/// - A warning is logged for each failed secondary store
151/// - The memory may not be searchable via semantic similarity
152///
153/// If entity extraction fails:
154/// - Capture still succeeds
155/// - A warning is logged
156/// - The memory won't have graph relationships
157pub struct CaptureService {
158    /// Configuration.
159    config: Config,
160    /// Secret detector.
161    secret_detector: SecretDetector,
162    /// Content redactor.
163    redactor: ContentRedactor,
164    /// Embedder for generating embeddings (optional).
165    embedder: Option<Arc<dyn Embedder>>,
166    /// Index backend for full-text search (optional).
167    index: Option<Arc<dyn IndexBackend + Send + Sync>>,
168    /// Vector backend for similarity search (optional).
169    vector: Option<Arc<dyn VectorBackend + Send + Sync>>,
170    /// Entity extraction callback for graph-augmented retrieval (optional).
171    entity_extraction: Option<EntityExtractionCallback>,
172    /// Expiration configuration for probabilistic TTL cleanup (optional).
173    ///
174    /// When set, a probabilistic cleanup of TTL-expired memories is triggered
175    /// after each successful capture (default 5% probability).
176    expiration_config: Option<ExpirationConfig>,
177    /// Organization-scoped index backend (optional).
178    ///
179    /// When set and the capture request has `scope: Some(DomainScope::Org)`,
180    /// the memory is stored in the org-shared index instead of the user-local index.
181    org_index: Option<Arc<dyn IndexBackend + Send + Sync>>,
182}
183
184impl CaptureService {
185    /// Creates a new capture service with `SQLite` storage backend.
186    ///
187    /// Automatically initializes the `SQLite` backend from the user data directory.
188    /// If initialization fails, the service is created without storage (captures
189    /// will not persist) and a warning is logged.
190    ///
191    /// For full searchability with vector search, use [`with_backends`](Self::with_backends)
192    /// to also configure embedder and vector backends.
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use subcog::config::Config;
198    /// use subcog::services::CaptureService;
199    ///
200    /// let config = Config::default();
201    /// let service = CaptureService::new(config);
202    /// assert!(!service.has_embedder());
203    /// // Index is auto-initialized from user data dir
204    /// ```
205    #[must_use]
206    pub fn new(config: Config) -> Self {
207        let index = Self::try_init_sqlite_backend(config.data_dir.as_deref());
208
209        Self {
210            config,
211            secret_detector: SecretDetector::new(),
212            redactor: ContentRedactor::new(),
213            embedder: None,
214            index,
215            vector: None,
216            entity_extraction: None,
217            expiration_config: None,
218            org_index: None,
219        }
220    }
221
222    /// Creates a capture service without auto-initializing storage backends.
223    ///
224    /// Use this for testing graceful degradation or when storage initialization
225    /// should be handled explicitly by the caller.
226    ///
227    /// Unlike [`new`](Self::new), this does not attempt to create a `SQLite`
228    /// backend from the user data directory.
229    #[must_use]
230    pub fn new_minimal(config: Config) -> Self {
231        Self {
232            config,
233            secret_detector: SecretDetector::new(),
234            redactor: ContentRedactor::new(),
235            embedder: None,
236            index: None,
237            vector: None,
238            entity_extraction: None,
239            expiration_config: None,
240            org_index: None,
241        }
242    }
243
244    /// Attempts to initialize `SQLite` backend from user data directory.
245    ///
246    /// Returns `Some(backend)` on success, `None` on failure (with warning logged).
247    fn try_init_sqlite_backend(
248        config_data_dir: Option<&Path>,
249    ) -> Option<Arc<dyn IndexBackend + Send + Sync>> {
250        // Use config data_dir if provided (hooks pass this from SubcogConfig),
251        // otherwise fall back to platform-specific user data directory.
252        let data_dir = match config_data_dir {
253            Some(dir) => dir.to_path_buf(),
254            None => match get_user_data_dir() {
255                Ok(dir) => dir,
256                Err(e) => {
257                    tracing::warn!(
258                        "CaptureService: unable to get user data dir ({e}) - captures will not persist"
259                    );
260                    return None;
261                },
262            },
263        };
264
265        if let Err(e) = std::fs::create_dir_all(&data_dir) {
266            tracing::warn!(
267                "CaptureService: unable to create data dir ({e}) - captures will not persist"
268            );
269            return None;
270        }
271
272        let db_path = data_dir.join("index.db");
273        match SqliteBackend::new(&db_path) {
274            Ok(backend) => Some(Arc::new(backend)),
275            Err(e) => {
276                tracing::warn!(
277                    "CaptureService: unable to open SQLite backend ({e}) - captures will not persist"
278                );
279                None
280            },
281        }
282    }
283
284    /// Creates a capture service with all storage backends.
285    ///
286    /// This enables:
287    /// - Embedding generation during capture
288    /// - Immediate indexing for text search
289    /// - Immediate vector upsert for semantic search
290    #[must_use]
291    pub fn with_backends(
292        config: Config,
293        embedder: Arc<dyn Embedder>,
294        index: Arc<dyn IndexBackend + Send + Sync>,
295        vector: Arc<dyn VectorBackend + Send + Sync>,
296    ) -> Self {
297        Self {
298            config,
299            secret_detector: SecretDetector::new(),
300            redactor: ContentRedactor::new(),
301            embedder: Some(embedder),
302            index: Some(index),
303            vector: Some(vector),
304            entity_extraction: None,
305            expiration_config: None,
306            org_index: None,
307        }
308    }
309
310    /// Adds an embedder to an existing capture service.
311    #[must_use]
312    pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
313        self.embedder = Some(embedder);
314        self
315    }
316
317    /// Adds an index backend to an existing capture service.
318    #[must_use]
319    pub fn with_index(mut self, index: Arc<dyn IndexBackend + Send + Sync>) -> Self {
320        self.index = Some(index);
321        self
322    }
323
324    /// Adds a vector backend to an existing capture service.
325    #[must_use]
326    pub fn with_vector(mut self, vector: Arc<dyn VectorBackend + Send + Sync>) -> Self {
327        self.vector = Some(vector);
328        self
329    }
330
331    /// Adds an org-scoped index backend for shared memory storage.
332    ///
333    /// When configured and a capture request has `scope: Some(DomainScope::Org)`,
334    /// the memory will be stored in the org-shared index instead of the user-local index.
335    #[must_use]
336    pub fn with_org_index(mut self, index: Arc<dyn IndexBackend + Send + Sync>) -> Self {
337        self.org_index = Some(index);
338        self
339    }
340
341    /// Returns whether an org-scoped index is configured.
342    #[must_use]
343    pub fn has_org_index(&self) -> bool {
344        self.org_index.is_some()
345    }
346
347    /// Adds an entity extraction callback for graph-augmented retrieval.
348    ///
349    /// When configured, entities (people, organizations, technologies, concepts)
350    /// are automatically extracted from captured content and stored in the
351    /// knowledge graph. This enables Graph RAG (Retrieval-Augmented Generation)
352    /// by finding related memories through entity relationships.
353    ///
354    /// # Arguments
355    ///
356    /// * `callback` - A callback that extracts entities from content and stores them.
357    ///   The callback receives the content and memory ID, and should return stats.
358    ///   Errors are logged but don't fail the capture operation.
359    ///
360    /// # Examples
361    ///
362    /// ```ignore
363    /// use std::sync::Arc;
364    /// use subcog::services::{CaptureService, EntityExtractionStats};
365    ///
366    /// let callback = Arc::new(|content: &str, memory_id: &MemoryId| {
367    ///     // Extract entities and store in graph...
368    ///     Ok(EntityExtractionStats::default())
369    /// });
370    ///
371    /// let service = CaptureService::default()
372    ///     .with_entity_extraction(callback);
373    /// ```
374    #[must_use]
375    pub fn with_entity_extraction(mut self, callback: EntityExtractionCallback) -> Self {
376        self.entity_extraction = Some(callback);
377        self
378    }
379
380    /// Returns whether entity extraction is configured.
381    #[must_use]
382    pub fn has_entity_extraction(&self) -> bool {
383        self.entity_extraction.is_some()
384    }
385
386    /// Returns whether embedding generation is available.
387    #[must_use]
388    pub fn has_embedder(&self) -> bool {
389        self.embedder.is_some()
390    }
391
392    /// Returns whether immediate indexing is available.
393    #[must_use]
394    pub fn has_index(&self) -> bool {
395        self.index.is_some()
396    }
397
398    /// Returns whether vector upsert is available.
399    #[must_use]
400    pub fn has_vector(&self) -> bool {
401        self.vector.is_some()
402    }
403
404    /// Adds expiration configuration for probabilistic TTL cleanup.
405    ///
406    /// When configured, a probabilistic cleanup of TTL-expired memories is
407    /// triggered after each successful capture. By default, there is a 5%
408    /// chance of running cleanup after each capture.
409    ///
410    /// # Examples
411    ///
412    /// ```
413    /// use subcog::services::CaptureService;
414    /// use subcog::gc::ExpirationConfig;
415    ///
416    /// // Enable expiration cleanup with default 5% probability
417    /// let service = CaptureService::default()
418    ///     .with_expiration_config(ExpirationConfig::default());
419    ///
420    /// // Or with custom probability (10%)
421    /// let config = ExpirationConfig::new().with_cleanup_probability(0.10);
422    /// let service = CaptureService::default()
423    ///     .with_expiration_config(config);
424    /// ```
425    #[must_use]
426    pub const fn with_expiration_config(mut self, config: ExpirationConfig) -> Self {
427        self.expiration_config = Some(config);
428        self
429    }
430
431    /// Returns whether expiration cleanup is configured.
432    #[must_use]
433    pub const fn has_expiration(&self) -> bool {
434        self.expiration_config.is_some()
435    }
436
437    /// Captures a memory.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if:
442    /// - The content is empty
443    /// - The content contains unredacted secrets (when blocking is enabled)
444    /// - Storage fails
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// use subcog::services::CaptureService;
450    /// use subcog::models::{CaptureRequest, Namespace, Domain};
451    ///
452    /// let service = CaptureService::default();
453    /// let request = CaptureRequest {
454    ///     content: "Use SQLite for local development".to_string(),
455    ///     namespace: Namespace::Decisions,
456    ///     domain: Domain::default(),
457    ///     tags: vec!["database".to_string(), "architecture".to_string()],
458    ///     source: Some("src/config.rs".to_string()),
459    ///     skip_security_check: false,
460    ///     ttl_seconds: None,
461    ///     scope: None,
462    ///     ..Default::default()
463    /// };
464    ///
465    /// let result = service.capture(request)?;
466    /// assert!(!result.memory_id.as_str().is_empty());
467    /// assert!(result.urn.starts_with("subcog://"));
468    /// # Ok::<(), subcog::Error>(())
469    /// ```
470    #[instrument(
471        name = "subcog.memory.capture",
472        skip(self, request),
473        fields(
474            request_id = tracing::field::Empty,
475            component = "memory",
476            operation = "capture",
477            namespace = %request.namespace,
478            domain = %request.domain,
479            content_length = request.content.len(),
480            skip_security_check = request.skip_security_check,
481            memory.id = tracing::field::Empty
482        )
483    )]
484    #[allow(clippy::too_many_lines)]
485    pub fn capture(&self, request: CaptureRequest) -> Result<CaptureResult> {
486        let start = Instant::now();
487        let namespace_label = request.namespace.as_str().to_string();
488        let domain_label = request.domain.to_string();
489        if let Some(request_id) = current_request_id() {
490            tracing::Span::current().record("request_id", request_id.as_str());
491        }
492
493        tracing::info!(namespace = %namespace_label, domain = %domain_label, "Capturing memory");
494
495        // Maximum content size (500KB) - prevents abuse and memory issues (MED-SEC-002, MED-COMP-003)
496        const MAX_CONTENT_SIZE: usize = 500_000;
497
498        let result = (|| {
499            let has_secrets = {
500                let _span = info_span!("subcog.memory.capture.validate").entered();
501                // Validate content length (MED-SEC-002, MED-COMP-003)
502                if request.content.trim().is_empty() {
503                    return Err(Error::InvalidInput("Content cannot be empty".to_string()));
504                }
505                if request.content.len() > MAX_CONTENT_SIZE {
506                    return Err(Error::InvalidInput(format!(
507                        "Content exceeds maximum size of {} bytes (got {} bytes)",
508                        MAX_CONTENT_SIZE,
509                        request.content.len()
510                    )));
511                }
512
513                // Check for secrets
514                let has_secrets = self.secret_detector.contains_secrets(&request.content);
515                if has_secrets && self.config.features.block_secrets && !request.skip_security_check
516                {
517                    return Err(Error::ContentBlocked {
518                        reason: "Content contains detected secrets".to_string(),
519                    });
520                }
521                has_secrets
522            };
523
524            // Optionally redact secrets
525            let (content, was_redacted) = {
526                let _span = info_span!("subcog.memory.capture.redact").entered();
527                if has_secrets
528                    && self.config.features.redact_secrets
529                    && !request.skip_security_check
530                {
531                    (self.redactor.redact(&request.content), true)
532                } else {
533                    (request.content.clone(), false)
534                }
535            };
536
537            // Get current timestamp
538            let now = SystemTime::now()
539                .duration_since(UNIX_EPOCH)
540                .map(|d| d.as_secs())
541                .unwrap_or(0);
542
543            // Generate memory ID from UUID (12 hex chars for consistency)
544            let uuid = uuid::Uuid::new_v4();
545            let memory_id = MemoryId::new(uuid.to_string().replace('-', "")[..12].to_string());
546
547            let span = tracing::Span::current();
548            span.record("memory.id", memory_id.as_str());
549
550            // Generate embedding if embedder is available
551            let embedding = {
552                let _span = info_span!("subcog.memory.capture.embed").entered();
553                if let Some(ref embedder) = self.embedder {
554                    match embedder.embed(&content) {
555                        Ok(emb) => {
556                            tracing::debug!(
557                                memory_id = %memory_id,
558                                dimensions = emb.len(),
559                                "Generated embedding for memory"
560                            );
561                            Some(emb)
562                        },
563                        Err(e) => {
564                            tracing::warn!(
565                                memory_id = %memory_id,
566                                error = %e,
567                                "Failed to generate embedding (continuing without)"
568                            );
569                            None
570                        },
571                    }
572                } else {
573                    None
574                }
575            };
576
577            // Resolve git context for facets.
578            let git_context = self
579                .config
580                .repo_path
581                .as_ref()
582                .map_or_else(GitContext::from_cwd, |path| GitContext::from_path(path));
583
584            let file_path =
585                resolve_file_path(self.config.repo_path.as_deref(), request.source.as_ref());
586
587            let mut tags = request.tags;
588            let hash_tag = ContentHasher::content_to_tag(&content);
589            if !tags.iter().any(|tag| tag == &hash_tag) {
590                tags.push(hash_tag);
591            }
592
593            // Calculate expires_at from TTL if provided
594            // ttl_seconds = 0 means "never expire" (expires_at = None)
595            // ttl_seconds > 0 means expires at now + ttl_seconds
596            let expires_at = request.ttl_seconds.and_then(|ttl| {
597                if ttl == 0 {
598                    None // Explicit "never expire"
599                } else {
600                    Some(now.saturating_add(ttl))
601                }
602            });
603
604            // Create memory
605            let mut memory = Memory {
606                id: memory_id.clone(),
607                content,
608                namespace: request.namespace,
609                domain: request.domain,
610                project_id: git_context.project_id,
611                branch: git_context.branch,
612                file_path,
613                status: MemoryStatus::Active,
614                created_at: now,
615                updated_at: now,
616                tombstoned_at: None,
617                expires_at,
618                embedding: embedding.clone(),
619                tags,
620                #[cfg(feature = "group-scope")]
621                group_id: request.group_id,
622                source: request.source,
623                is_summary: false,
624                source_memory_ids: None,
625                consolidation_timestamp: None,
626            };
627
628            // Generate URN (always use subcog:// format)
629            let urn = self.generate_urn(&memory);
630
631            // Collect warnings
632            let mut warnings = Vec::new();
633            if was_redacted {
634                warnings.push("Content was redacted due to detected secrets".to_string());
635            }
636
637            // Index memory for text search (best-effort)
638            if let Some(ref index) = self.index {
639                let _span = info_span!("subcog.memory.capture.index").entered();
640                // Need mutable access - clone the Arc and get mutable ref
641                let index_clone = Arc::clone(index);
642                // IndexBackend::index takes &self with interior mutability
643                match index_clone.index(&memory) {
644                    Ok(()) => {
645                        tracing::debug!(memory_id = %memory_id, "Indexed memory for text search");
646                    },
647                    Err(e) => {
648                        tracing::warn!(
649                            memory_id = %memory_id,
650                            error = %e,
651                            "Failed to index memory (continuing without)"
652                        );
653                        warnings.push("Memory not indexed for text search".to_string());
654                    },
655                }
656            }
657
658            // Upsert embedding to vector store (best-effort)
659            if let (Some(vector), Some(emb)) = (&self.vector, &embedding) {
660                let _span = info_span!("subcog.memory.capture.vector").entered();
661                // VectorBackend::upsert takes &self with interior mutability
662                let vector_clone = Arc::clone(vector);
663                match vector_clone.upsert(&memory_id, emb) {
664                    Ok(()) => {
665                        tracing::debug!(memory_id = %memory_id, "Upserted embedding to vector store");
666                    },
667                    Err(e) => {
668                        tracing::warn!(
669                            memory_id = %memory_id,
670                            error = %e,
671                            "Failed to upsert embedding (continuing without)"
672                        );
673                        warnings.push("Embedding not stored in vector index".to_string());
674                    },
675                }
676            }
677
678            // Extract entities for graph-augmented retrieval (async when possible, sync fallback)
679            // Entity extraction runs in the background to avoid blocking capture latency.
680            if self.config.features.auto_extract_entities
681                && let Some(ref callback) = self.entity_extraction
682            {
683                let callback = Arc::clone(callback);
684                let content = memory.content.clone();
685                let memory_id_for_task = memory_id.clone();
686
687                // Check if we're in a tokio runtime context
688                if tokio::runtime::Handle::try_current().is_ok() {
689                    // Async path: spawn background task
690                    tokio::spawn(async move {
691                        run_entity_extraction(&callback, &content, &memory_id_for_task);
692                    });
693                } else {
694                    // Sync path: run inline (for tests/CLI without async runtime)
695                    run_entity_extraction(&callback, &content, &memory_id_for_task);
696                }
697            }
698
699            // Clear embedding from memory before returning (it's stored separately)
700            memory.embedding = None;
701
702            record_event(MemoryEvent::Captured {
703                meta: EventMeta::with_timestamp("capture", current_request_id(), now),
704                memory_id: memory_id.clone(),
705                namespace: memory.namespace,
706                domain: memory.domain.clone(),
707                content_length: memory.content.len(),
708            });
709            if was_redacted {
710                record_event(MemoryEvent::Redacted {
711                    meta: EventMeta::with_timestamp("capture", current_request_id(), now),
712                    memory_id: memory_id.clone(),
713                    redaction_type: "secrets".to_string(),
714                });
715            }
716
717            Ok(CaptureResult {
718                memory_id,
719                urn,
720                content_modified: was_redacted,
721                warnings,
722            })
723        })();
724
725        let status = if result.is_ok() { "success" } else { "error" };
726        metrics::counter!(
727            "memory_operations_total",
728            "operation" => "capture",
729            "namespace" => namespace_label.clone(),
730            "domain" => domain_label,
731            "status" => status
732        )
733        .increment(1);
734        metrics::histogram!(
735            "memory_operation_duration_ms",
736            "operation" => "capture",
737            "namespace" => namespace_label
738        )
739        .record(start.elapsed().as_secs_f64() * 1000.0);
740        metrics::histogram!(
741            "memory_lifecycle_duration_ms",
742            "component" => "memory",
743            "operation" => "capture"
744        )
745        .record(start.elapsed().as_secs_f64() * 1000.0);
746
747        // Probabilistic TTL cleanup (only on success, with configured index)
748        if result.is_ok() {
749            self.maybe_run_expiration_cleanup();
750        }
751
752        result
753    }
754
755    /// Probabilistically runs expiration cleanup of TTL-expired memories.
756    ///
757    /// This is called after each successful capture to lazily clean up
758    /// expired memories without requiring a separate scheduled job.
759    fn maybe_run_expiration_cleanup(&self) {
760        // Need both expiration config and index backend
761        let (Some(config), Some(index)) = (&self.expiration_config, &self.index) else {
762            return;
763        };
764
765        // Create a temporary expiration service to check probability
766        let service = ExpirationService::new(Arc::clone(index), config.clone());
767
768        if !service.should_run_cleanup() {
769            return;
770        }
771
772        // Run cleanup (best-effort, don't fail capture)
773        let _span = info_span!("subcog.memory.capture.expiration_cleanup").entered();
774        match service.gc_expired_memories(false) {
775            Ok(result) => {
776                if result.memories_tombstoned > 0 {
777                    tracing::info!(
778                        tombstoned = result.memories_tombstoned,
779                        checked = result.memories_checked,
780                        duration_ms = result.duration_ms,
781                        "Probabilistic TTL cleanup completed"
782                    );
783                } else {
784                    tracing::debug!(
785                        checked = result.memories_checked,
786                        duration_ms = result.duration_ms,
787                        "Probabilistic TTL cleanup found no expired memories"
788                    );
789                }
790            },
791            Err(e) => {
792                tracing::warn!(
793                    error = %e,
794                    "Probabilistic TTL cleanup failed (capture still succeeded)"
795                );
796            },
797        }
798    }
799
800    /// Generates a URN for the memory.
801    #[allow(clippy::unused_self)] // Method kept for potential future use of self
802    fn generate_urn(&self, memory: &Memory) -> String {
803        let domain_part = if memory.domain.is_project_scoped() {
804            "project".to_string()
805        } else {
806            memory.domain.to_string()
807        };
808
809        format!(
810            "subcog://{}/{}/{}",
811            domain_part,
812            memory.namespace.as_str(),
813            memory.id.as_str()
814        )
815    }
816
817    /// Validates a capture request without storing.
818    ///
819    /// # Errors
820    ///
821    /// Returns an error if validation fails.
822    ///
823    /// # Examples
824    ///
825    /// ```
826    /// use subcog::services::CaptureService;
827    /// use subcog::models::{CaptureRequest, Namespace, Domain};
828    ///
829    /// let service = CaptureService::default();
830    ///
831    /// // Valid request
832    /// let request = CaptureRequest {
833    ///     content: "Valid content".to_string(),
834    ///     namespace: Namespace::Learnings,
835    ///     domain: Domain::default(),
836    ///     tags: vec![],
837    ///     source: None,
838    ///     skip_security_check: false,
839    ///     ttl_seconds: None,
840    ///     scope: None,
841    ///     ..Default::default()
842    /// };
843    /// let result = service.validate(&request)?;
844    /// assert!(result.is_valid);
845    ///
846    /// // Empty content is invalid
847    /// let empty_request = CaptureRequest {
848    ///     content: "".to_string(),
849    ///     namespace: Namespace::Learnings,
850    ///     domain: Domain::default(),
851    ///     tags: vec![],
852    ///     source: None,
853    ///     skip_security_check: false,
854    ///     ttl_seconds: None,
855    ///     scope: None,
856    ///     ..Default::default()
857    /// };
858    /// let result = service.validate(&empty_request)?;
859    /// assert!(!result.is_valid);
860    /// # Ok::<(), subcog::Error>(())
861    /// ```
862    pub fn validate(&self, request: &CaptureRequest) -> Result<ValidationResult> {
863        let mut issues = Vec::new();
864        let mut warnings = Vec::new();
865
866        // Check content length
867        if request.content.trim().is_empty() {
868            issues.push("Content cannot be empty".to_string());
869        } else if request.content.len() > 100_000 {
870            warnings.push("Content is very long (>100KB)".to_string());
871        }
872
873        // Check for secrets
874        let secrets = self.secret_detector.detect_types(&request.content);
875        if !secrets.is_empty() {
876            if self.config.features.block_secrets {
877                issues.push(format!("Content contains secrets: {}", secrets.join(", ")));
878            } else {
879                warnings.push(format!("Content contains secrets: {}", secrets.join(", ")));
880            }
881        }
882
883        Ok(ValidationResult {
884            is_valid: issues.is_empty(),
885            issues,
886            warnings,
887        })
888    }
889
890    /// Captures a memory with authorization check (CRIT-006).
891    ///
892    /// This method requires [`super::auth::Permission::Write`] to be present in the auth context.
893    /// Use this for MCP/HTTP endpoints where authorization is required.
894    ///
895    /// When the request contains a `group_id`, this method also validates that the
896    /// auth context has write permission to that group.
897    ///
898    /// # Arguments
899    ///
900    /// * `request` - The capture request
901    /// * `auth` - Authorization context with permissions
902    ///
903    /// # Errors
904    ///
905    /// Returns [`Error::Unauthorized`] if write permission is not granted.
906    /// Returns [`Error::Unauthorized`] if group write permission is required but not granted.
907    /// Returns other errors as per [`capture`](Self::capture).
908    pub fn capture_authorized(
909        &self,
910        request: CaptureRequest,
911        auth: &super::auth::AuthContext,
912    ) -> Result<CaptureResult> {
913        auth.require(super::auth::Permission::Write)?;
914
915        // Check group write permission if group_id is specified
916        #[cfg(feature = "group-scope")]
917        if let Some(ref group_id) = request.group_id {
918            use crate::models::group::GroupRole;
919            auth.require_group_role(group_id, GroupRole::Write)?;
920        }
921
922        self.capture(request)
923    }
924}
925
926fn resolve_file_path(repo_root: Option<&Path>, source: Option<&String>) -> Option<String> {
927    let source = source?;
928    if source.contains("://") {
929        return None;
930    }
931
932    let source_path = Path::new(source);
933    let repo_root = repo_root?;
934
935    if let Ok(relative) = source_path.strip_prefix(repo_root) {
936        return Some(normalize_path(&relative.to_string_lossy()));
937    }
938
939    if source_path.is_relative() {
940        return Some(normalize_path(source));
941    }
942
943    None
944}
945
946fn normalize_path(path: &str) -> String {
947    path.replace('\\', "/")
948}
949
950impl Default for CaptureService {
951    fn default() -> Self {
952        Self::new(Config::default())
953    }
954}
955
956/// Result of capture validation.
957#[derive(Debug, Clone)]
958pub struct ValidationResult {
959    /// Whether the capture request is valid.
960    pub is_valid: bool,
961    /// List of blocking issues.
962    pub issues: Vec<String>,
963    /// List of non-blocking warnings.
964    pub warnings: Vec<String>,
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::models::{Domain, Namespace};
971
972    fn test_config() -> Config {
973        Config::default()
974    }
975
976    fn test_request(content: &str) -> CaptureRequest {
977        CaptureRequest {
978            content: content.to_string(),
979            namespace: Namespace::Decisions,
980            domain: Domain::default(),
981            tags: vec!["test".to_string()],
982            source: Some("test.rs".to_string()),
983            skip_security_check: false,
984            ttl_seconds: None,
985            scope: None,
986            #[cfg(feature = "group-scope")]
987            group_id: None,
988        }
989    }
990
991    #[test]
992    fn test_capture_success() {
993        let service = CaptureService::new(test_config());
994        let request = test_request("Use PostgreSQL for primary storage");
995
996        let result = service.capture(request);
997        assert!(result.is_ok());
998
999        let result = result.unwrap();
1000        // Memory ID is SHA only (12 hex chars), no namespace prefix
1001        assert_eq!(result.memory_id.as_str().len(), 12);
1002        assert!(
1003            result
1004                .memory_id
1005                .as_str()
1006                .chars()
1007                .all(|c| c.is_ascii_hexdigit())
1008        );
1009        assert!(result.urn.starts_with("subcog://"));
1010        assert!(!result.content_modified);
1011    }
1012
1013    #[test]
1014    fn test_capture_empty_content() {
1015        let service = CaptureService::new(test_config());
1016        let request = test_request("   ");
1017
1018        let result = service.capture(request);
1019        assert!(result.is_err());
1020        assert!(matches!(result, Err(Error::InvalidInput(_))));
1021    }
1022
1023    #[test]
1024    fn test_capture_with_secrets_redacted() {
1025        let mut config = test_config();
1026        config.features.redact_secrets = true;
1027        config.features.block_secrets = false;
1028
1029        let service = CaptureService::new(config);
1030        let request = test_request("My API key is AKIAIOSFODNN7EXAMPLE");
1031
1032        let result = service.capture(request);
1033        assert!(result.is_ok());
1034        assert!(result.unwrap().content_modified);
1035    }
1036
1037    #[test]
1038    fn test_capture_with_secrets_blocked() {
1039        let mut config = test_config();
1040        config.features.block_secrets = true;
1041
1042        let service = CaptureService::new(config);
1043        let request = test_request("My API key is AKIAIOSFODNN7EXAMPLE");
1044
1045        let result = service.capture(request);
1046        assert!(result.is_err());
1047        assert!(matches!(result, Err(Error::ContentBlocked { .. })));
1048    }
1049
1050    #[test]
1051    fn test_validate_valid() {
1052        let service = CaptureService::new(test_config());
1053        let request = test_request("Valid content");
1054
1055        let result = service.validate(&request).unwrap();
1056        assert!(result.is_valid);
1057        assert!(result.issues.is_empty());
1058    }
1059
1060    #[test]
1061    fn test_validate_empty() {
1062        let service = CaptureService::new(test_config());
1063        let request = test_request("");
1064
1065        let result = service.validate(&request).unwrap();
1066        assert!(!result.is_valid);
1067        assert!(!result.issues.is_empty());
1068    }
1069
1070    #[test]
1071    fn test_generate_urn() {
1072        let service = CaptureService::new(test_config());
1073
1074        let memory = Memory {
1075            id: MemoryId::new("test_123"),
1076            content: "Test".to_string(),
1077            namespace: Namespace::Decisions,
1078            domain: Domain::for_repository("zircote", "subcog"),
1079            project_id: None,
1080            branch: None,
1081            file_path: None,
1082            status: MemoryStatus::Active,
1083            created_at: 0,
1084            updated_at: 0,
1085            tombstoned_at: None,
1086            expires_at: None,
1087            embedding: None,
1088            tags: vec![],
1089            #[cfg(feature = "group-scope")]
1090            group_id: None,
1091            source: None,
1092            is_summary: false,
1093            source_memory_ids: None,
1094            consolidation_timestamp: None,
1095        };
1096
1097        let urn = service.generate_urn(&memory);
1098        assert!(urn.contains("subcog"));
1099        assert!(urn.contains("decisions"));
1100        assert!(urn.contains("test_123"));
1101    }
1102
1103    // ========================================================================
1104    // Phase 3 (MEM-003) Tests: Embedding generation and backend integration
1105    // ========================================================================
1106
1107    use crate::embedding::FastEmbedEmbedder;
1108    use crate::services::deduplication::ContentHasher;
1109    use crate::storage::index::SqliteBackend;
1110    use crate::storage::vector::UsearchBackend;
1111    use git2::{Repository, Signature};
1112    use tempfile::TempDir;
1113
1114    fn init_test_repo() -> (TempDir, Repository) {
1115        let dir = TempDir::new().expect("Failed to create temp dir");
1116        let repo = Repository::init(dir.path()).expect("Failed to init repo");
1117
1118        let sig = Signature::now("test", "test@test.com").expect("Failed to create signature");
1119        let tree_id = repo
1120            .index()
1121            .expect("Failed to get index")
1122            .write_tree()
1123            .expect("Failed to write tree");
1124        {
1125            let tree = repo.find_tree(tree_id).expect("Failed to find tree");
1126            repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1127                .expect("Failed to create commit");
1128        }
1129        repo.remote("origin", "https://github.com/org/repo.git")
1130            .expect("Failed to add remote");
1131
1132        (dir, repo)
1133    }
1134
1135    #[test]
1136    fn test_capture_with_embedder_generates_embedding() {
1137        // Test that embedder is invoked during capture
1138        let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1139        let service = CaptureService::new(test_config()).with_embedder(embedder);
1140
1141        assert!(service.has_embedder());
1142        // Note: The embedding is generated internally during capture.
1143        // We verify the service is configured correctly.
1144    }
1145
1146    #[test]
1147    fn test_capture_with_index_backend() {
1148        // Test that index backend is used during capture
1149        let index = SqliteBackend::in_memory().expect("Failed to create in-memory SQLite backend");
1150        let index_arc: Arc<dyn IndexBackend + Send + Sync> = Arc::new(index);
1151
1152        let service = CaptureService::new(test_config()).with_index(index_arc);
1153
1154        assert!(service.has_index());
1155        // Capture should succeed and index the memory
1156        let request = test_request("Use PostgreSQL for primary storage");
1157        let result = service.capture(request);
1158        assert!(result.is_ok());
1159    }
1160
1161    #[test]
1162    fn test_capture_sets_facets_and_hash_tag() {
1163        let (dir, _repo) = init_test_repo();
1164        let repo_path = dir.path();
1165        let index: Arc<dyn IndexBackend + Send + Sync> =
1166            Arc::new(SqliteBackend::in_memory().unwrap());
1167        let config = Config::new().with_repo_path(repo_path);
1168        let service = CaptureService::new(config).with_index(Arc::clone(&index));
1169
1170        let file_path = repo_path.join("src").join("lib.rs");
1171        std::fs::create_dir_all(file_path.parent().expect("parent path")).expect("create dir");
1172        std::fs::write(&file_path, "fn main() {}\n").expect("write file");
1173
1174        let request = CaptureRequest {
1175            content: "Test content for facets".to_string(),
1176            namespace: Namespace::Decisions,
1177            domain: Domain::new(),
1178            tags: vec!["test".to_string()],
1179            source: Some(file_path.to_string_lossy().to_string()),
1180            skip_security_check: false,
1181            ttl_seconds: None,
1182            scope: None,
1183            #[cfg(feature = "group-scope")]
1184            group_id: None,
1185        };
1186
1187        let result = service.capture(request).expect("capture");
1188        let stored = index
1189            .get_memory(&result.memory_id)
1190            .expect("get memory")
1191            .expect("stored memory");
1192
1193        assert_eq!(stored.project_id.as_deref(), Some("github.com/org/repo"));
1194        assert!(stored.branch.is_some());
1195        assert_eq!(stored.file_path.as_deref(), Some("src/lib.rs"));
1196
1197        let hash_tag = ContentHasher::content_to_tag(&stored.content);
1198        assert!(stored.tags.contains(&hash_tag));
1199    }
1200
1201    #[test]
1202    fn test_capture_with_vector_backend() {
1203        // Test that vector backend is used during capture
1204        // Using fallback UsearchBackend for testing (no usearch-hnsw feature needed)
1205        #[cfg(not(feature = "usearch-hnsw"))]
1206        let vector = UsearchBackend::new(
1207            std::env::temp_dir().join("test_vector_capture"),
1208            FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1209        );
1210        #[cfg(feature = "usearch-hnsw")]
1211        let vector = UsearchBackend::new(
1212            std::env::temp_dir().join("test_vector_capture"),
1213            FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1214        )
1215        .expect("Failed to create usearch backend");
1216
1217        let vector_arc: Arc<dyn VectorBackend + Send + Sync> = Arc::new(vector);
1218
1219        let service = CaptureService::new(test_config()).with_vector(vector_arc);
1220
1221        assert!(service.has_vector());
1222        // Note: Without embedder, no embedding will be generated for vector storage
1223    }
1224
1225    #[test]
1226    fn test_capture_with_all_backends() {
1227        // Test full pipeline: embedder + index + vector
1228        let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1229        let index = SqliteBackend::in_memory().expect("Failed to create in-memory SQLite backend");
1230        let index_arc: Arc<dyn IndexBackend + Send + Sync> = Arc::new(index);
1231
1232        #[cfg(not(feature = "usearch-hnsw"))]
1233        let vector = UsearchBackend::new(
1234            std::env::temp_dir().join("test_vector_all"),
1235            FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1236        );
1237        #[cfg(feature = "usearch-hnsw")]
1238        let vector = UsearchBackend::new(
1239            std::env::temp_dir().join("test_vector_all"),
1240            FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1241        )
1242        .expect("Failed to create usearch backend");
1243
1244        let vector_arc: Arc<dyn VectorBackend + Send + Sync> = Arc::new(vector);
1245
1246        let service = CaptureService::with_backends(
1247            test_config(),
1248            Arc::clone(&embedder),
1249            Arc::clone(&index_arc),
1250            Arc::clone(&vector_arc),
1251        );
1252
1253        assert!(service.has_embedder());
1254        assert!(service.has_index());
1255        assert!(service.has_vector());
1256
1257        // Capture should succeed with all backends
1258        let request = test_request("Use PostgreSQL for primary storage");
1259        let result = service.capture(request);
1260        assert!(result.is_ok(), "Capture failed: {:?}", result.err());
1261    }
1262
1263    #[test]
1264    fn test_capture_succeeds_without_backends() {
1265        // Graceful degradation: capture should succeed even without optional backends
1266        let service = CaptureService::new_minimal(test_config());
1267
1268        assert!(!service.has_embedder());
1269        assert!(!service.has_index());
1270        assert!(!service.has_vector());
1271
1272        let request = test_request("This should still work");
1273        let result = service.capture(request);
1274        assert!(result.is_ok(), "Capture should succeed without backends");
1275    }
1276
1277    #[test]
1278    fn test_capture_succeeds_with_only_embedder() {
1279        // Test partial configuration: only embedder (no storage backends)
1280        let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1281        let service = CaptureService::new_minimal(test_config()).with_embedder(embedder);
1282
1283        assert!(service.has_embedder());
1284        assert!(!service.has_index());
1285        assert!(!service.has_vector());
1286
1287        let request = test_request("Test with embedder only");
1288        let result = service.capture(request);
1289        assert!(result.is_ok(), "Capture should succeed with embedder only");
1290    }
1291
1292    #[test]
1293    fn test_capture_index_failure_doesnt_fail_capture() {
1294        // Test graceful degradation: index failure shouldn't fail the capture
1295        // This is verified by the warning log, but capture still succeeds
1296        // We test this indirectly by verifying the capture succeeds
1297        // even when index could potentially fail.
1298
1299        let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1300        let service = CaptureService::new(test_config()).with_embedder(embedder);
1301
1302        let request = test_request("Test graceful degradation");
1303        let result = service.capture(request);
1304        assert!(result.is_ok());
1305    }
1306
1307    #[test]
1308    fn test_has_embedder_returns_false_when_not_configured() {
1309        let service = CaptureService::new(test_config());
1310        assert!(!service.has_embedder());
1311    }
1312
1313    #[test]
1314    fn test_has_index_returns_false_when_not_configured() {
1315        // Test minimal service (no auto-initialization)
1316        let service = CaptureService::new_minimal(test_config());
1317        assert!(!service.has_index());
1318    }
1319
1320    #[test]
1321    fn test_new_auto_initializes_index() {
1322        // Test that new() auto-initializes SQLite backend
1323        let service = CaptureService::new(test_config());
1324        // Index should be initialized (or None if user data dir unavailable)
1325        // In test environment, this should succeed
1326        assert!(
1327            service.has_index(),
1328            "CaptureService::new() should auto-initialize SQLite backend"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_has_vector_returns_false_when_not_configured() {
1334        let service = CaptureService::new(test_config());
1335        assert!(!service.has_vector());
1336    }
1337
1338    #[test]
1339    fn test_builder_methods_chain() {
1340        // Test that builder methods can be chained
1341        let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1342        let index = SqliteBackend::in_memory().expect("Failed to create in-memory SQLite backend");
1343        let index_arc: Arc<dyn IndexBackend + Send + Sync> = Arc::new(index);
1344
1345        let service = CaptureService::new(test_config())
1346            .with_embedder(embedder)
1347            .with_index(index_arc);
1348
1349        assert!(service.has_embedder());
1350        assert!(service.has_index());
1351        assert!(!service.has_vector()); // Not configured
1352    }
1353
1354    // ========================================================================
1355    // Group-scoped capture tests
1356    // ========================================================================
1357
1358    #[cfg(feature = "group-scope")]
1359    mod group_scope_tests {
1360        use super::*;
1361        use crate::services::auth::AuthContext;
1362
1363        fn test_config() -> Config {
1364            Config::default()
1365        }
1366
1367        #[test]
1368        fn test_capture_with_group_id() {
1369            let index: Arc<dyn IndexBackend + Send + Sync> =
1370                Arc::new(SqliteBackend::in_memory().unwrap());
1371            let service = CaptureService::new(test_config()).with_index(Arc::clone(&index));
1372
1373            let request = CaptureRequest::new("Group-scoped memory content")
1374                .with_namespace(Namespace::Decisions)
1375                .with_group_id("team-alpha");
1376
1377            let result = service.capture(request).expect("capture should succeed");
1378
1379            // Verify the memory was stored with group_id
1380            let stored = index
1381                .get_memory(&result.memory_id)
1382                .expect("get memory")
1383                .expect("stored memory");
1384            assert_eq!(stored.group_id.as_deref(), Some("team-alpha"));
1385        }
1386
1387        #[test]
1388        fn test_capture_authorized_with_group_permission() {
1389            let service = CaptureService::new(test_config());
1390
1391            // Use builder to create auth with write scope and group permission
1392            let auth = AuthContext::builder()
1393                .scope("write")
1394                .group_role("team-alpha", "write")
1395                .build();
1396
1397            let request = CaptureRequest::new("Group content")
1398                .with_namespace(Namespace::Decisions)
1399                .with_group_id("team-alpha");
1400
1401            let result = service.capture_authorized(request, &auth);
1402            assert!(result.is_ok(), "Should succeed with group permission");
1403        }
1404
1405        #[test]
1406        fn test_capture_authorized_fails_without_group_permission() {
1407            let service = CaptureService::new(test_config());
1408
1409            // Auth with write scope but NO group permission
1410            let auth = AuthContext::builder().scope("write").build();
1411
1412            let request = CaptureRequest::new("Group content")
1413                .with_namespace(Namespace::Decisions)
1414                .with_group_id("team-alpha");
1415
1416            let result = service.capture_authorized(request, &auth);
1417            assert!(result.is_err(), "Should fail without group permission");
1418            assert!(matches!(result, Err(Error::Unauthorized { .. })));
1419        }
1420
1421        #[test]
1422        fn test_capture_authorized_fails_with_read_only_group_permission() {
1423            let service = CaptureService::new(test_config());
1424
1425            // Auth with write scope but only read permission to group
1426            let auth = AuthContext::builder()
1427                .scope("write")
1428                .group_role("team-alpha", "read")
1429                .build();
1430
1431            let request = CaptureRequest::new("Group content")
1432                .with_namespace(Namespace::Decisions)
1433                .with_group_id("team-alpha");
1434
1435            let result = service.capture_authorized(request, &auth);
1436            assert!(result.is_err(), "Should fail with read-only group access");
1437        }
1438
1439        #[test]
1440        fn test_capture_authorized_without_group_id_succeeds() {
1441            let service = CaptureService::new(test_config());
1442
1443            // Auth with write scope
1444            let auth = AuthContext::builder().scope("write").build();
1445
1446            // No group_id in request - should not require group permission
1447            let request =
1448                CaptureRequest::new("Non-group content").with_namespace(Namespace::Decisions);
1449
1450            let result = service.capture_authorized(request, &auth);
1451            assert!(result.is_ok(), "Should succeed without group_id");
1452        }
1453
1454        #[test]
1455        fn test_capture_request_builder_with_group_id() {
1456            let request = CaptureRequest::new("Content")
1457                .with_namespace(Namespace::Learnings)
1458                .with_tag("test")
1459                .with_group_id("my-group");
1460
1461            assert_eq!(request.group_id.as_deref(), Some("my-group"));
1462            assert_eq!(request.namespace, Namespace::Learnings);
1463        }
1464
1465        #[test]
1466        fn test_capture_with_local_auth_and_group_id() {
1467            // Local auth context should have implicit group admin access
1468            let service = CaptureService::new(test_config());
1469            let auth = AuthContext::local();
1470
1471            let request = CaptureRequest::new("Local group content")
1472                .with_namespace(Namespace::Decisions)
1473                .with_group_id("any-group");
1474
1475            let result = service.capture_authorized(request, &auth);
1476            assert!(
1477                result.is_ok(),
1478                "Local auth should have implicit group access"
1479            );
1480        }
1481    }
1482}