Skip to main content

agentic_contract/
contracts.rs

1//! Agentic-sdk trait implementations for AgenticContract.
2//!
3//! Implements: Sister, SessionManagement, Grounding, Queryable,
4//!             FileFormatReader, FileFormatWriter, EventEmitter,
5//!             ReceiptIntegration
6//!
7//! Contract is the most trait-rich sister — governance requires
8//! sessions (scoped policies), grounding (verify claims against
9//! policies), receipts (audit trail), and events (real-time notifications).
10
11use std::path::{Path, PathBuf};
12use std::time::Instant;
13
14use agentic_sdk::prelude::*;
15use chrono::Utc;
16
17use crate::contract_engine::ContractEngine;
18use crate::error::ContractError;
19use crate::file_format::{ContractFile, MAGIC, VERSION};
20
21// ═══════════════════════════════════════════════════════════════════
22// ERROR BRIDGE
23// ═══════════════════════════════════════════════════════════════════
24
25impl From<ContractError> for SisterError {
26    fn from(e: ContractError) -> Self {
27        match &e {
28            ContractError::NotFound(entity) => {
29                SisterError::not_found(format!("contract entity not found: {entity}"))
30            }
31            ContractError::PolicyViolation(msg) => {
32                SisterError::new(ErrorCode::InvalidState, format!("Policy violation: {msg}"))
33            }
34            ContractError::RiskLimitExceeded {
35                limit,
36                current,
37                max,
38            } => SisterError::new(
39                ErrorCode::InvalidState,
40                format!("Risk limit exceeded: {limit} (current: {current}, max: {max})"),
41            ),
42            ContractError::ApprovalRequired(msg) => {
43                SisterError::new(ErrorCode::InvalidState, format!("Approval required: {msg}"))
44            }
45            ContractError::ApprovalDenied(msg) => {
46                SisterError::new(ErrorCode::InvalidState, format!("Approval denied: {msg}"))
47            }
48            ContractError::ConditionNotMet(msg) => {
49                SisterError::new(ErrorCode::InvalidState, format!("Condition not met: {msg}"))
50            }
51            ContractError::ObligationUnfulfilled(msg) => SisterError::new(
52                ErrorCode::InvalidState,
53                format!("Obligation unfulfilled: {msg}"),
54            ),
55            ContractError::ContractExpired(msg) => {
56                SisterError::new(ErrorCode::InvalidState, format!("Contract expired: {msg}"))
57            }
58            ContractError::InvalidContract(msg) => {
59                SisterError::invalid_input(format!("Invalid contract: {msg}"))
60            }
61            ContractError::FileFormat(msg) => SisterError::new(
62                ErrorCode::VersionMismatch,
63                format!("File format error: {msg}"),
64            ),
65            ContractError::Io(err) => {
66                SisterError::new(ErrorCode::StorageError, format!("IO error: {err}"))
67            }
68            ContractError::Serialization(err) => SisterError::new(
69                ErrorCode::StorageError,
70                format!("Serialization error: {err}"),
71            ),
72        }
73    }
74}
75
76// ═══════════════════════════════════════════════════════════════════
77// FACADE
78// ═══════════════════════════════════════════════════════════════════
79
80/// Contract facade wrapping the ContractEngine with SDK trait implementations.
81///
82/// Contract is session-scoped: policies and governance rules are loaded
83/// into a session context and can be queried, grounded, and audited.
84pub struct ContractSister {
85    /// Core engine (owns the ContractFile).
86    engine: ContractEngine,
87
88    /// Path to the .acon file.
89    #[allow(dead_code)]
90    file_path: PathBuf,
91
92    /// Startup time for uptime tracking.
93    started_at: Instant,
94
95    /// Current session ID.
96    session_id: Option<ContextId>,
97
98    /// Event manager for real-time notifications.
99    events: EventManager,
100}
101
102// ═══════════════════════════════════════════════════════════════════
103// SISTER TRAIT
104// ═══════════════════════════════════════════════════════════════════
105
106impl Sister for ContractSister {
107    const SISTER_TYPE: SisterType = SisterType::Contract;
108    const FILE_EXTENSION: &'static str = "acon";
109
110    fn init(config: SisterConfig) -> SisterResult<Self>
111    where
112        Self: Sized,
113    {
114        let path = config
115            .data_path
116            .unwrap_or_else(|| PathBuf::from("contract.acon"));
117
118        let file = if path.exists() {
119            ContractFile::load(&path).map_err(SisterError::from)?
120        } else if config.create_if_missing {
121            if let Some(parent) = path.parent() {
122                if !parent.as_os_str().is_empty() {
123                    std::fs::create_dir_all(parent).map_err(|e| {
124                        SisterError::new(
125                            ErrorCode::StorageError,
126                            format!("Failed to create parent dir: {e}"),
127                        )
128                    })?;
129                }
130            }
131            let mut f = ContractFile::new();
132            f.path = Some(path.clone());
133            f
134        } else {
135            return Err(SisterError::not_found(format!(
136                "Contract file not found: {}",
137                path.display()
138            )));
139        };
140
141        let engine = ContractEngine::from_file(file);
142
143        Ok(Self {
144            engine,
145            file_path: path,
146            started_at: Instant::now(),
147            session_id: None,
148            events: EventManager::new(256),
149        })
150    }
151
152    fn health(&self) -> HealthStatus {
153        let uptime = self.started_at.elapsed();
154        let stats = self.engine.stats();
155
156        HealthStatus {
157            healthy: true,
158            status: Status::Ready,
159            uptime,
160            resources: ResourceUsage {
161                memory_bytes: 0,
162                disk_bytes: 0,
163                open_handles: stats.total_entities,
164            },
165            warnings: vec![],
166            last_error: None,
167        }
168    }
169
170    fn version(&self) -> Version {
171        Version::new(0, 1, 0)
172    }
173
174    fn shutdown(&mut self) -> SisterResult<()> {
175        self.events
176            .emit(SisterEvent::shutting_down(SisterType::Contract));
177        self.engine.save().map_err(SisterError::from)?;
178        Ok(())
179    }
180
181    fn capabilities(&self) -> Vec<Capability> {
182        vec![
183            Capability::new("policy_add", "Add a policy rule governing agent behavior"),
184            Capability::new(
185                "policy_check",
186                "Check if an action is allowed under policies",
187            ),
188            Capability::new(
189                "policy_list",
190                "List active policies with optional scope filter",
191            ),
192            Capability::new("risk_limit_set", "Set a risk limit threshold"),
193            Capability::new("risk_limit_check", "Check if an action would exceed limits"),
194            Capability::new(
195                "approval_request",
196                "Request approval for a controlled action",
197            ),
198            Capability::new("approval_decide", "Approve or deny a pending request"),
199            Capability::new("obligation_add", "Add an obligation that must be fulfilled"),
200            Capability::new("obligation_check", "Check the status of obligations"),
201            Capability::new("violation_report", "Report a contract or policy violation"),
202            Capability::new("violation_list", "List recorded violations"),
203        ]
204    }
205}
206
207// ═══════════════════════════════════════════════════════════════════
208// SESSION MANAGEMENT
209// ═══════════════════════════════════════════════════════════════════
210
211impl SessionManagement for ContractSister {
212    fn start_session(&mut self, name: &str) -> SisterResult<ContextId> {
213        let id = ContextId::new();
214        self.session_id = Some(id);
215
216        self.events.emit(SisterEvent::context_created(
217            SisterType::Contract,
218            id,
219            name.to_string(),
220        ));
221
222        Ok(id)
223    }
224
225    fn end_session(&mut self) -> SisterResult<()> {
226        self.session_id = None;
227        Ok(())
228    }
229
230    fn current_session(&self) -> Option<ContextId> {
231        self.session_id
232    }
233
234    fn current_session_info(&self) -> SisterResult<ContextInfo> {
235        let id = self
236            .session_id
237            .ok_or_else(|| SisterError::new(ErrorCode::InvalidState, "No active session"))?;
238
239        let stats = self.engine.stats();
240        Ok(ContextInfo {
241            id,
242            name: "contract_session".to_string(),
243            created_at: Utc::now(),
244            updated_at: Utc::now(),
245            item_count: stats.total_entities,
246            size_bytes: 0,
247            metadata: Metadata::new(),
248        })
249    }
250
251    fn list_sessions(&self) -> SisterResult<Vec<ContextSummary>> {
252        // Contract sessions are ephemeral — only current session tracked
253        Ok(vec![])
254    }
255
256    fn export_session(&self, _id: ContextId) -> SisterResult<ContextSnapshot> {
257        let info = self.current_session_info()?;
258        let data = serde_json::to_vec(&self.engine.file)
259            .map_err(|e| SisterError::new(ErrorCode::Internal, e.to_string()))?;
260        let checksum = *blake3::hash(&data).as_bytes();
261
262        Ok(ContextSnapshot {
263            sister_type: SisterType::Contract,
264            version: Version::new(0, 1, 0),
265            context_info: info,
266            data,
267            checksum,
268            snapshot_at: Utc::now(),
269        })
270    }
271
272    fn import_session(&mut self, snapshot: ContextSnapshot) -> SisterResult<ContextId> {
273        if !snapshot.verify() {
274            return Err(SisterError::new(
275                ErrorCode::ChecksumMismatch,
276                "Snapshot checksum verification failed",
277            ));
278        }
279        self.start_session(&snapshot.context_info.name)
280    }
281}
282
283// ═══════════════════════════════════════════════════════════════════
284// GROUNDING
285// ═══════════════════════════════════════════════════════════════════
286
287impl Grounding for ContractSister {
288    fn ground(&self, claim: &str) -> SisterResult<GroundingResult> {
289        // Search policies
290        let mut best_score = 0.0f64;
291        let mut evidence = Vec::new();
292
293        for policy in &self.engine.file.policies {
294            let score = word_overlap_score(claim, &policy.label);
295            let desc_score = word_overlap_score(claim, &policy.description);
296            let combined = score.max(desc_score);
297
298            if combined > 0.0 {
299                best_score = best_score.max(combined);
300                evidence.push(GroundingEvidence::new(
301                    "policy",
302                    policy.id.to_string(),
303                    combined,
304                    format!("{} [{}]", policy.label, policy.scope),
305                ));
306            }
307        }
308
309        // Search obligations
310        for obligation in &self.engine.file.obligations {
311            let score = word_overlap_score(claim, &obligation.label);
312            if score > 0.0 {
313                best_score = best_score.max(score);
314                evidence.push(GroundingEvidence::new(
315                    "obligation",
316                    obligation.id.to_string(),
317                    score,
318                    format!("{} [{}]", obligation.label, obligation.assignee),
319                ));
320            }
321        }
322
323        // Search violations
324        for violation in &self.engine.file.violations {
325            let score = word_overlap_score(claim, &violation.description);
326            if score > 0.0 {
327                best_score = best_score.max(score);
328                evidence.push(GroundingEvidence::new(
329                    "violation",
330                    violation.id.to_string(),
331                    score,
332                    format!("{} [{}]", violation.description, violation.severity),
333                ));
334            }
335        }
336
337        if evidence.is_empty() {
338            // Build suggestions from policies
339            let suggestions: Vec<String> = self
340                .engine
341                .file
342                .policies
343                .iter()
344                .take(3)
345                .map(|p| p.label.clone())
346                .collect();
347
348            Ok(GroundingResult::ungrounded(
349                claim,
350                "No matching policies, obligations, or violations found",
351            )
352            .with_suggestions(suggestions))
353        } else if best_score > 0.5 {
354            Ok(GroundingResult::verified(claim, best_score)
355                .with_evidence(evidence)
356                .with_reason("Found matching contract entities"))
357        } else {
358            Ok(GroundingResult::partial(claim, best_score)
359                .with_evidence(evidence)
360                .with_reason("Some evidence found in contract entities"))
361        }
362    }
363
364    fn evidence(&self, query: &str, max_results: usize) -> SisterResult<Vec<EvidenceDetail>> {
365        let mut results = Vec::new();
366
367        for policy in &self.engine.file.policies {
368            let score = word_overlap_score(query, &policy.label);
369            if score > 0.0 {
370                results.push(EvidenceDetail {
371                    evidence_type: "policy".to_string(),
372                    id: policy.id.to_string(),
373                    score,
374                    created_at: policy.created_at,
375                    source_sister: SisterType::Contract,
376                    content: format!("{} [{}]", policy.label, policy.scope),
377                    data: Metadata::new(),
378                });
379            }
380        }
381
382        for obligation in &self.engine.file.obligations {
383            let score = word_overlap_score(query, &obligation.label);
384            if score > 0.0 {
385                results.push(EvidenceDetail {
386                    evidence_type: "obligation".to_string(),
387                    id: obligation.id.to_string(),
388                    score,
389                    created_at: obligation.created_at,
390                    source_sister: SisterType::Contract,
391                    content: obligation.label.to_string(),
392                    data: Metadata::new(),
393                });
394            }
395        }
396
397        // Sort by score descending
398        results.sort_by(|a, b| {
399            b.score
400                .partial_cmp(&a.score)
401                .unwrap_or(std::cmp::Ordering::Equal)
402        });
403        results.truncate(max_results);
404        Ok(results)
405    }
406
407    fn suggest(&self, _query: &str, limit: usize) -> SisterResult<Vec<GroundingSuggestion>> {
408        let mut suggestions = Vec::new();
409
410        for policy in self.engine.file.policies.iter().take(limit) {
411            suggestions.push(GroundingSuggestion {
412                item_type: "policy".to_string(),
413                id: policy.id.to_string(),
414                relevance_score: 0.5,
415                description: format!("{} [{}]", policy.label, policy.scope),
416                data: Metadata::new(),
417            });
418        }
419
420        Ok(suggestions)
421    }
422}
423
424// ═══════════════════════════════════════════════════════════════════
425// QUERYABLE
426// ═══════════════════════════════════════════════════════════════════
427
428impl Queryable for ContractSister {
429    fn query(&self, query: Query) -> SisterResult<QueryResult> {
430        let start = Instant::now();
431
432        match query.query_type.as_str() {
433            "list" => {
434                let limit = query.limit.unwrap_or(50);
435                let offset = query.offset.unwrap_or(0);
436                let entity_filter = query.get_string("entity_type");
437
438                let mut results: Vec<serde_json::Value> = Vec::new();
439
440                match entity_filter.as_deref() {
441                    Some("policy") | None => {
442                        for p in &self.engine.file.policies {
443                            results.push(serde_json::json!({
444                                "id": p.id.to_string(),
445                                "type": "policy",
446                                "label": p.label,
447                                "scope": format!("{}", p.scope),
448                                "action": format!("{:?}", p.action),
449                                "status": format!("{:?}", p.status),
450                            }));
451                        }
452                    }
453                    _ => {}
454                }
455
456                match entity_filter.as_deref() {
457                    Some("risk_limit") | None => {
458                        for r in &self.engine.file.risk_limits {
459                            results.push(serde_json::json!({
460                                "id": r.id.to_string(),
461                                "type": "risk_limit",
462                                "label": r.label,
463                                "current": r.current_value,
464                                "max": r.max_value,
465                                "usage": format!("{:.1}%", r.usage_ratio() * 100.0),
466                            }));
467                        }
468                    }
469                    _ => {}
470                }
471
472                match entity_filter.as_deref() {
473                    Some("obligation") | None => {
474                        for o in &self.engine.file.obligations {
475                            results.push(serde_json::json!({
476                                "id": o.id.to_string(),
477                                "type": "obligation",
478                                "label": o.label,
479                                "assignee": o.assignee,
480                                "status": format!("{:?}", o.status),
481                            }));
482                        }
483                    }
484                    _ => {}
485                }
486
487                match entity_filter.as_deref() {
488                    Some("violation") | None => {
489                        for v in &self.engine.file.violations {
490                            results.push(serde_json::json!({
491                                "id": v.id.to_string(),
492                                "type": "violation",
493                                "description": v.description,
494                                "severity": format!("{}", v.severity),
495                                "actor": v.actor,
496                            }));
497                        }
498                    }
499                    _ => {}
500                }
501
502                match entity_filter.as_deref() {
503                    Some("condition") | None => {
504                        for c in &self.engine.file.conditions {
505                            results.push(serde_json::json!({
506                                "id": c.id.to_string(),
507                                "type": "condition",
508                                "label": c.label,
509                                "status": format!("{:?}", c.status),
510                            }));
511                        }
512                    }
513                    _ => {}
514                }
515
516                let total = results.len();
517                let paged: Vec<serde_json::Value> =
518                    results.into_iter().skip(offset).take(limit).collect();
519
520                Ok(QueryResult::new(query, paged, start.elapsed())
521                    .with_pagination(total, offset + limit < total))
522            }
523
524            "search" => {
525                let query_text = query.get_string("text").unwrap_or_default();
526                let limit = query.limit.unwrap_or(20);
527
528                let mut scored: Vec<(f64, serde_json::Value)> = Vec::new();
529
530                // Search policies
531                for p in &self.engine.file.policies {
532                    let score = word_overlap_score(&query_text, &p.label)
533                        .max(word_overlap_score(&query_text, &p.description));
534                    if score > 0.0 {
535                        scored.push((
536                            score,
537                            serde_json::json!({
538                                "id": p.id.to_string(),
539                                "type": "policy",
540                                "label": p.label,
541                                "scope": format!("{}", p.scope),
542                                "score": score,
543                            }),
544                        ));
545                    }
546                }
547
548                // Search obligations
549                for o in &self.engine.file.obligations {
550                    let score = word_overlap_score(&query_text, &o.label)
551                        .max(word_overlap_score(&query_text, &o.description));
552                    if score > 0.0 {
553                        scored.push((
554                            score,
555                            serde_json::json!({
556                                "id": o.id.to_string(),
557                                "type": "obligation",
558                                "label": o.label,
559                                "score": score,
560                            }),
561                        ));
562                    }
563                }
564
565                // Search violations
566                for v in &self.engine.file.violations {
567                    let score = word_overlap_score(&query_text, &v.description);
568                    if score > 0.0 {
569                        scored.push((
570                            score,
571                            serde_json::json!({
572                                "id": v.id.to_string(),
573                                "type": "violation",
574                                "description": v.description,
575                                "severity": format!("{}", v.severity),
576                                "score": score,
577                            }),
578                        ));
579                    }
580                }
581
582                // Sort by score descending
583                scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
584                let results: Vec<serde_json::Value> =
585                    scored.into_iter().take(limit).map(|(_, v)| v).collect();
586
587                Ok(QueryResult::new(query, results, start.elapsed()))
588            }
589
590            "recent" => {
591                let limit = query.limit.unwrap_or(10);
592
593                // Combine entities with timestamps, take most recent
594                let mut items: Vec<(chrono::DateTime<Utc>, serde_json::Value)> = Vec::new();
595
596                for p in &self.engine.file.policies {
597                    items.push((
598                        p.created_at,
599                        serde_json::json!({
600                            "id": p.id.to_string(),
601                            "type": "policy",
602                            "label": p.label,
603                            "created_at": p.created_at.to_rfc3339(),
604                        }),
605                    ));
606                }
607
608                for v in &self.engine.file.violations {
609                    items.push((
610                        v.detected_at,
611                        serde_json::json!({
612                            "id": v.id.to_string(),
613                            "type": "violation",
614                            "description": v.description,
615                            "detected_at": v.detected_at.to_rfc3339(),
616                        }),
617                    ));
618                }
619
620                for o in &self.engine.file.obligations {
621                    items.push((
622                        o.created_at,
623                        serde_json::json!({
624                            "id": o.id.to_string(),
625                            "type": "obligation",
626                            "label": o.label,
627                            "created_at": o.created_at.to_rfc3339(),
628                        }),
629                    ));
630                }
631
632                // Sort by time descending (most recent first)
633                items.sort_by(|a, b| b.0.cmp(&a.0));
634                let results: Vec<serde_json::Value> =
635                    items.into_iter().take(limit).map(|(_, v)| v).collect();
636
637                Ok(QueryResult::new(query, results, start.elapsed()))
638            }
639
640            "get" => {
641                let id_str = query
642                    .get_string("id")
643                    .ok_or_else(|| SisterError::invalid_input("Missing required field: id"))?;
644
645                let id: crate::ContractId = id_str
646                    .parse()
647                    .map_err(|_| SisterError::invalid_input(format!("Invalid UUID: {id_str}")))?;
648
649                // Search all entity types for this ID
650                if let Some(p) = self.engine.file.find_policy(id) {
651                    let result = serde_json::json!({
652                        "id": p.id.to_string(),
653                        "type": "policy",
654                        "label": p.label,
655                        "description": p.description,
656                        "scope": format!("{}", p.scope),
657                        "action": format!("{:?}", p.action),
658                        "status": format!("{:?}", p.status),
659                        "tags": p.tags,
660                        "created_at": p.created_at.to_rfc3339(),
661                    });
662                    return Ok(QueryResult::new(query, vec![result], start.elapsed()));
663                }
664
665                if let Some(o) = self.engine.file.find_obligation(id) {
666                    let result = serde_json::json!({
667                        "id": o.id.to_string(),
668                        "type": "obligation",
669                        "label": o.label,
670                        "description": o.description,
671                        "assignee": o.assignee,
672                        "status": format!("{:?}", o.status),
673                        "created_at": o.created_at.to_rfc3339(),
674                    });
675                    return Ok(QueryResult::new(query, vec![result], start.elapsed()));
676                }
677
678                Err(SisterError::not_found(format!("Entity {id_str}")))
679            }
680
681            "stats" => {
682                let stats = self.engine.stats();
683                let result = serde_json::to_value(stats)
684                    .map_err(|e| SisterError::new(ErrorCode::Internal, e.to_string()))?;
685                Ok(QueryResult::new(query, vec![result], start.elapsed()))
686            }
687
688            _ => Ok(QueryResult::new(query, vec![], start.elapsed())),
689        }
690    }
691
692    fn supports_query(&self, query_type: &str) -> bool {
693        matches!(query_type, "list" | "search" | "recent" | "get" | "stats")
694    }
695
696    fn query_types(&self) -> Vec<QueryTypeInfo> {
697        vec![
698            QueryTypeInfo::new("list", "List all contract entities"),
699            QueryTypeInfo::new("search", "Search entities by text").required(vec!["text"]),
700            QueryTypeInfo::new("recent", "Get most recently created entities"),
701            QueryTypeInfo::new("get", "Get entity by ID").required(vec!["id"]),
702            QueryTypeInfo::new("stats", "Get contract statistics"),
703        ]
704    }
705}
706
707// ═══════════════════════════════════════════════════════════════════
708// FILE FORMAT (Reader + Writer)
709// ═══════════════════════════════════════════════════════════════════
710
711impl FileFormatReader for ContractSister {
712    fn read_file(path: &Path) -> SisterResult<Self>
713    where
714        Self: Sized,
715    {
716        let config = SisterConfig::new(path).create_if_missing(false);
717        Self::init(config)
718    }
719
720    fn can_read(path: &Path) -> SisterResult<FileInfo> {
721        // Peek at magic bytes
722        use std::io::Read;
723        let mut f = std::fs::File::open(path)?;
724        let mut magic = [0u8; 4];
725        f.read_exact(&mut magic).map_err(|e| {
726            SisterError::new(ErrorCode::StorageError, format!("Cannot read file: {e}"))
727        })?;
728
729        if magic != MAGIC {
730            return Err(SisterError::new(
731                ErrorCode::VersionMismatch,
732                format!("Not an .acon file (magic: {:?})", magic),
733            ));
734        }
735
736        let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
737        Ok(FileInfo {
738            sister_type: SisterType::Contract,
739            version: Version::new(0, VERSION as u8, 0),
740            created_at: Utc::now(),
741            updated_at: Utc::now(),
742            content_length: file_size,
743            needs_migration: false,
744            format_id: "ACON".to_string(),
745        })
746    }
747
748    fn file_version(path: &Path) -> SisterResult<Version> {
749        let info = Self::can_read(path)?;
750        Ok(info.version)
751    }
752
753    fn migrate(_data: &[u8], _from_version: Version) -> SisterResult<Vec<u8>> {
754        // v0.1.0 is the only version — no migration needed
755        Err(SisterError::new(
756            ErrorCode::NotImplemented,
757            "Migration not needed for v0.1.0",
758        ))
759    }
760}
761
762impl FileFormatWriter for ContractSister {
763    fn write_file(&self, path: &Path) -> SisterResult<()> {
764        let mut file = self.engine.file.clone();
765        file.path = Some(path.to_path_buf());
766        file.save().map_err(SisterError::from)
767    }
768
769    fn to_bytes(&self) -> SisterResult<Vec<u8>> {
770        serde_json::to_vec(&self.engine.file)
771            .map_err(|e| SisterError::new(ErrorCode::Internal, e.to_string()))
772    }
773}
774
775// ═══════════════════════════════════════════════════════════════════
776// EVENT EMITTER
777// ═══════════════════════════════════════════════════════════════════
778
779impl EventEmitter for ContractSister {
780    fn subscribe(&self, _filter: EventFilter) -> EventReceiver {
781        self.events.subscribe()
782    }
783
784    fn recent_events(&self, limit: usize) -> Vec<SisterEvent> {
785        self.events.recent(limit)
786    }
787
788    fn emit(&self, event: SisterEvent) {
789        self.events.emit(event);
790    }
791}
792
793// ═══════════════════════════════════════════════════════════════════
794// RECEIPT INTEGRATION
795// ═══════════════════════════════════════════════════════════════════
796
797impl ReceiptIntegration for ContractSister {
798    fn create_receipt(&self, action: ActionRecord) -> SisterResult<ReceiptId> {
799        // Contract creates receipts for policy decisions, approvals, and violations.
800        // In a full implementation this would delegate to Identity sister.
801        // Here we generate a receipt ID and log the event.
802        let receipt_id = ReceiptId::new();
803
804        self.events.emit(SisterEvent::operation_started(
805            SisterType::Contract,
806            receipt_id.to_string(),
807            format!("receipt:{}", action.action_type),
808        ));
809
810        Ok(receipt_id)
811    }
812
813    fn get_receipt(&self, id: ReceiptId) -> SisterResult<Receipt> {
814        // In production, receipts live in Identity. Contract only creates them.
815        Err(SisterError::new(
816            ErrorCode::NotImplemented,
817            format!(
818                "Receipt {} should be retrieved from Identity sister. Contract creates receipts but delegates storage to Identity.",
819                id
820            ),
821        ))
822    }
823
824    fn list_receipts(&self, _filter: ReceiptFilter) -> SisterResult<Vec<Receipt>> {
825        // Delegate to Identity for receipt storage
826        Ok(vec![])
827    }
828}
829
830// ═══════════════════════════════════════════════════════════════════
831// HELPERS
832// ═══════════════════════════════════════════════════════════════════
833
834/// BM25-inspired word overlap score between query and text.
835fn word_overlap_score(query: &str, text: &str) -> f64 {
836    let query_lower = query.to_lowercase();
837    let query_words: Vec<&str> = query_lower.split_whitespace().collect();
838    let text_lower = text.to_lowercase();
839
840    if query_words.is_empty() {
841        return 0.0;
842    }
843
844    let matched = query_words
845        .iter()
846        .filter(|w| text_lower.contains(**w))
847        .count();
848
849    matched as f64 / query_words.len() as f64
850}
851
852// ═══════════════════════════════════════════════════════════════════
853// TESTS
854// ═══════════════════════════════════════════════════════════════════
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use crate::policy::{Policy, PolicyAction, PolicyScope};
860    use crate::violation::{Violation, ViolationSeverity};
861
862    fn make_sister() -> ContractSister {
863        let config = SisterConfig::stateless().create_if_missing(true);
864        ContractSister::init(config).unwrap()
865    }
866
867    #[test]
868    fn test_sister_basics() {
869        let sister = make_sister();
870        assert_eq!(sister.sister_type(), SisterType::Contract);
871        assert_eq!(sister.file_extension(), "acon");
872        assert_eq!(sister.mcp_prefix(), "contract");
873        assert!(sister.is_healthy());
874    }
875
876    #[test]
877    fn test_session_lifecycle() {
878        let mut sister = make_sister();
879        assert!(sister.current_session().is_none());
880
881        let id = sister.start_session("test").unwrap();
882        assert_eq!(sister.current_session().unwrap(), id);
883
884        let info = sister.current_session_info().unwrap();
885        assert_eq!(info.id, id);
886
887        sister.end_session().unwrap();
888        assert!(sister.current_session().is_none());
889    }
890
891    #[test]
892    fn test_grounding() {
893        let mut sister = make_sister();
894        sister.engine.add_policy(Policy::new(
895            "Require approval for deploys",
896            PolicyScope::Global,
897            PolicyAction::RequireApproval,
898        ));
899        sister.engine.add_policy(Policy::new(
900            "Rate limit API calls",
901            PolicyScope::Session,
902            PolicyAction::AuditOnly,
903        ));
904
905        let result = sister.ground("approval for deploys").unwrap();
906        assert_eq!(result.status, GroundingStatus::Verified);
907        assert!(!result.evidence.is_empty());
908
909        let result = sister.ground("cats can teleport").unwrap();
910        assert_eq!(result.status, GroundingStatus::Ungrounded);
911    }
912
913    #[test]
914    fn test_queryable() {
915        let mut sister = make_sister();
916        sister.engine.add_policy(Policy::new(
917            "Policy A",
918            PolicyScope::Global,
919            PolicyAction::Allow,
920        ));
921        sister.engine.report_violation(Violation::new(
922            "Limit exceeded",
923            ViolationSeverity::Warning,
924            "agent_1",
925        ));
926
927        // List
928        let result = sister.query(Query::list()).unwrap();
929        assert!(result.len() >= 2);
930
931        // Search
932        let result = sister.search("limit").unwrap();
933        assert!(!result.is_empty());
934
935        // Stats
936        let result = sister.query(Query::new("stats")).unwrap();
937        assert_eq!(result.len(), 1);
938    }
939
940    #[test]
941    fn test_events() {
942        let mut sister = make_sister();
943        sister.start_session("test").unwrap();
944
945        let events = sister.recent_events(10);
946        assert!(!events.is_empty());
947    }
948
949    #[test]
950    fn test_receipt_creation() {
951        let sister = make_sister();
952        let action = ActionRecord::new(
953            SisterType::Contract,
954            "policy_check",
955            ActionOutcome::success(),
956        );
957        let receipt_id = sister.create_receipt(action).unwrap();
958        assert!(!receipt_id.to_string().is_empty());
959    }
960
961    #[test]
962    fn test_word_overlap() {
963        assert!(word_overlap_score("deploy approval", "Require approval for deploys") > 0.5);
964        assert_eq!(
965            word_overlap_score("cats", "Require approval for deploys"),
966            0.0
967        );
968        assert_eq!(word_overlap_score("", "anything"), 0.0);
969    }
970
971    #[test]
972    fn test_error_bridge() {
973        let err = ContractError::NotFound("policy_42".to_string());
974        let sister_err: SisterError = err.into();
975        assert_eq!(sister_err.code, ErrorCode::NotFound);
976
977        let err = ContractError::PolicyViolation("no deploys on friday".to_string());
978        let sister_err: SisterError = err.into();
979        assert_eq!(sister_err.code, ErrorCode::InvalidState);
980    }
981}