1use 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
21impl 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
76pub struct ContractSister {
85 engine: ContractEngine,
87
88 #[allow(dead_code)]
90 file_path: PathBuf,
91
92 started_at: Instant,
94
95 session_id: Option<ContextId>,
97
98 events: EventManager,
100}
101
102impl 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
207impl 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 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
283impl Grounding for ContractSister {
288 fn ground(&self, claim: &str) -> SisterResult<GroundingResult> {
289 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 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 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 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 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
424impl 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 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 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 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 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 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 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 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
707impl 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 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 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
775impl 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
793impl ReceiptIntegration for ContractSister {
798 fn create_receipt(&self, action: ActionRecord) -> SisterResult<ReceiptId> {
799 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 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 Ok(vec![])
827 }
828}
829
830fn 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#[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 let result = sister.query(Query::list()).unwrap();
929 assert!(result.len() >= 2);
930
931 let result = sister.search("limit").unwrap();
933 assert!(!result.is_empty());
934
935 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}