Skip to main content

chio_store_sqlite/
receipt_query.rs

1use chio_kernel::receipt_query::{ReceiptQuery, ReceiptQueryResult};
2use chio_kernel::ReceiptStoreError;
3
4use crate::receipt_store::SqliteReceiptStore;
5
6impl SqliteReceiptStore {
7    /// Query tool receipts with multi-filter support and cursor-based pagination.
8    ///
9    /// Filters are applied with AND semantics. The cursor parameter enables
10    /// forward-only pagination using the seq column as a stable cursor.
11    ///
12    /// The limit is capped at MAX_QUERY_LIMIT. total_count reflects the full
13    /// filtered set (no cursor applied), regardless of the page limit.
14    pub fn query_receipts(
15        &self,
16        query: &ReceiptQuery,
17    ) -> Result<ReceiptQueryResult, ReceiptStoreError> {
18        // Delegate to the impl in receipt_store.rs (which can access the private connection field).
19        self.query_receipts_impl(query)
20    }
21}
22
23#[cfg(test)]
24#[allow(clippy::expect_used, clippy::unwrap_used)]
25mod tests {
26    use std::time::{SystemTime, UNIX_EPOCH};
27
28    use chio_core::crypto::Keypair;
29    use chio_core::receipt::{ChioReceipt, ChioReceiptBody, Decision, ToolCallAction};
30    use chio_kernel::{ReceiptStore, MAX_QUERY_LIMIT};
31
32    use super::*;
33    use crate::receipt_store::SqliteReceiptStore;
34
35    fn unique_db_path(prefix: &str) -> std::path::PathBuf {
36        let nonce = SystemTime::now()
37            .duration_since(UNIX_EPOCH)
38            .expect("time before epoch")
39            .as_nanos();
40        std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
41    }
42
43    fn valid_tool_action(parameters: serde_json::Value) -> ToolCallAction {
44        ToolCallAction::from_parameters(parameters).unwrap()
45    }
46
47    fn make_receipt_with_metadata(
48        id: &str,
49        capability_id: &str,
50        tool_server: &str,
51        tool_name: &str,
52        decision: Decision,
53        timestamp: u64,
54        metadata: Option<serde_json::Value>,
55    ) -> ChioReceipt {
56        let keypair = Keypair::generate();
57        ChioReceipt::sign(
58            ChioReceiptBody {
59                id: id.to_string(),
60                timestamp,
61                capability_id: capability_id.to_string(),
62                tool_server: tool_server.to_string(),
63                tool_name: tool_name.to_string(),
64                action: valid_tool_action(serde_json::json!({})),
65                decision,
66                content_hash: "content-hash".to_string(),
67                policy_hash: "policy-hash".to_string(),
68                evidence: Vec::new(),
69                metadata,
70                trust_level: chio_core::TrustLevel::default(),
71                tenant_id: None,
72                kernel_key: keypair.public_key(),
73            },
74            &keypair,
75        )
76        .unwrap()
77    }
78
79    /// Build a receipt with given fields. cost populates financial metadata.
80    fn make_receipt(
81        id: &str,
82        capability_id: &str,
83        tool_server: &str,
84        tool_name: &str,
85        decision: Decision,
86        timestamp: u64,
87        cost: Option<u64>,
88    ) -> ChioReceipt {
89        let metadata = cost.map(|c| {
90            serde_json::json!({
91                "financial": {
92                    "grant_index": 0u32,
93                    "cost_charged": c,
94                    "currency": "USD",
95                    "budget_remaining": 1000u64,
96                    "budget_total": 2000u64,
97                    "delegation_depth": 0u32,
98                    "root_budget_holder": "root-agent",
99                    "settlement_status": "pending"
100                }
101            })
102        });
103        make_receipt_with_metadata(
104            id,
105            capability_id,
106            tool_server,
107            tool_name,
108            decision,
109            timestamp,
110            metadata,
111        )
112    }
113
114    #[test]
115    fn test_query_no_filters() {
116        let path = unique_db_path("rq-no-filters");
117        let mut store = SqliteReceiptStore::open(&path).unwrap();
118
119        for i in 0..5usize {
120            let r = make_receipt(
121                &format!("r-{i}"),
122                "cap-1",
123                "shell",
124                "bash",
125                Decision::Allow,
126                100 + i as u64,
127                None,
128            );
129            store.append_chio_receipt(&r).unwrap();
130        }
131
132        let result = store
133            .query_receipts(&ReceiptQuery {
134                limit: 10,
135                ..Default::default()
136            })
137            .unwrap();
138
139        assert_eq!(result.receipts.len(), 5);
140        assert_eq!(result.total_count, 5);
141        // Results ordered by seq ASC.
142        let seqs: Vec<u64> = result.receipts.iter().map(|r| r.seq).collect();
143        let mut sorted = seqs.clone();
144        sorted.sort();
145        assert_eq!(seqs, sorted, "receipts should be ordered by seq ASC");
146
147        let _ = std::fs::remove_file(path);
148    }
149
150    #[test]
151    fn test_query_filter_capability_id() {
152        let path = unique_db_path("rq-cap-id");
153        let mut store = SqliteReceiptStore::open(&path).unwrap();
154
155        store
156            .append_chio_receipt(&make_receipt(
157                "r-1",
158                "cap-A",
159                "s",
160                "t",
161                Decision::Allow,
162                100,
163                None,
164            ))
165            .unwrap();
166        store
167            .append_chio_receipt(&make_receipt(
168                "r-2",
169                "cap-B",
170                "s",
171                "t",
172                Decision::Allow,
173                101,
174                None,
175            ))
176            .unwrap();
177        store
178            .append_chio_receipt(&make_receipt(
179                "r-3",
180                "cap-A",
181                "s",
182                "t",
183                Decision::Allow,
184                102,
185                None,
186            ))
187            .unwrap();
188
189        let result = store
190            .query_receipts(&ReceiptQuery {
191                capability_id: Some("cap-A".to_string()),
192                limit: 10,
193                ..Default::default()
194            })
195            .unwrap();
196
197        assert_eq!(result.receipts.len(), 2);
198        assert_eq!(result.total_count, 2);
199        for r in &result.receipts {
200            assert_eq!(r.receipt.capability_id, "cap-A");
201        }
202
203        let _ = std::fs::remove_file(path);
204    }
205
206    #[test]
207    fn test_query_filter_tool_server() {
208        let path = unique_db_path("rq-tool-server");
209        let mut store = SqliteReceiptStore::open(&path).unwrap();
210
211        store
212            .append_chio_receipt(&make_receipt(
213                "r-1",
214                "cap-1",
215                "shell",
216                "bash",
217                Decision::Allow,
218                100,
219                None,
220            ))
221            .unwrap();
222        store
223            .append_chio_receipt(&make_receipt(
224                "r-2",
225                "cap-1",
226                "files",
227                "read",
228                Decision::Allow,
229                101,
230                None,
231            ))
232            .unwrap();
233        store
234            .append_chio_receipt(&make_receipt(
235                "r-3",
236                "cap-1",
237                "shell",
238                "ls",
239                Decision::Allow,
240                102,
241                None,
242            ))
243            .unwrap();
244
245        let result = store
246            .query_receipts(&ReceiptQuery {
247                tool_server: Some("shell".to_string()),
248                limit: 10,
249                ..Default::default()
250            })
251            .unwrap();
252
253        assert_eq!(result.receipts.len(), 2);
254        assert_eq!(result.total_count, 2);
255        for r in &result.receipts {
256            assert_eq!(r.receipt.tool_server, "shell");
257        }
258
259        let _ = std::fs::remove_file(path);
260    }
261
262    #[test]
263    fn test_query_filter_tool_name() {
264        let path = unique_db_path("rq-tool-name");
265        let mut store = SqliteReceiptStore::open(&path).unwrap();
266
267        store
268            .append_chio_receipt(&make_receipt(
269                "r-1",
270                "cap-1",
271                "shell",
272                "bash",
273                Decision::Allow,
274                100,
275                None,
276            ))
277            .unwrap();
278        store
279            .append_chio_receipt(&make_receipt(
280                "r-2",
281                "cap-1",
282                "shell",
283                "ls",
284                Decision::Allow,
285                101,
286                None,
287            ))
288            .unwrap();
289        store
290            .append_chio_receipt(&make_receipt(
291                "r-3",
292                "cap-1",
293                "shell",
294                "bash",
295                Decision::Allow,
296                102,
297                None,
298            ))
299            .unwrap();
300
301        let result = store
302            .query_receipts(&ReceiptQuery {
303                tool_name: Some("bash".to_string()),
304                limit: 10,
305                ..Default::default()
306            })
307            .unwrap();
308
309        assert_eq!(result.receipts.len(), 2);
310        assert_eq!(result.total_count, 2);
311        for r in &result.receipts {
312            assert_eq!(r.receipt.tool_name, "bash");
313        }
314
315        let _ = std::fs::remove_file(path);
316    }
317
318    #[test]
319    fn test_query_filter_outcome() {
320        let path = unique_db_path("rq-outcome");
321        let mut store = SqliteReceiptStore::open(&path).unwrap();
322
323        store
324            .append_chio_receipt(&make_receipt(
325                "r-1",
326                "cap-1",
327                "s",
328                "t",
329                Decision::Allow,
330                100,
331                None,
332            ))
333            .unwrap();
334        store
335            .append_chio_receipt(&make_receipt(
336                "r-2",
337                "cap-1",
338                "s",
339                "t",
340                Decision::Deny {
341                    reason: "no".to_string(),
342                    guard: "G".to_string(),
343                },
344                101,
345                None,
346            ))
347            .unwrap();
348        store
349            .append_chio_receipt(&make_receipt(
350                "r-3",
351                "cap-1",
352                "s",
353                "t",
354                Decision::Allow,
355                102,
356                None,
357            ))
358            .unwrap();
359
360        let result = store
361            .query_receipts(&ReceiptQuery {
362                outcome: Some("allow".to_string()),
363                limit: 10,
364                ..Default::default()
365            })
366            .unwrap();
367
368        assert_eq!(result.receipts.len(), 2);
369        assert_eq!(result.total_count, 2);
370        for r in &result.receipts {
371            assert!(r.receipt.is_allowed());
372        }
373
374        let _ = std::fs::remove_file(path);
375    }
376
377    #[test]
378    fn test_query_filter_time_range_since() {
379        let path = unique_db_path("rq-since");
380        let mut store = SqliteReceiptStore::open(&path).unwrap();
381
382        store
383            .append_chio_receipt(&make_receipt(
384                "r-1",
385                "cap-1",
386                "s",
387                "t",
388                Decision::Allow,
389                100,
390                None,
391            ))
392            .unwrap();
393        store
394            .append_chio_receipt(&make_receipt(
395                "r-2",
396                "cap-1",
397                "s",
398                "t",
399                Decision::Allow,
400                200,
401                None,
402            ))
403            .unwrap();
404        store
405            .append_chio_receipt(&make_receipt(
406                "r-3",
407                "cap-1",
408                "s",
409                "t",
410                Decision::Allow,
411                300,
412                None,
413            ))
414            .unwrap();
415
416        let result = store
417            .query_receipts(&ReceiptQuery {
418                since: Some(200),
419                limit: 10,
420                ..Default::default()
421            })
422            .unwrap();
423
424        assert_eq!(
425            result.receipts.len(),
426            2,
427            "since 200 should include timestamps 200 and 300"
428        );
429        assert_eq!(result.total_count, 2);
430        for r in &result.receipts {
431            assert!(r.receipt.timestamp >= 200);
432        }
433
434        let _ = std::fs::remove_file(path);
435    }
436
437    #[test]
438    fn test_query_filter_time_range_until() {
439        let path = unique_db_path("rq-until");
440        let mut store = SqliteReceiptStore::open(&path).unwrap();
441
442        store
443            .append_chio_receipt(&make_receipt(
444                "r-1",
445                "cap-1",
446                "s",
447                "t",
448                Decision::Allow,
449                100,
450                None,
451            ))
452            .unwrap();
453        store
454            .append_chio_receipt(&make_receipt(
455                "r-2",
456                "cap-1",
457                "s",
458                "t",
459                Decision::Allow,
460                200,
461                None,
462            ))
463            .unwrap();
464        store
465            .append_chio_receipt(&make_receipt(
466                "r-3",
467                "cap-1",
468                "s",
469                "t",
470                Decision::Allow,
471                300,
472                None,
473            ))
474            .unwrap();
475
476        let result = store
477            .query_receipts(&ReceiptQuery {
478                until: Some(200),
479                limit: 10,
480                ..Default::default()
481            })
482            .unwrap();
483
484        assert_eq!(
485            result.receipts.len(),
486            2,
487            "until 200 should include timestamps 100 and 200"
488        );
489        assert_eq!(result.total_count, 2);
490        for r in &result.receipts {
491            assert!(r.receipt.timestamp <= 200);
492        }
493
494        let _ = std::fs::remove_file(path);
495    }
496
497    #[test]
498    fn test_query_filter_time_range_both() {
499        let path = unique_db_path("rq-time-both");
500        let mut store = SqliteReceiptStore::open(&path).unwrap();
501
502        store
503            .append_chio_receipt(&make_receipt(
504                "r-1",
505                "cap-1",
506                "s",
507                "t",
508                Decision::Allow,
509                100,
510                None,
511            ))
512            .unwrap();
513        store
514            .append_chio_receipt(&make_receipt(
515                "r-2",
516                "cap-1",
517                "s",
518                "t",
519                Decision::Allow,
520                200,
521                None,
522            ))
523            .unwrap();
524        store
525            .append_chio_receipt(&make_receipt(
526                "r-3",
527                "cap-1",
528                "s",
529                "t",
530                Decision::Allow,
531                300,
532                None,
533            ))
534            .unwrap();
535        store
536            .append_chio_receipt(&make_receipt(
537                "r-4",
538                "cap-1",
539                "s",
540                "t",
541                Decision::Allow,
542                400,
543                None,
544            ))
545            .unwrap();
546
547        let result = store
548            .query_receipts(&ReceiptQuery {
549                since: Some(200),
550                until: Some(300),
551                limit: 10,
552                ..Default::default()
553            })
554            .unwrap();
555
556        assert_eq!(result.receipts.len(), 2);
557        assert_eq!(result.total_count, 2);
558        for r in &result.receipts {
559            assert!(r.receipt.timestamp >= 200 && r.receipt.timestamp <= 300);
560        }
561
562        let _ = std::fs::remove_file(path);
563    }
564
565    #[test]
566    fn test_query_filter_cost_range_min() {
567        let path = unique_db_path("rq-min-cost");
568        let mut store = SqliteReceiptStore::open(&path).unwrap();
569
570        // No cost (no financial metadata).
571        store
572            .append_chio_receipt(&make_receipt(
573                "r-1",
574                "cap-1",
575                "s",
576                "t",
577                Decision::Allow,
578                100,
579                None,
580            ))
581            .unwrap();
582        // cost = 50
583        store
584            .append_chio_receipt(&make_receipt(
585                "r-2",
586                "cap-1",
587                "s",
588                "t",
589                Decision::Allow,
590                101,
591                Some(50),
592            ))
593            .unwrap();
594        // cost = 150
595        store
596            .append_chio_receipt(&make_receipt(
597                "r-3",
598                "cap-1",
599                "s",
600                "t",
601                Decision::Allow,
602                102,
603                Some(150),
604            ))
605            .unwrap();
606
607        let result = store
608            .query_receipts(&ReceiptQuery {
609                min_cost: Some(100),
610                limit: 10,
611                ..Default::default()
612            })
613            .unwrap();
614
615        // Only r-3 (cost=150) passes -- r-1 has no metadata, r-2 has cost<100
616        assert_eq!(
617            result.receipts.len(),
618            1,
619            "only r-3 with cost=150 should match min_cost=100"
620        );
621        assert_eq!(result.total_count, 1);
622
623        let _ = std::fs::remove_file(path);
624    }
625
626    #[test]
627    fn test_query_filter_cost_range_max() {
628        let path = unique_db_path("rq-max-cost");
629        let mut store = SqliteReceiptStore::open(&path).unwrap();
630
631        // No cost (no financial metadata).
632        store
633            .append_chio_receipt(&make_receipt(
634                "r-1",
635                "cap-1",
636                "s",
637                "t",
638                Decision::Allow,
639                100,
640                None,
641            ))
642            .unwrap();
643        // cost = 50
644        store
645            .append_chio_receipt(&make_receipt(
646                "r-2",
647                "cap-1",
648                "s",
649                "t",
650                Decision::Allow,
651                101,
652                Some(50),
653            ))
654            .unwrap();
655        // cost = 150
656        store
657            .append_chio_receipt(&make_receipt(
658                "r-3",
659                "cap-1",
660                "s",
661                "t",
662                Decision::Allow,
663                102,
664                Some(150),
665            ))
666            .unwrap();
667
668        let result = store
669            .query_receipts(&ReceiptQuery {
670                max_cost: Some(100),
671                limit: 10,
672                ..Default::default()
673            })
674            .unwrap();
675
676        // Only r-2 (cost=50) passes -- r-1 has no metadata, r-3 has cost>100
677        assert_eq!(
678            result.receipts.len(),
679            1,
680            "only r-2 with cost=50 should match max_cost=100"
681        );
682        assert_eq!(result.total_count, 1);
683
684        let _ = std::fs::remove_file(path);
685    }
686
687    #[test]
688    fn test_query_filter_cost_range_both() {
689        let path = unique_db_path("rq-cost-both");
690        let mut store = SqliteReceiptStore::open(&path).unwrap();
691
692        store
693            .append_chio_receipt(&make_receipt(
694                "r-1",
695                "cap-1",
696                "s",
697                "t",
698                Decision::Allow,
699                100,
700                None,
701            ))
702            .unwrap();
703        store
704            .append_chio_receipt(&make_receipt(
705                "r-2",
706                "cap-1",
707                "s",
708                "t",
709                Decision::Allow,
710                101,
711                Some(50),
712            ))
713            .unwrap();
714        store
715            .append_chio_receipt(&make_receipt(
716                "r-3",
717                "cap-1",
718                "s",
719                "t",
720                Decision::Allow,
721                102,
722                Some(100),
723            ))
724            .unwrap();
725        store
726            .append_chio_receipt(&make_receipt(
727                "r-4",
728                "cap-1",
729                "s",
730                "t",
731                Decision::Allow,
732                103,
733                Some(200),
734            ))
735            .unwrap();
736
737        let result = store
738            .query_receipts(&ReceiptQuery {
739                min_cost: Some(75),
740                max_cost: Some(150),
741                limit: 10,
742                ..Default::default()
743            })
744            .unwrap();
745
746        // Only r-3 (cost=100) passes the 75..=150 window
747        assert_eq!(
748            result.receipts.len(),
749            1,
750            "only r-3 with cost=100 should match 75..=150 window"
751        );
752        assert_eq!(result.total_count, 1);
753
754        let _ = std::fs::remove_file(path);
755    }
756
757    #[test]
758    fn test_query_cursor_pagination() {
759        let path = unique_db_path("rq-cursor");
760        let mut store = SqliteReceiptStore::open(&path).unwrap();
761
762        for i in 0..5usize {
763            let r = make_receipt(
764                &format!("r-{i}"),
765                "cap-1",
766                "s",
767                "t",
768                Decision::Allow,
769                100 + i as u64,
770                None,
771            );
772            store.append_chio_receipt(&r).unwrap();
773        }
774
775        // Get first 2 receipts.
776        let page1 = store
777            .query_receipts(&ReceiptQuery {
778                cursor: None,
779                limit: 2,
780                ..Default::default()
781            })
782            .unwrap();
783        assert_eq!(page1.receipts.len(), 2);
784
785        let cursor = page1.next_cursor.expect("should have next cursor");
786
787        // Get next page after cursor.
788        let page2 = store
789            .query_receipts(&ReceiptQuery {
790                cursor: Some(cursor),
791                limit: 2,
792                ..Default::default()
793            })
794            .unwrap();
795
796        // All seq in page2 must be > cursor.
797        for r in &page2.receipts {
798            assert!(
799                r.seq > cursor,
800                "page2 receipt seq {} should be > cursor {}",
801                r.seq,
802                cursor
803            );
804        }
805
806        let _ = std::fs::remove_file(path);
807    }
808
809    #[test]
810    fn test_query_cursor_pagination_pages() {
811        let path = unique_db_path("rq-cursor-pages");
812        let mut store = SqliteReceiptStore::open(&path).unwrap();
813
814        for i in 0..7usize {
815            let r = make_receipt(
816                &format!("r-{i}"),
817                "cap-1",
818                "s",
819                "t",
820                Decision::Allow,
821                100 + i as u64,
822                None,
823            );
824            store.append_chio_receipt(&r).unwrap();
825        }
826
827        // Paginate through all 7 receipts with page size 3.
828        let mut all_seqs = Vec::new();
829        let mut cursor = None;
830
831        loop {
832            let page = store
833                .query_receipts(&ReceiptQuery {
834                    cursor,
835                    limit: 3,
836                    ..Default::default()
837                })
838                .unwrap();
839
840            for r in &page.receipts {
841                all_seqs.push(r.seq);
842            }
843
844            cursor = page.next_cursor;
845            if cursor.is_none() {
846                break;
847            }
848        }
849
850        assert_eq!(
851            all_seqs.len(),
852            7,
853            "all 7 receipts should be seen across pages"
854        );
855
856        // No duplicates -- seqs are strictly increasing so dedup covers exact duplicates.
857        let mut unique = all_seqs.clone();
858        unique.dedup();
859        assert_eq!(all_seqs, unique, "no duplicate seqs");
860
861        let _ = std::fs::remove_file(path);
862    }
863
864    #[test]
865    fn test_query_next_cursor_some_when_more() {
866        let path = unique_db_path("rq-next-cursor-some");
867        let mut store = SqliteReceiptStore::open(&path).unwrap();
868
869        for i in 0..5usize {
870            let r = make_receipt(
871                &format!("r-{i}"),
872                "cap-1",
873                "s",
874                "t",
875                Decision::Allow,
876                100 + i as u64,
877                None,
878            );
879            store.append_chio_receipt(&r).unwrap();
880        }
881
882        let result = store
883            .query_receipts(&ReceiptQuery {
884                limit: 3,
885                ..Default::default()
886            })
887            .unwrap();
888
889        // 5 total, page size 3, so there should be a next_cursor.
890        assert_eq!(result.receipts.len(), 3);
891        assert!(
892            result.next_cursor.is_some(),
893            "next_cursor should be Some when results.len() == limit"
894        );
895        assert_eq!(result.next_cursor.unwrap(), result.receipts[2].seq);
896
897        let _ = std::fs::remove_file(path);
898    }
899
900    #[test]
901    fn test_query_next_cursor_none_when_last_page() {
902        let path = unique_db_path("rq-next-cursor-none");
903        let mut store = SqliteReceiptStore::open(&path).unwrap();
904
905        for i in 0..3usize {
906            let r = make_receipt(
907                &format!("r-{i}"),
908                "cap-1",
909                "s",
910                "t",
911                Decision::Allow,
912                100 + i as u64,
913                None,
914            );
915            store.append_chio_receipt(&r).unwrap();
916        }
917
918        let result = store
919            .query_receipts(&ReceiptQuery {
920                limit: 5,
921                ..Default::default()
922            })
923            .unwrap();
924
925        // 3 total, page size 5, so this is the last page.
926        assert_eq!(result.receipts.len(), 3);
927        assert!(
928            result.next_cursor.is_none(),
929            "next_cursor should be None when results.len() < limit"
930        );
931
932        let _ = std::fs::remove_file(path);
933    }
934
935    #[test]
936    fn test_query_total_count() {
937        let path = unique_db_path("rq-total-count");
938        let mut store = SqliteReceiptStore::open(&path).unwrap();
939
940        for i in 0..10usize {
941            let r = make_receipt(
942                &format!("r-{i}"),
943                "cap-1",
944                "s",
945                "t",
946                Decision::Allow,
947                100 + i as u64,
948                None,
949            );
950            store.append_chio_receipt(&r).unwrap();
951        }
952
953        // Fetch only 3 receipts but total_count should reflect all 10.
954        let result = store
955            .query_receipts(&ReceiptQuery {
956                limit: 3,
957                ..Default::default()
958            })
959            .unwrap();
960
961        assert_eq!(result.receipts.len(), 3);
962        assert_eq!(
963            result.total_count, 10,
964            "total_count should reflect all matching receipts"
965        );
966
967        let _ = std::fs::remove_file(path);
968    }
969
970    #[test]
971    fn test_query_limit_capped() {
972        let path = unique_db_path("rq-limit-capped");
973        let mut store = SqliteReceiptStore::open(&path).unwrap();
974
975        // Insert MAX_QUERY_LIMIT + 10 receipts.
976        for i in 0..(MAX_QUERY_LIMIT + 10) {
977            let r = make_receipt(
978                &format!("r-{i}"),
979                "cap-1",
980                "s",
981                "t",
982                Decision::Allow,
983                100 + i as u64,
984                None,
985            );
986            store.append_chio_receipt(&r).unwrap();
987        }
988
989        // Request more than MAX_QUERY_LIMIT -- should be capped.
990        let result = store
991            .query_receipts(&ReceiptQuery {
992                limit: MAX_QUERY_LIMIT + 100,
993                ..Default::default()
994            })
995            .unwrap();
996
997        assert_eq!(
998            result.receipts.len(),
999            MAX_QUERY_LIMIT,
1000            "limit above MAX_QUERY_LIMIT should be capped to MAX_QUERY_LIMIT"
1001        );
1002
1003        let _ = std::fs::remove_file(path);
1004    }
1005
1006    // ---------------------------------------------------------------------------
1007    // agent_subject filter tests (Phase 12-02)
1008    // ---------------------------------------------------------------------------
1009
1010    #[test]
1011    fn test_query_agent_subject_filter() {
1012        let path = unique_db_path("rq-agent-subject");
1013        let mut store = SqliteReceiptStore::open(&path).unwrap();
1014
1015        use chio_core::capability::{
1016            CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1017        };
1018        use chio_core::crypto::Keypair;
1019
1020        let kp_agent1 = Keypair::generate();
1021        let kp_agent2 = Keypair::generate();
1022        let kp_issuer = Keypair::generate();
1023
1024        let make_token_local = |id: &str, subject_kp: &Keypair, issuer_kp: &Keypair| {
1025            let body = CapabilityTokenBody {
1026                id: id.to_string(),
1027                issuer: issuer_kp.public_key(),
1028                subject: subject_kp.public_key(),
1029                scope: ChioScope {
1030                    grants: vec![ToolGrant {
1031                        server_id: "shell".to_string(),
1032                        tool_name: "bash".to_string(),
1033                        operations: vec![Operation::Invoke],
1034                        constraints: vec![],
1035                        max_invocations: None,
1036                        max_cost_per_invocation: None,
1037                        max_total_cost: None,
1038                        dpop_required: None,
1039                    }],
1040                    resource_grants: vec![],
1041                    prompt_grants: vec![],
1042                },
1043                issued_at: 1000,
1044                expires_at: 9000,
1045                delegation_chain: vec![],
1046            };
1047            CapabilityToken::sign(body, issuer_kp).expect("sign failed")
1048        };
1049
1050        let token1 = make_token_local("cap-agent1", &kp_agent1, &kp_issuer);
1051        let token2 = make_token_local("cap-agent2", &kp_agent2, &kp_issuer);
1052
1053        store.record_capability_snapshot(&token1, None).unwrap();
1054        store.record_capability_snapshot(&token2, None).unwrap();
1055
1056        // 2 receipts for agent1, 1 for agent2
1057        store
1058            .append_chio_receipt(&make_receipt(
1059                "r-1",
1060                "cap-agent1",
1061                "s",
1062                "t",
1063                Decision::Allow,
1064                100,
1065                None,
1066            ))
1067            .unwrap();
1068        store
1069            .append_chio_receipt(&make_receipt(
1070                "r-2",
1071                "cap-agent1",
1072                "s",
1073                "t",
1074                Decision::Allow,
1075                101,
1076                None,
1077            ))
1078            .unwrap();
1079        store
1080            .append_chio_receipt(&make_receipt(
1081                "r-3",
1082                "cap-agent2",
1083                "s",
1084                "t",
1085                Decision::Allow,
1086                102,
1087                None,
1088            ))
1089            .unwrap();
1090
1091        let agent1_key = kp_agent1.public_key().to_hex();
1092        let result = store
1093            .query_receipts(&ReceiptQuery {
1094                agent_subject: Some(agent1_key),
1095                limit: 10,
1096                ..Default::default()
1097            })
1098            .unwrap();
1099
1100        assert_eq!(
1101            result.receipts.len(),
1102            2,
1103            "only agent1 receipts should match"
1104        );
1105        assert_eq!(result.total_count, 2);
1106        for r in &result.receipts {
1107            assert_eq!(r.receipt.capability_id, "cap-agent1");
1108        }
1109
1110        let _ = std::fs::remove_file(path);
1111    }
1112
1113    #[test]
1114    fn test_query_agent_subject_none_returns_all() {
1115        let path = unique_db_path("rq-agent-none");
1116        let mut store = SqliteReceiptStore::open(&path).unwrap();
1117
1118        use chio_core::capability::{
1119            CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1120        };
1121        use chio_core::crypto::Keypair;
1122
1123        let kp_agent1 = Keypair::generate();
1124        let kp_agent2 = Keypair::generate();
1125        let kp_issuer = Keypair::generate();
1126
1127        let make_token_local = |id: &str, subject_kp: &Keypair, issuer_kp: &Keypair| {
1128            let body = CapabilityTokenBody {
1129                id: id.to_string(),
1130                issuer: issuer_kp.public_key(),
1131                subject: subject_kp.public_key(),
1132                scope: ChioScope {
1133                    grants: vec![ToolGrant {
1134                        server_id: "shell".to_string(),
1135                        tool_name: "bash".to_string(),
1136                        operations: vec![Operation::Invoke],
1137                        constraints: vec![],
1138                        max_invocations: None,
1139                        max_cost_per_invocation: None,
1140                        max_total_cost: None,
1141                        dpop_required: None,
1142                    }],
1143                    resource_grants: vec![],
1144                    prompt_grants: vec![],
1145                },
1146                issued_at: 1000,
1147                expires_at: 9000,
1148                delegation_chain: vec![],
1149            };
1150            CapabilityToken::sign(body, issuer_kp).expect("sign failed")
1151        };
1152
1153        let token1 = make_token_local("cap-none-1", &kp_agent1, &kp_issuer);
1154        let token2 = make_token_local("cap-none-2", &kp_agent2, &kp_issuer);
1155
1156        store.record_capability_snapshot(&token1, None).unwrap();
1157        store.record_capability_snapshot(&token2, None).unwrap();
1158
1159        store
1160            .append_chio_receipt(&make_receipt(
1161                "r-1",
1162                "cap-none-1",
1163                "s",
1164                "t",
1165                Decision::Allow,
1166                100,
1167                None,
1168            ))
1169            .unwrap();
1170        store
1171            .append_chio_receipt(&make_receipt(
1172                "r-2",
1173                "cap-none-2",
1174                "s",
1175                "t",
1176                Decision::Allow,
1177                101,
1178                None,
1179            ))
1180            .unwrap();
1181        store
1182            .append_chio_receipt(&make_receipt(
1183                "r-3",
1184                "cap-none-1",
1185                "s",
1186                "t",
1187                Decision::Allow,
1188                102,
1189                None,
1190            ))
1191            .unwrap();
1192
1193        // agent_subject=None should return all 3 receipts
1194        let result = store
1195            .query_receipts(&ReceiptQuery {
1196                agent_subject: None,
1197                limit: 10,
1198                ..Default::default()
1199            })
1200            .unwrap();
1201
1202        assert_eq!(
1203            result.receipts.len(),
1204            3,
1205            "no agent_subject filter should return all receipts"
1206        );
1207        assert_eq!(result.total_count, 3);
1208
1209        let _ = std::fs::remove_file(path);
1210    }
1211
1212    #[test]
1213    fn test_query_agent_subject_no_match() {
1214        let path = unique_db_path("rq-agent-no-match");
1215        let mut store = SqliteReceiptStore::open(&path).unwrap();
1216
1217        store
1218            .append_chio_receipt(&make_receipt(
1219                "r-1",
1220                "cap-1",
1221                "s",
1222                "t",
1223                Decision::Allow,
1224                100,
1225                None,
1226            ))
1227            .unwrap();
1228
1229        // Query with a key that does not exist in capability_lineage
1230        let result = store
1231            .query_receipts(&ReceiptQuery {
1232                agent_subject: Some("deadbeef".to_string()),
1233                limit: 10,
1234                ..Default::default()
1235            })
1236            .unwrap();
1237
1238        assert_eq!(result.receipts.len(), 0, "no match should return empty");
1239        assert_eq!(result.total_count, 0);
1240
1241        let _ = std::fs::remove_file(path);
1242    }
1243
1244    #[test]
1245    fn test_query_agent_subject_with_outcome_filter() {
1246        let path = unique_db_path("rq-agent-outcome");
1247        let mut store = SqliteReceiptStore::open(&path).unwrap();
1248
1249        use chio_core::capability::{
1250            CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1251        };
1252        use chio_core::crypto::Keypair;
1253
1254        let kp_agent = Keypair::generate();
1255        let kp_issuer = Keypair::generate();
1256
1257        let body = CapabilityTokenBody {
1258            id: "cap-outcome-1".to_string(),
1259            issuer: kp_issuer.public_key(),
1260            subject: kp_agent.public_key(),
1261            scope: ChioScope {
1262                grants: vec![ToolGrant {
1263                    server_id: "shell".to_string(),
1264                    tool_name: "bash".to_string(),
1265                    operations: vec![Operation::Invoke],
1266                    constraints: vec![],
1267                    max_invocations: None,
1268                    max_cost_per_invocation: None,
1269                    max_total_cost: None,
1270                    dpop_required: None,
1271                }],
1272                resource_grants: vec![],
1273                prompt_grants: vec![],
1274            },
1275            issued_at: 1000,
1276            expires_at: 9000,
1277            delegation_chain: vec![],
1278        };
1279        let token = CapabilityToken::sign(body, &kp_issuer).expect("sign failed");
1280        store.record_capability_snapshot(&token, None).unwrap();
1281
1282        // 1 allow + 1 deny for the same agent capability
1283        store
1284            .append_chio_receipt(&make_receipt(
1285                "r-1",
1286                "cap-outcome-1",
1287                "s",
1288                "t",
1289                Decision::Allow,
1290                100,
1291                None,
1292            ))
1293            .unwrap();
1294        store
1295            .append_chio_receipt(&make_receipt(
1296                "r-2",
1297                "cap-outcome-1",
1298                "s",
1299                "t",
1300                Decision::Deny {
1301                    reason: "no".to_string(),
1302                    guard: "G".to_string(),
1303                },
1304                101,
1305                None,
1306            ))
1307            .unwrap();
1308
1309        let agent_key = kp_agent.public_key().to_hex();
1310        let result = store
1311            .query_receipts(&ReceiptQuery {
1312                agent_subject: Some(agent_key),
1313                outcome: Some("allow".to_string()),
1314                limit: 10,
1315                ..Default::default()
1316            })
1317            .unwrap();
1318
1319        // Intersection: agent1 AND outcome=allow -> only r-1
1320        assert_eq!(
1321            result.receipts.len(),
1322            1,
1323            "intersection of agent and outcome=allow should be 1"
1324        );
1325        assert_eq!(result.total_count, 1);
1326        assert!(result.receipts[0].receipt.is_allowed());
1327
1328        let _ = std::fs::remove_file(path);
1329    }
1330
1331    #[test]
1332    fn test_query_agent_subject_cursor_pagination() {
1333        let path = unique_db_path("rq-agent-cursor");
1334        let mut store = SqliteReceiptStore::open(&path).unwrap();
1335
1336        use chio_core::capability::{
1337            CapabilityToken, CapabilityTokenBody, ChioScope, Operation, ToolGrant,
1338        };
1339        use chio_core::crypto::Keypair;
1340
1341        let kp_agent = Keypair::generate();
1342        let kp_issuer = Keypair::generate();
1343
1344        let body = CapabilityTokenBody {
1345            id: "cap-page-1".to_string(),
1346            issuer: kp_issuer.public_key(),
1347            subject: kp_agent.public_key(),
1348            scope: ChioScope {
1349                grants: vec![ToolGrant {
1350                    server_id: "shell".to_string(),
1351                    tool_name: "bash".to_string(),
1352                    operations: vec![Operation::Invoke],
1353                    constraints: vec![],
1354                    max_invocations: None,
1355                    max_cost_per_invocation: None,
1356                    max_total_cost: None,
1357                    dpop_required: None,
1358                }],
1359                resource_grants: vec![],
1360                prompt_grants: vec![],
1361            },
1362            issued_at: 1000,
1363            expires_at: 9000,
1364            delegation_chain: vec![],
1365        };
1366        let token = CapabilityToken::sign(body, &kp_issuer).expect("sign failed");
1367        store.record_capability_snapshot(&token, None).unwrap();
1368
1369        // Insert 7 receipts for cap-page-1
1370        for i in 0..7usize {
1371            store
1372                .append_chio_receipt(&make_receipt(
1373                    &format!("r-page-{i}"),
1374                    "cap-page-1",
1375                    "s",
1376                    "t",
1377                    Decision::Allow,
1378                    100 + i as u64,
1379                    None,
1380                ))
1381                .unwrap();
1382        }
1383
1384        let agent_key = kp_agent.public_key().to_hex();
1385
1386        // Paginate with page size 3, collect all seqs
1387        let mut all_seqs = Vec::new();
1388        let mut cursor = None;
1389
1390        loop {
1391            let page = store
1392                .query_receipts(&ReceiptQuery {
1393                    agent_subject: Some(agent_key.clone()),
1394                    cursor,
1395                    limit: 3,
1396                    ..Default::default()
1397                })
1398                .unwrap();
1399
1400            for r in &page.receipts {
1401                all_seqs.push(r.seq);
1402            }
1403
1404            cursor = page.next_cursor;
1405            if cursor.is_none() {
1406                break;
1407            }
1408        }
1409
1410        assert_eq!(
1411            all_seqs.len(),
1412            7,
1413            "all 7 receipts should be seen across pages with agent filter"
1414        );
1415
1416        // No duplicates
1417        let mut unique = all_seqs.clone();
1418        unique.dedup();
1419        assert_eq!(all_seqs, unique, "no duplicate seqs");
1420
1421        let _ = std::fs::remove_file(path);
1422    }
1423
1424    #[test]
1425    fn test_query_cursor_u64_max_returns_empty() {
1426        // Querying with cursor=u64::MAX means "return receipts with seq > u64::MAX",
1427        // which is impossible -- the result must always be empty.
1428        let path = unique_db_path("rq-cursor-u64max");
1429        let mut store = SqliteReceiptStore::open(&path).unwrap();
1430
1431        // Insert a few receipts so the store is non-empty.
1432        for i in 0..5usize {
1433            let r = make_receipt(
1434                &format!("r-max-{i}"),
1435                "cap-1",
1436                "s",
1437                "t",
1438                Decision::Allow,
1439                100 + i as u64,
1440                None,
1441            );
1442            store.append_chio_receipt(&r).unwrap();
1443        }
1444
1445        let result = store
1446            .query_receipts(&ReceiptQuery {
1447                cursor: Some(u64::MAX),
1448                limit: 10,
1449                ..Default::default()
1450            })
1451            .unwrap();
1452
1453        assert_eq!(
1454            result.receipts.len(),
1455            0,
1456            "cursor=u64::MAX should return no receipts (no seq can exceed u64::MAX)"
1457        );
1458        assert!(
1459            result.next_cursor.is_none(),
1460            "next_cursor should be None when result is empty"
1461        );
1462
1463        let _ = std::fs::remove_file(path);
1464    }
1465
1466    #[test]
1467    fn test_query_combined_filters() {
1468        let path = unique_db_path("rq-combined");
1469        let mut store = SqliteReceiptStore::open(&path).unwrap();
1470
1471        // cap-A, allow, ts=200
1472        store
1473            .append_chio_receipt(&make_receipt(
1474                "r-1",
1475                "cap-A",
1476                "s",
1477                "t",
1478                Decision::Allow,
1479                200,
1480                None,
1481            ))
1482            .unwrap();
1483        // cap-A, deny, ts=300
1484        store
1485            .append_chio_receipt(&make_receipt(
1486                "r-2",
1487                "cap-A",
1488                "s",
1489                "t",
1490                Decision::Deny {
1491                    reason: "no".to_string(),
1492                    guard: "G".to_string(),
1493                },
1494                300,
1495                None,
1496            ))
1497            .unwrap();
1498        // cap-B, allow, ts=200
1499        store
1500            .append_chio_receipt(&make_receipt(
1501                "r-3",
1502                "cap-B",
1503                "s",
1504                "t",
1505                Decision::Allow,
1506                200,
1507                None,
1508            ))
1509            .unwrap();
1510        // cap-A, allow, ts=100 (before since)
1511        store
1512            .append_chio_receipt(&make_receipt(
1513                "r-4",
1514                "cap-A",
1515                "s",
1516                "t",
1517                Decision::Allow,
1518                100,
1519                None,
1520            ))
1521            .unwrap();
1522        // cap-A, allow, ts=250 -- matches all 3 filters
1523        store
1524            .append_chio_receipt(&make_receipt(
1525                "r-5",
1526                "cap-A",
1527                "s",
1528                "t",
1529                Decision::Allow,
1530                250,
1531                None,
1532            ))
1533            .unwrap();
1534
1535        let result = store
1536            .query_receipts(&ReceiptQuery {
1537                capability_id: Some("cap-A".to_string()),
1538                outcome: Some("allow".to_string()),
1539                since: Some(150),
1540                limit: 10,
1541                ..Default::default()
1542            })
1543            .unwrap();
1544
1545        // r-1 (cap-A, allow, ts=200) and r-5 (cap-A, allow, ts=250) should match.
1546        assert_eq!(result.receipts.len(), 2);
1547        assert_eq!(result.total_count, 2);
1548        for r in &result.receipts {
1549            assert_eq!(r.receipt.capability_id, "cap-A");
1550            assert!(r.receipt.is_allowed());
1551            assert!(r.receipt.timestamp >= 150);
1552        }
1553
1554        let _ = std::fs::remove_file(path);
1555    }
1556}