1use anyhow::{Context, Result};
64use rusqlite::{Connection, OptionalExtension, params};
65use serde::{Deserialize, Serialize};
66
67pub const GLOBAL_NAMESPACE: &str = "_global";
80
81pub const DEFAULT_MAX_MEMORIES_PER_DAY: i64 = 1000;
85
86pub const DEFAULT_MAX_STORAGE_BYTES: i64 = 100 * 1024 * 1024;
90
91pub const DEFAULT_MAX_LINKS_PER_DAY: i64 = 5000;
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct QuotaDefaults {
107 pub max_memories_per_day: i64,
109 pub max_storage_bytes: i64,
111 pub max_links_per_day: i64,
113}
114
115impl Default for QuotaDefaults {
116 fn default() -> Self {
119 Self {
120 max_memories_per_day: DEFAULT_MAX_MEMORIES_PER_DAY,
121 max_storage_bytes: DEFAULT_MAX_STORAGE_BYTES,
122 max_links_per_day: DEFAULT_MAX_LINKS_PER_DAY,
123 }
124 }
125}
126
127static QUOTA_DEFAULTS: std::sync::OnceLock<QuotaDefaults> = std::sync::OnceLock::new();
128
129pub fn set_quota_defaults(defaults: QuotaDefaults) {
135 let _ = QUOTA_DEFAULTS.set(defaults);
136}
137
138#[must_use]
142pub fn quota_defaults() -> QuotaDefaults {
143 QUOTA_DEFAULTS.get().copied().unwrap_or_default()
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum QuotaOp {
156 Memory { bytes: i64 },
160 Link,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum QuotaLimit {
169 MemoriesPerDay,
172 StorageBytes,
174 LinksPerDay,
177}
178
179impl QuotaLimit {
180 #[must_use]
183 pub const fn as_str(self) -> &'static str {
184 match self {
185 Self::MemoriesPerDay => "memories_per_day",
186 Self::StorageBytes => "storage_bytes",
187 Self::LinksPerDay => "links_per_day",
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct QuotaError {
196 pub agent_id: String,
198 pub namespace: String,
202 pub limit: QuotaLimit,
204 pub current: i64,
206 pub max: i64,
208}
209
210impl std::fmt::Display for QuotaError {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 write!(
213 f,
214 "QUOTA_EXCEEDED: agent {} namespace {} hit {} (current={}, max={})",
215 self.agent_id,
216 self.namespace,
217 self.limit.as_str(),
218 self.current,
219 self.max,
220 )
221 }
222}
223
224impl std::error::Error for QuotaError {}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct QuotaStatus {
231 pub agent_id: String,
232 #[serde(default = "default_namespace")]
238 pub namespace: String,
239 pub max_memories_per_day: i64,
240 pub max_storage_bytes: i64,
241 pub max_links_per_day: i64,
242 pub current_memories_today: i64,
243 pub current_storage_bytes: i64,
244 pub current_links_today: i64,
245 pub day_started_at: String,
246 pub created_at: String,
247 pub updated_at: String,
248}
249
250fn default_namespace() -> String {
251 GLOBAL_NAMESPACE.to_string()
252}
253
254fn ensure_row(conn: &Connection, agent_id: &str, namespace: &str) -> Result<QuotaStatus> {
259 if let Some(row) = load_row(conn, agent_id, namespace)? {
260 return Ok(row);
261 }
262 let now = chrono::Utc::now().to_rfc3339();
263 let day = day_bucket(&now);
264 let defaults = quota_defaults();
265 conn.execute(
266 "INSERT OR IGNORE INTO agent_quotas
267 (agent_id, namespace,
268 max_memories_per_day, max_storage_bytes, max_links_per_day,
269 current_memories_today, current_storage_bytes, current_links_today,
270 day_started_at, created_at, updated_at)
271 VALUES (?1, ?2, ?3, ?4, ?5, 0, 0, 0, ?6, ?7, ?7)",
272 params![
273 agent_id,
274 namespace,
275 defaults.max_memories_per_day,
276 defaults.max_storage_bytes,
277 defaults.max_links_per_day,
278 day,
279 now,
280 ],
281 )
282 .context("failed to insert default quota row")?;
283 load_row(conn, agent_id, namespace)?
284 .context("quota row missing immediately after insert (concurrent delete?)")
285}
286
287fn load_row(conn: &Connection, agent_id: &str, namespace: &str) -> Result<Option<QuotaStatus>> {
290 conn.query_row(
291 "SELECT agent_id, namespace,
292 max_memories_per_day, max_storage_bytes, max_links_per_day,
293 current_memories_today, current_storage_bytes, current_links_today,
294 day_started_at, created_at, updated_at
295 FROM agent_quotas
296 WHERE agent_id = ?1 AND namespace = ?2",
297 params![agent_id, namespace],
298 |r| {
299 Ok(QuotaStatus {
300 agent_id: r.get(0)?,
301 namespace: r.get(1)?,
302 max_memories_per_day: r.get(2)?,
303 max_storage_bytes: r.get(3)?,
304 max_links_per_day: r.get(4)?,
305 current_memories_today: r.get(5)?,
306 current_storage_bytes: r.get(6)?,
307 current_links_today: r.get(7)?,
308 day_started_at: r.get(8)?,
309 created_at: r.get(9)?,
310 updated_at: r.get(10)?,
311 })
312 },
313 )
314 .optional()
315 .context("failed to load agent quota row")
316}
317
318fn day_bucket(rfc3339: &str) -> String {
322 rfc3339.get(..10).unwrap_or(rfc3339).to_string()
323}
324
325pub fn check_quota(
349 conn: &Connection,
350 agent_id: &str,
351 namespace: &str,
352 op: QuotaOp,
353) -> std::result::Result<(), QuotaCheckError> {
354 let row = ensure_row(conn, agent_id, namespace).map_err(QuotaCheckError::Sql)?;
355
356 let today = day_bucket(&chrono::Utc::now().to_rfc3339());
361 let stored_day = day_bucket(&row.day_started_at);
362 let (memories_today, links_today) = if stored_day == today {
363 (row.current_memories_today, row.current_links_today)
364 } else {
365 (0, 0)
366 };
367
368 match op {
369 QuotaOp::Memory { bytes } => {
370 if memories_today.saturating_add(1) > row.max_memories_per_day {
381 return Err(QuotaCheckError::Quota(QuotaError {
382 agent_id: agent_id.to_string(),
383 namespace: namespace.to_string(),
384 limit: QuotaLimit::MemoriesPerDay,
385 current: memories_today,
386 max: row.max_memories_per_day,
387 }));
388 }
389 if row.current_storage_bytes.saturating_add(bytes) > row.max_storage_bytes {
390 return Err(QuotaCheckError::Quota(QuotaError {
391 agent_id: agent_id.to_string(),
392 namespace: namespace.to_string(),
393 limit: QuotaLimit::StorageBytes,
394 current: row.current_storage_bytes,
395 max: row.max_storage_bytes,
396 }));
397 }
398 }
399 QuotaOp::Link => {
400 if links_today.saturating_add(1) > row.max_links_per_day {
403 return Err(QuotaCheckError::Quota(QuotaError {
404 agent_id: agent_id.to_string(),
405 namespace: namespace.to_string(),
406 limit: QuotaLimit::LinksPerDay,
407 current: links_today,
408 max: row.max_links_per_day,
409 }));
410 }
411 }
412 }
413
414 Ok(())
415}
416
417#[derive(Debug)]
422pub enum QuotaCheckError {
423 Quota(QuotaError),
425 Sql(anyhow::Error),
427}
428
429impl std::fmt::Display for QuotaCheckError {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 match self {
432 Self::Quota(q) => std::fmt::Display::fmt(q, f),
433 Self::Sql(e) => write!(f, "quota check substrate error: {e}"),
434 }
435 }
436}
437
438impl std::error::Error for QuotaCheckError {}
439
440fn quota_update_failed(e: impl std::fmt::Display) -> QuotaCheckError {
444 QuotaCheckError::Sql(anyhow::anyhow!("update failed: {e}"))
445}
446
447pub(crate) fn log_refund_op_failed(agent_id: &str, e: &dyn std::fmt::Display) {
451 tracing::warn!("quota refund_op failed for agent {agent_id}: {e}");
452}
453
454pub fn check_and_record(
482 conn: &Connection,
483 agent_id: &str,
484 namespace: &str,
485 op: QuotaOp,
486) -> std::result::Result<(), QuotaCheckError> {
487 let _ = ensure_row(conn, agent_id, namespace).map_err(QuotaCheckError::Sql)?;
490
491 conn.execute_batch(crate::storage::connection::SQL_BEGIN_IMMEDIATE)
497 .map_err(|e| QuotaCheckError::Sql(anyhow::anyhow!("BEGIN IMMEDIATE failed: {e}")))?;
498
499 let result: std::result::Result<(), QuotaCheckError> = (|| {
500 let row = load_row(conn, agent_id, namespace)
501 .map_err(QuotaCheckError::Sql)?
502 .ok_or_else(|| {
503 QuotaCheckError::Sql(anyhow::anyhow!(
504 "quota row vanished mid-transaction for agent {agent_id} namespace {namespace}"
505 ))
506 })?;
507
508 let now = chrono::Utc::now().to_rfc3339();
512 let today = day_bucket(&now);
513 let stored_day = day_bucket(&row.day_started_at);
514 let day_rolled = stored_day != today;
515 let (memories_today, links_today) = if day_rolled {
516 (0, 0)
517 } else {
518 (row.current_memories_today, row.current_links_today)
519 };
520
521 match op {
522 QuotaOp::Memory { bytes } => {
523 if memories_today.saturating_add(1) > row.max_memories_per_day {
528 return Err(QuotaCheckError::Quota(QuotaError {
529 agent_id: agent_id.to_string(),
530 namespace: namespace.to_string(),
531 limit: QuotaLimit::MemoriesPerDay,
532 current: memories_today,
533 max: row.max_memories_per_day,
534 }));
535 }
536 if row.current_storage_bytes.saturating_add(bytes) > row.max_storage_bytes {
537 return Err(QuotaCheckError::Quota(QuotaError {
538 agent_id: agent_id.to_string(),
539 namespace: namespace.to_string(),
540 limit: QuotaLimit::StorageBytes,
541 current: row.current_storage_bytes,
542 max: row.max_storage_bytes,
543 }));
544 }
545 if day_rolled {
546 conn.execute(
547 "UPDATE agent_quotas SET
548 current_memories_today = 1,
549 current_links_today = 0,
550 current_storage_bytes = current_storage_bytes + ?1,
551 day_started_at = ?2,
552 updated_at = ?2
553 WHERE agent_id = ?3 AND namespace = ?4",
554 params![bytes, now, agent_id, namespace],
555 )
556 .map_err(quota_update_failed)?;
557 } else {
558 conn.execute(
559 "UPDATE agent_quotas SET
560 current_memories_today = current_memories_today + 1,
561 current_storage_bytes = current_storage_bytes + ?1,
562 updated_at = ?2
563 WHERE agent_id = ?3 AND namespace = ?4",
564 params![bytes, now, agent_id, namespace],
565 )
566 .map_err(quota_update_failed)?;
567 }
568 }
569 QuotaOp::Link => {
570 if links_today.saturating_add(1) > row.max_links_per_day {
573 return Err(QuotaCheckError::Quota(QuotaError {
574 agent_id: agent_id.to_string(),
575 namespace: namespace.to_string(),
576 limit: QuotaLimit::LinksPerDay,
577 current: links_today,
578 max: row.max_links_per_day,
579 }));
580 }
581 if day_rolled {
582 conn.execute(
583 "UPDATE agent_quotas SET
584 current_memories_today = 0,
585 current_links_today = 1,
586 day_started_at = ?1,
587 updated_at = ?1
588 WHERE agent_id = ?2 AND namespace = ?3",
589 params![now, agent_id, namespace],
590 )
591 .map_err(quota_update_failed)?;
592 } else {
593 conn.execute(
594 "UPDATE agent_quotas SET
595 current_links_today = current_links_today + 1,
596 updated_at = ?1
597 WHERE agent_id = ?2 AND namespace = ?3",
598 params![now, agent_id, namespace],
599 )
600 .map_err(quota_update_failed)?;
601 }
602 }
603 }
604 Ok(())
605 })();
606
607 match result {
608 Ok(()) => {
609 conn.execute_batch(crate::storage::connection::SQL_COMMIT)
610 .map_err(|e| QuotaCheckError::Sql(anyhow::anyhow!("quota commit failed: {e}")))?;
611 Ok(())
612 }
613 Err(e) => {
614 let _ = conn.execute_batch(crate::storage::connection::SQL_ROLLBACK);
617 Err(e)
618 }
619 }
620}
621
622pub fn refund_op(conn: &Connection, agent_id: &str, namespace: &str, op: QuotaOp) -> Result<()> {
641 let now = chrono::Utc::now().to_rfc3339();
642 match op {
643 QuotaOp::Memory { bytes } => {
644 conn.execute(
645 "UPDATE agent_quotas SET
646 current_memories_today = MAX(current_memories_today - 1, 0),
647 current_storage_bytes = MAX(current_storage_bytes - ?1, 0),
648 updated_at = ?2
649 WHERE agent_id = ?3 AND namespace = ?4",
650 params![bytes, now, agent_id, namespace],
651 )?;
652 }
653 QuotaOp::Link => {
654 conn.execute(
655 "UPDATE agent_quotas SET
656 current_links_today = MAX(current_links_today - 1, 0),
657 updated_at = ?1
658 WHERE agent_id = ?2 AND namespace = ?3",
659 params![now, agent_id, namespace],
660 )?;
661 }
662 }
663 Ok(())
664}
665
666pub fn record_op(conn: &Connection, agent_id: &str, namespace: &str, op: QuotaOp) -> Result<()> {
684 let row = ensure_row(conn, agent_id, namespace)?;
687 let now = chrono::Utc::now().to_rfc3339();
688 let today = day_bucket(&now);
689 let stored_day = day_bucket(&row.day_started_at);
690 let day_rolled = stored_day != today;
691
692 match op {
693 QuotaOp::Memory { bytes } => {
694 if day_rolled {
695 conn.execute(
696 "UPDATE agent_quotas SET
697 current_memories_today = 1,
698 current_links_today = 0,
699 current_storage_bytes = current_storage_bytes + ?1,
700 day_started_at = ?2,
701 updated_at = ?2
702 WHERE agent_id = ?3 AND namespace = ?4",
703 params![bytes, now, agent_id, namespace],
704 )?;
705 } else {
706 conn.execute(
707 "UPDATE agent_quotas SET
708 current_memories_today = current_memories_today + 1,
709 current_storage_bytes = current_storage_bytes + ?1,
710 updated_at = ?2
711 WHERE agent_id = ?3 AND namespace = ?4",
712 params![bytes, now, agent_id, namespace],
713 )?;
714 }
715 }
716 QuotaOp::Link => {
717 if day_rolled {
718 conn.execute(
719 "UPDATE agent_quotas SET
720 current_memories_today = 0,
721 current_links_today = 1,
722 day_started_at = ?1,
723 updated_at = ?1
724 WHERE agent_id = ?2 AND namespace = ?3",
725 params![now, agent_id, namespace],
726 )?;
727 } else {
728 conn.execute(
729 "UPDATE agent_quotas SET
730 current_links_today = current_links_today + 1,
731 updated_at = ?1
732 WHERE agent_id = ?2 AND namespace = ?3",
733 params![now, agent_id, namespace],
734 )?;
735 }
736 }
737 }
738 Ok(())
739}
740
741pub fn reset_daily(conn: &Connection) -> Result<usize> {
759 let now = chrono::Utc::now().to_rfc3339();
760 let today = day_bucket(&now);
761 let affected = conn.execute(
762 "UPDATE agent_quotas SET
763 current_memories_today = 0,
764 current_links_today = 0,
765 day_started_at = ?1,
766 updated_at = ?1
767 WHERE substr(day_started_at, 1, 10) <> ?2",
768 params![now, today],
769 )?;
770 Ok(affected)
771}
772
773pub fn get_status(conn: &Connection, agent_id: &str, namespace: &str) -> Result<QuotaStatus> {
788 ensure_row(conn, agent_id, namespace)
789}
790
791pub fn get_aggregate_status(conn: &Connection, agent_id: &str) -> Result<QuotaStatus> {
810 let mut stmt = conn
811 .prepare(
812 "SELECT
813 COALESCE(MAX(max_memories_per_day), 0),
814 COALESCE(MAX(max_storage_bytes), 0),
815 COALESCE(MAX(max_links_per_day), 0),
816 COALESCE(SUM(current_memories_today), 0),
817 COALESCE(SUM(current_storage_bytes), 0),
818 COALESCE(SUM(current_links_today), 0),
819 COALESCE(MIN(day_started_at), ''),
820 COALESCE(MIN(created_at), ''),
821 COALESCE(MAX(updated_at), '')
822 FROM agent_quotas WHERE agent_id = ?1",
823 )
824 .context("failed to prepare aggregate quota query")?;
825 let row: Option<(i64, i64, i64, i64, i64, i64, String, String, String)> = stmt
826 .query_row(params![agent_id], |r| {
827 Ok((
828 r.get(0)?,
829 r.get(1)?,
830 r.get(2)?,
831 r.get(3)?,
832 r.get(4)?,
833 r.get(5)?,
834 r.get(6)?,
835 r.get(7)?,
836 r.get(8)?,
837 ))
838 })
839 .optional()
840 .context("failed to read aggregate quota row")?;
841 drop(stmt);
842 if let Some((mm, ms, ml, cm, cs, cl, day, created, updated)) = row {
843 if !created.is_empty() {
844 return Ok(QuotaStatus {
845 agent_id: agent_id.to_string(),
846 namespace: GLOBAL_NAMESPACE.to_string(),
847 max_memories_per_day: mm,
848 max_storage_bytes: ms,
849 max_links_per_day: ml,
850 current_memories_today: cm,
851 current_storage_bytes: cs,
852 current_links_today: cl,
853 day_started_at: day,
854 created_at: created,
855 updated_at: updated,
856 });
857 }
858 }
859 ensure_row(conn, agent_id, GLOBAL_NAMESPACE)
861}
862
863pub fn list_status(conn: &Connection, namespace_filter: Option<&str>) -> Result<Vec<QuotaStatus>> {
879 let map_row = |r: &rusqlite::Row<'_>| -> rusqlite::Result<QuotaStatus> {
880 Ok(QuotaStatus {
881 agent_id: r.get(0)?,
882 namespace: r.get(1)?,
883 max_memories_per_day: r.get(2)?,
884 max_storage_bytes: r.get(3)?,
885 max_links_per_day: r.get(4)?,
886 current_memories_today: r.get(5)?,
887 current_storage_bytes: r.get(6)?,
888 current_links_today: r.get(7)?,
889 day_started_at: r.get(8)?,
890 created_at: r.get(9)?,
891 updated_at: r.get(10)?,
892 })
893 };
894 let mut out = Vec::new();
895 if let Some(ns) = namespace_filter {
896 let mut stmt = conn
897 .prepare(
898 "SELECT agent_id, namespace,
899 max_memories_per_day, max_storage_bytes, max_links_per_day,
900 current_memories_today, current_storage_bytes, current_links_today,
901 day_started_at, created_at, updated_at
902 FROM agent_quotas
903 WHERE namespace = ?1
904 ORDER BY agent_id ASC, namespace ASC",
905 )
906 .context("failed to prepare per-namespace quota list query")?;
907 let rows = stmt
908 .query_map(params![ns], map_row)
909 .context("failed to query per-namespace quota rows")?;
910 for row in rows {
911 out.push(row.context("failed to materialize quota row")?);
912 }
913 } else {
914 let mut stmt = conn
915 .prepare(
916 "SELECT agent_id, namespace,
917 max_memories_per_day, max_storage_bytes, max_links_per_day,
918 current_memories_today, current_storage_bytes, current_links_today,
919 day_started_at, created_at, updated_at
920 FROM agent_quotas
921 ORDER BY agent_id ASC, namespace ASC",
922 )
923 .context("failed to prepare quota list query")?;
924 let rows = stmt
925 .query_map([], map_row)
926 .context("failed to query quota rows")?;
927 for row in rows {
928 out.push(row.context("failed to materialize quota row")?);
929 }
930 }
931 Ok(out)
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937
938 fn fresh_db() -> Connection {
939 let conn = Connection::open_in_memory().expect("open in-memory");
940 conn.execute_batch(include_str!(
945 "../migrations/sqlite/0022_v07_agent_quotas.sql"
946 ))
947 .expect("apply v28 K8 migration");
948 conn.execute_batch(include_str!(
949 "../migrations/sqlite/0042_v50_per_namespace_quota.sql"
950 ))
951 .expect("apply v50 per-namespace migration");
952 conn
953 }
954
955 #[test]
956 fn check_quota_under_limit_returns_ok() {
957 let conn = fresh_db();
958 assert!(
959 check_quota(
960 &conn,
961 "agent-a",
962 GLOBAL_NAMESPACE,
963 QuotaOp::Memory { bytes: 100 }
964 )
965 .is_ok()
966 );
967 }
968
969 #[test]
970 fn check_quota_at_memory_limit_returns_quota_exceeded() {
971 let conn = fresh_db();
972 check_quota(
974 &conn,
975 "agent-a",
976 GLOBAL_NAMESPACE,
977 QuotaOp::Memory { bytes: 1 },
978 )
979 .unwrap();
980 conn.execute(
981 "UPDATE agent_quotas SET max_memories_per_day = 1
982 WHERE agent_id = ?1 AND namespace = ?2",
983 params!["agent-a", GLOBAL_NAMESPACE],
984 )
985 .unwrap();
986 record_op(
987 &conn,
988 "agent-a",
989 GLOBAL_NAMESPACE,
990 QuotaOp::Memory { bytes: 1 },
991 )
992 .unwrap();
993 let err = check_quota(
994 &conn,
995 "agent-a",
996 GLOBAL_NAMESPACE,
997 QuotaOp::Memory { bytes: 1 },
998 )
999 .unwrap_err();
1000 match err {
1001 QuotaCheckError::Quota(q) => {
1002 assert_eq!(q.limit, QuotaLimit::MemoriesPerDay);
1003 assert_eq!(q.max, 1);
1004 assert_eq!(q.namespace, GLOBAL_NAMESPACE);
1005 }
1006 QuotaCheckError::Sql(e) => panic!("expected QuotaError, got SQL: {e}"),
1007 }
1008 }
1009
1010 #[test]
1011 fn check_quota_storage_bytes_limit_fires() {
1012 let conn = fresh_db();
1013 check_quota(
1014 &conn,
1015 "agent-b",
1016 GLOBAL_NAMESPACE,
1017 QuotaOp::Memory { bytes: 1 },
1018 )
1019 .unwrap();
1020 conn.execute(
1021 "UPDATE agent_quotas SET max_storage_bytes = 100
1022 WHERE agent_id = ?1 AND namespace = ?2",
1023 params!["agent-b", GLOBAL_NAMESPACE],
1024 )
1025 .unwrap();
1026 let err = check_quota(
1027 &conn,
1028 "agent-b",
1029 GLOBAL_NAMESPACE,
1030 QuotaOp::Memory { bytes: 200 },
1031 )
1032 .unwrap_err();
1033 match err {
1034 QuotaCheckError::Quota(q) => assert_eq!(q.limit, QuotaLimit::StorageBytes),
1035 QuotaCheckError::Sql(e) => panic!("expected QuotaError, got SQL: {e}"),
1036 }
1037 }
1038
1039 #[test]
1040 fn check_quota_links_per_day_limit_fires() {
1041 let conn = fresh_db();
1042 check_quota(&conn, "agent-c", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1043 conn.execute(
1044 "UPDATE agent_quotas SET max_links_per_day = 1, current_links_today = 1
1045 WHERE agent_id = ?1 AND namespace = ?2",
1046 params!["agent-c", GLOBAL_NAMESPACE],
1047 )
1048 .unwrap();
1049 let err = check_quota(&conn, "agent-c", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap_err();
1050 match err {
1051 QuotaCheckError::Quota(q) => assert_eq!(q.limit, QuotaLimit::LinksPerDay),
1052 QuotaCheckError::Sql(e) => panic!("expected QuotaError, got SQL: {e}"),
1053 }
1054 }
1055
1056 #[test]
1057 fn record_op_increments_counters() {
1058 let conn = fresh_db();
1059 record_op(
1060 &conn,
1061 "agent-d",
1062 GLOBAL_NAMESPACE,
1063 QuotaOp::Memory { bytes: 42 },
1064 )
1065 .unwrap();
1066 let s = get_status(&conn, "agent-d", GLOBAL_NAMESPACE).unwrap();
1067 assert_eq!(s.current_memories_today, 1);
1068 assert_eq!(s.current_storage_bytes, 42);
1069 record_op(&conn, "agent-d", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1070 let s2 = get_status(&conn, "agent-d", GLOBAL_NAMESPACE).unwrap();
1071 assert_eq!(s2.current_links_today, 1);
1072 }
1073
1074 #[test]
1075 fn reset_daily_zeros_stale_rows_only() {
1076 let conn = fresh_db();
1077 record_op(
1078 &conn,
1079 "agent-e",
1080 GLOBAL_NAMESPACE,
1081 QuotaOp::Memory { bytes: 10 },
1082 )
1083 .unwrap();
1084 record_op(&conn, "agent-f", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1085 conn.execute(
1087 "UPDATE agent_quotas SET day_started_at = '2020-01-01T00:00:00+00:00'
1088 WHERE agent_id = ?1 AND namespace = ?2",
1089 params!["agent-e", GLOBAL_NAMESPACE],
1090 )
1091 .unwrap();
1092 let n = reset_daily(&conn).unwrap();
1093 assert_eq!(n, 1, "exactly one stale row should be reset");
1094 let s_e = get_status(&conn, "agent-e", GLOBAL_NAMESPACE).unwrap();
1095 assert_eq!(s_e.current_memories_today, 0);
1096 let s_f = get_status(&conn, "agent-f", GLOBAL_NAMESPACE).unwrap();
1097 assert_eq!(
1098 s_f.current_links_today, 1,
1099 "fresh row must not be touched by the daily reset"
1100 );
1101 assert_eq!(s_e.current_storage_bytes, 10);
1103 }
1104
1105 #[test]
1106 fn list_status_returns_all_rows_sorted() {
1107 let conn = fresh_db();
1108 record_op(
1109 &conn,
1110 "z-agent",
1111 GLOBAL_NAMESPACE,
1112 QuotaOp::Memory { bytes: 1 },
1113 )
1114 .unwrap();
1115 record_op(
1116 &conn,
1117 "a-agent",
1118 GLOBAL_NAMESPACE,
1119 QuotaOp::Memory { bytes: 1 },
1120 )
1121 .unwrap();
1122 record_op(
1123 &conn,
1124 "m-agent",
1125 GLOBAL_NAMESPACE,
1126 QuotaOp::Memory { bytes: 1 },
1127 )
1128 .unwrap();
1129 let rows = list_status(&conn, None).unwrap();
1130 let ids: Vec<&str> = rows.iter().map(|r| r.agent_id.as_str()).collect();
1131 assert_eq!(ids, vec!["a-agent", "m-agent", "z-agent"]);
1132 }
1133
1134 #[test]
1135 fn get_status_auto_inserts_default_row() {
1136 let conn = fresh_db();
1137 let s = get_status(&conn, "fresh-agent", GLOBAL_NAMESPACE).unwrap();
1138 assert_eq!(s.max_memories_per_day, DEFAULT_MAX_MEMORIES_PER_DAY);
1139 assert_eq!(s.max_storage_bytes, DEFAULT_MAX_STORAGE_BYTES);
1140 assert_eq!(s.max_links_per_day, DEFAULT_MAX_LINKS_PER_DAY);
1141 assert_eq!(s.current_memories_today, 0);
1142 assert_eq!(s.namespace, GLOBAL_NAMESPACE);
1143 }
1144
1145 #[test]
1146 fn quota_limit_as_str_returns_expected_canonical_form() {
1147 assert_eq!(QuotaLimit::MemoriesPerDay.as_str(), "memories_per_day");
1148 assert_eq!(QuotaLimit::StorageBytes.as_str(), "storage_bytes");
1149 assert_eq!(QuotaLimit::LinksPerDay.as_str(), "links_per_day");
1150 }
1151
1152 #[test]
1153 fn quota_error_display_format_contract() {
1154 let err = QuotaError {
1155 agent_id: "alice".to_string(),
1156 namespace: "team/policies".to_string(),
1157 limit: QuotaLimit::StorageBytes,
1158 current: 1024,
1159 max: 2048,
1160 };
1161 let s = format!("{err}");
1162 assert!(s.contains("QUOTA_EXCEEDED"));
1163 assert!(s.contains("alice"));
1164 assert!(s.contains("team/policies"));
1165 assert!(s.contains("storage_bytes"));
1166 assert!(s.contains("current=1024"));
1167 assert!(s.contains("max=2048"));
1168 let _: &dyn std::error::Error = &err;
1169 }
1170
1171 #[test]
1172 fn quota_check_error_display_quota_variant_delegates_to_inner() {
1173 let err = QuotaCheckError::Quota(QuotaError {
1174 agent_id: "bob".to_string(),
1175 namespace: GLOBAL_NAMESPACE.to_string(),
1176 limit: QuotaLimit::MemoriesPerDay,
1177 current: 99,
1178 max: 100,
1179 });
1180 let s = format!("{err}");
1181 assert!(s.contains("QUOTA_EXCEEDED"));
1182 assert!(s.contains("memories_per_day"));
1183 let _: &dyn std::error::Error = &err;
1184 }
1185
1186 #[test]
1187 fn quota_check_error_display_sql_variant_wraps_substrate_error() {
1188 let err = QuotaCheckError::Sql(anyhow::anyhow!("boom"));
1189 let s = format!("{err}");
1190 assert!(s.contains("quota check substrate error"));
1191 assert!(s.contains("boom"));
1192 }
1193
1194 #[test]
1195 fn check_and_record_under_limit_increments_counters() {
1196 let conn = fresh_db();
1197 check_and_record(
1198 &conn,
1199 "agent-cr-a",
1200 GLOBAL_NAMESPACE,
1201 QuotaOp::Memory { bytes: 50 },
1202 )
1203 .unwrap();
1204 let s = get_status(&conn, "agent-cr-a", GLOBAL_NAMESPACE).unwrap();
1205 assert_eq!(s.current_memories_today, 1);
1206 assert_eq!(s.current_storage_bytes, 50);
1207 check_and_record(&conn, "agent-cr-a", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1208 let s2 = get_status(&conn, "agent-cr-a", GLOBAL_NAMESPACE).unwrap();
1209 assert_eq!(s2.current_links_today, 1);
1210 }
1211
1212 #[test]
1213 fn check_and_record_at_memories_limit_returns_quota_error_and_rolls_back() {
1214 let conn = fresh_db();
1215 check_and_record(
1216 &conn,
1217 "agent-cr-b",
1218 GLOBAL_NAMESPACE,
1219 QuotaOp::Memory { bytes: 1 },
1220 )
1221 .unwrap();
1222 conn.execute(
1224 "UPDATE agent_quotas SET max_memories_per_day = 1
1225 WHERE agent_id = ?1 AND namespace = ?2",
1226 params!["agent-cr-b", GLOBAL_NAMESPACE],
1227 )
1228 .unwrap();
1229 let err = check_and_record(
1230 &conn,
1231 "agent-cr-b",
1232 GLOBAL_NAMESPACE,
1233 QuotaOp::Memory { bytes: 1 },
1234 )
1235 .unwrap_err();
1236 match err {
1237 QuotaCheckError::Quota(q) => {
1238 assert_eq!(q.limit, QuotaLimit::MemoriesPerDay);
1239 }
1240 QuotaCheckError::Sql(e) => panic!("expected Quota, got SQL: {e}"),
1241 }
1242 let s = get_status(&conn, "agent-cr-b", GLOBAL_NAMESPACE).unwrap();
1244 assert_eq!(s.current_memories_today, 1);
1245 }
1246
1247 #[test]
1248 fn check_and_record_storage_limit_returns_quota_error() {
1249 let conn = fresh_db();
1250 check_and_record(
1251 &conn,
1252 "agent-cr-c",
1253 GLOBAL_NAMESPACE,
1254 QuotaOp::Memory { bytes: 1 },
1255 )
1256 .unwrap();
1257 conn.execute(
1258 "UPDATE agent_quotas SET max_storage_bytes = 100
1259 WHERE agent_id = ?1 AND namespace = ?2",
1260 params!["agent-cr-c", GLOBAL_NAMESPACE],
1261 )
1262 .unwrap();
1263 let err = check_and_record(
1264 &conn,
1265 "agent-cr-c",
1266 GLOBAL_NAMESPACE,
1267 QuotaOp::Memory { bytes: 1000 },
1268 )
1269 .expect_err("storage cap should fire");
1270 match err {
1271 QuotaCheckError::Quota(q) => assert_eq!(q.limit, QuotaLimit::StorageBytes),
1272 QuotaCheckError::Sql(e) => panic!("expected quota, got SQL: {e}"),
1273 }
1274 }
1275
1276 #[test]
1277 fn check_and_record_links_limit_returns_quota_error() {
1278 let conn = fresh_db();
1279 check_and_record(&conn, "agent-cr-d", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1280 conn.execute(
1281 "UPDATE agent_quotas SET max_links_per_day = 1
1282 WHERE agent_id = ?1 AND namespace = ?2",
1283 params!["agent-cr-d", GLOBAL_NAMESPACE],
1284 )
1285 .unwrap();
1286 let err = check_and_record(&conn, "agent-cr-d", GLOBAL_NAMESPACE, QuotaOp::Link)
1287 .expect_err("links cap should fire");
1288 match err {
1289 QuotaCheckError::Quota(q) => assert_eq!(q.limit, QuotaLimit::LinksPerDay),
1290 QuotaCheckError::Sql(e) => panic!("expected quota, got SQL: {e}"),
1291 }
1292 }
1293
1294 #[test]
1295 fn check_and_record_day_roll_branch_for_memory_zeros_daily_counters() {
1296 let conn = fresh_db();
1297 check_and_record(
1298 &conn,
1299 "agent-cr-e",
1300 GLOBAL_NAMESPACE,
1301 QuotaOp::Memory { bytes: 10 },
1302 )
1303 .unwrap();
1304 conn.execute(
1305 "UPDATE agent_quotas SET day_started_at = '2020-01-01T00:00:00+00:00',
1306 current_memories_today = 999, current_links_today = 7
1307 WHERE agent_id = ?1 AND namespace = ?2",
1308 params!["agent-cr-e", GLOBAL_NAMESPACE],
1309 )
1310 .unwrap();
1311 check_and_record(
1312 &conn,
1313 "agent-cr-e",
1314 GLOBAL_NAMESPACE,
1315 QuotaOp::Memory { bytes: 5 },
1316 )
1317 .unwrap();
1318 let s = get_status(&conn, "agent-cr-e", GLOBAL_NAMESPACE).unwrap();
1319 assert_eq!(s.current_memories_today, 1);
1320 assert_eq!(s.current_links_today, 0);
1321 assert_eq!(s.current_storage_bytes, 15);
1322 }
1323
1324 #[test]
1325 fn check_and_record_day_roll_branch_for_link_resets_daily_counters() {
1326 let conn = fresh_db();
1327 check_and_record(&conn, "agent-cr-f", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1328 conn.execute(
1329 "UPDATE agent_quotas SET day_started_at = '2020-01-01T00:00:00+00:00',
1330 current_memories_today = 50, current_links_today = 8
1331 WHERE agent_id = ?1 AND namespace = ?2",
1332 params!["agent-cr-f", GLOBAL_NAMESPACE],
1333 )
1334 .unwrap();
1335 check_and_record(&conn, "agent-cr-f", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1336 let s = get_status(&conn, "agent-cr-f", GLOBAL_NAMESPACE).unwrap();
1337 assert_eq!(s.current_memories_today, 0);
1338 assert_eq!(s.current_links_today, 1);
1339 }
1340
1341 #[test]
1342 fn refund_op_memory_decrements_counters_saturating_to_zero() {
1343 let conn = fresh_db();
1344 check_and_record(
1345 &conn,
1346 "agent-rf-a",
1347 GLOBAL_NAMESPACE,
1348 QuotaOp::Memory { bytes: 200 },
1349 )
1350 .unwrap();
1351 refund_op(
1352 &conn,
1353 "agent-rf-a",
1354 GLOBAL_NAMESPACE,
1355 QuotaOp::Memory { bytes: 200 },
1356 )
1357 .unwrap();
1358 let s = get_status(&conn, "agent-rf-a", GLOBAL_NAMESPACE).unwrap();
1359 assert_eq!(s.current_memories_today, 0);
1360 assert_eq!(s.current_storage_bytes, 0);
1361 refund_op(
1362 &conn,
1363 "agent-rf-a",
1364 GLOBAL_NAMESPACE,
1365 QuotaOp::Memory { bytes: 200 },
1366 )
1367 .unwrap();
1368 let s2 = get_status(&conn, "agent-rf-a", GLOBAL_NAMESPACE).unwrap();
1369 assert_eq!(s2.current_memories_today, 0);
1370 assert_eq!(s2.current_storage_bytes, 0);
1371 }
1372
1373 #[test]
1374 fn refund_op_link_decrements_counter_saturating_to_zero() {
1375 let conn = fresh_db();
1376 check_and_record(&conn, "agent-rf-b", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1377 refund_op(&conn, "agent-rf-b", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1378 let s = get_status(&conn, "agent-rf-b", GLOBAL_NAMESPACE).unwrap();
1379 assert_eq!(s.current_links_today, 0);
1380 refund_op(&conn, "agent-rf-b", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1381 let s2 = get_status(&conn, "agent-rf-b", GLOBAL_NAMESPACE).unwrap();
1382 assert_eq!(s2.current_links_today, 0);
1383 }
1384
1385 #[test]
1386 fn record_op_day_roll_branch_for_memory() {
1387 let conn = fresh_db();
1388 record_op(
1389 &conn,
1390 "agent-ro-a",
1391 GLOBAL_NAMESPACE,
1392 QuotaOp::Memory { bytes: 100 },
1393 )
1394 .unwrap();
1395 conn.execute(
1396 "UPDATE agent_quotas SET day_started_at = '2020-01-01T00:00:00+00:00',
1397 current_memories_today = 50, current_links_today = 4
1398 WHERE agent_id = ?1 AND namespace = ?2",
1399 params!["agent-ro-a", GLOBAL_NAMESPACE],
1400 )
1401 .unwrap();
1402 record_op(
1403 &conn,
1404 "agent-ro-a",
1405 GLOBAL_NAMESPACE,
1406 QuotaOp::Memory { bytes: 5 },
1407 )
1408 .unwrap();
1409 let s = get_status(&conn, "agent-ro-a", GLOBAL_NAMESPACE).unwrap();
1410 assert_eq!(s.current_memories_today, 1);
1411 assert_eq!(s.current_links_today, 0);
1412 assert_eq!(s.current_storage_bytes, 105);
1413 }
1414
1415 #[test]
1416 fn record_op_day_roll_branch_for_link() {
1417 let conn = fresh_db();
1418 record_op(&conn, "agent-ro-b", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1419 conn.execute(
1420 "UPDATE agent_quotas SET day_started_at = '2020-01-01T00:00:00+00:00',
1421 current_memories_today = 7, current_links_today = 9
1422 WHERE agent_id = ?1 AND namespace = ?2",
1423 params!["agent-ro-b", GLOBAL_NAMESPACE],
1424 )
1425 .unwrap();
1426 record_op(&conn, "agent-ro-b", GLOBAL_NAMESPACE, QuotaOp::Link).unwrap();
1427 let s = get_status(&conn, "agent-ro-b", GLOBAL_NAMESPACE).unwrap();
1428 assert_eq!(s.current_memories_today, 0);
1429 assert_eq!(s.current_links_today, 1);
1430 }
1431
1432 #[test]
1433 fn quota_status_serde_roundtrip_carries_namespace() {
1434 let conn = fresh_db();
1435 let s = get_status(&conn, "ser-agent", "team/policies").unwrap();
1436 let json = serde_json::to_string(&s).unwrap();
1437 let parsed: QuotaStatus = serde_json::from_str(&json).unwrap();
1438 assert_eq!(parsed.agent_id, "ser-agent");
1439 assert_eq!(parsed.namespace, "team/policies");
1440 assert_eq!(parsed.max_memories_per_day, DEFAULT_MAX_MEMORIES_PER_DAY);
1441 }
1442
1443 #[test]
1444 fn check_quota_day_roll_branch_treats_daily_as_zero() {
1445 let conn = fresh_db();
1446 check_quota(
1447 &conn,
1448 "agent-cq-roll",
1449 GLOBAL_NAMESPACE,
1450 QuotaOp::Memory { bytes: 1 },
1451 )
1452 .unwrap();
1453 conn.execute(
1454 "UPDATE agent_quotas SET day_started_at = '2020-01-01T00:00:00+00:00',
1455 current_memories_today = 99999, current_links_today = 99999
1456 WHERE agent_id = ?1 AND namespace = ?2",
1457 params!["agent-cq-roll", GLOBAL_NAMESPACE],
1458 )
1459 .unwrap();
1460 assert!(
1461 check_quota(
1462 &conn,
1463 "agent-cq-roll",
1464 GLOBAL_NAMESPACE,
1465 QuotaOp::Memory { bytes: 1 }
1466 )
1467 .is_ok()
1468 );
1469 assert!(check_quota(&conn, "agent-cq-roll", GLOBAL_NAMESPACE, QuotaOp::Link).is_ok());
1470 }
1471
1472 #[test]
1480 fn per_namespace_isolation_memories() {
1481 let conn = fresh_db();
1482 check_and_record(
1484 &conn,
1485 "agent-ns",
1486 "alice/scratch",
1487 QuotaOp::Memory { bytes: 1 },
1488 )
1489 .unwrap();
1490 check_and_record(
1491 &conn,
1492 "agent-ns",
1493 "team/policies",
1494 QuotaOp::Memory { bytes: 1 },
1495 )
1496 .unwrap();
1497 conn.execute(
1499 "UPDATE agent_quotas SET max_memories_per_day = 1
1500 WHERE agent_id = ?1 AND namespace = ?2",
1501 params!["agent-ns", "alice/scratch"],
1502 )
1503 .unwrap();
1504 let err = check_and_record(
1506 &conn,
1507 "agent-ns",
1508 "alice/scratch",
1509 QuotaOp::Memory { bytes: 1 },
1510 )
1511 .unwrap_err();
1512 match err {
1513 QuotaCheckError::Quota(q) => {
1514 assert_eq!(q.namespace, "alice/scratch");
1515 assert_eq!(q.limit, QuotaLimit::MemoriesPerDay);
1516 }
1517 QuotaCheckError::Sql(e) => panic!("expected Quota, got SQL: {e}"),
1518 }
1519 check_and_record(
1521 &conn,
1522 "agent-ns",
1523 "team/policies",
1524 QuotaOp::Memory { bytes: 1 },
1525 )
1526 .unwrap();
1527 }
1528
1529 #[test]
1532 fn per_namespace_isolation_storage_bytes() {
1533 let conn = fresh_db();
1534 check_and_record(
1535 &conn,
1536 "agent-ns2",
1537 "alice/scratch",
1538 QuotaOp::Memory { bytes: 50 },
1539 )
1540 .unwrap();
1541 conn.execute(
1543 "UPDATE agent_quotas SET max_storage_bytes = 100
1544 WHERE agent_id = ?1 AND namespace = ?2",
1545 params!["agent-ns2", "alice/scratch"],
1546 )
1547 .unwrap();
1548 let err = check_and_record(
1550 &conn,
1551 "agent-ns2",
1552 "alice/scratch",
1553 QuotaOp::Memory { bytes: 60 },
1554 )
1555 .unwrap_err();
1556 assert!(matches!(err, QuotaCheckError::Quota(q) if q.limit == QuotaLimit::StorageBytes));
1557 check_and_record(
1560 &conn,
1561 "agent-ns2",
1562 "shared/team-a",
1563 QuotaOp::Memory { bytes: 60 },
1564 )
1565 .unwrap();
1566 }
1567
1568 #[test]
1570 fn per_namespace_isolation_links() {
1571 let conn = fresh_db();
1572 check_and_record(&conn, "agent-ns3", "alice/scratch", QuotaOp::Link).unwrap();
1573 conn.execute(
1574 "UPDATE agent_quotas SET max_links_per_day = 1
1575 WHERE agent_id = ?1 AND namespace = ?2",
1576 params!["agent-ns3", "alice/scratch"],
1577 )
1578 .unwrap();
1579 let err = check_and_record(&conn, "agent-ns3", "alice/scratch", QuotaOp::Link)
1580 .expect_err("links cap on alice/scratch should fire");
1581 assert!(matches!(err, QuotaCheckError::Quota(q) if q.limit == QuotaLimit::LinksPerDay));
1582 check_and_record(&conn, "agent-ns3", "team/policies", QuotaOp::Link).unwrap();
1584 }
1585
1586 #[test]
1589 fn aggregate_status_sums_across_namespaces() {
1590 let conn = fresh_db();
1591 record_op(
1592 &conn,
1593 "agent-agg",
1594 "alice/scratch",
1595 QuotaOp::Memory { bytes: 100 },
1596 )
1597 .unwrap();
1598 record_op(
1599 &conn,
1600 "agent-agg",
1601 "team/policies",
1602 QuotaOp::Memory { bytes: 200 },
1603 )
1604 .unwrap();
1605 record_op(&conn, "agent-agg", "alice/scratch", QuotaOp::Link).unwrap();
1606 record_op(&conn, "agent-agg", "team/policies", QuotaOp::Link).unwrap();
1607 record_op(&conn, "agent-agg", "team/policies", QuotaOp::Link).unwrap();
1608
1609 let agg = get_aggregate_status(&conn, "agent-agg").unwrap();
1610 assert_eq!(agg.agent_id, "agent-agg");
1611 assert_eq!(agg.namespace, GLOBAL_NAMESPACE);
1612 assert_eq!(agg.current_memories_today, 2);
1614 assert_eq!(agg.current_storage_bytes, 300);
1616 assert_eq!(agg.current_links_today, 3);
1618 }
1619
1620 #[test]
1623 fn list_status_returns_per_namespace_rows_sorted() {
1624 let conn = fresh_db();
1625 record_op(&conn, "agent-ls", "z-ns", QuotaOp::Memory { bytes: 1 }).unwrap();
1626 record_op(&conn, "agent-ls", "a-ns", QuotaOp::Memory { bytes: 1 }).unwrap();
1627 let rows = list_status(&conn, None).unwrap();
1628 let agent_ls_rows: Vec<&QuotaStatus> =
1630 rows.iter().filter(|r| r.agent_id == "agent-ls").collect();
1631 assert_eq!(agent_ls_rows.len(), 2);
1632 assert_eq!(agent_ls_rows[0].namespace, "a-ns");
1634 assert_eq!(agent_ls_rows[1].namespace, "z-ns");
1635 }
1636
1637 #[test]
1639 fn list_status_namespace_filter() {
1640 let conn = fresh_db();
1641 record_op(
1642 &conn,
1643 "agent-lf",
1644 "team/policies",
1645 QuotaOp::Memory { bytes: 1 },
1646 )
1647 .unwrap();
1648 record_op(
1649 &conn,
1650 "agent-lf",
1651 "alice/scratch",
1652 QuotaOp::Memory { bytes: 1 },
1653 )
1654 .unwrap();
1655 record_op(
1656 &conn,
1657 "other-agent",
1658 "team/policies",
1659 QuotaOp::Memory { bytes: 1 },
1660 )
1661 .unwrap();
1662 let rows = list_status(&conn, Some("team/policies")).unwrap();
1663 for r in &rows {
1664 assert_eq!(r.namespace, "team/policies");
1665 }
1666 let agent_ids: std::collections::HashSet<&str> =
1668 rows.iter().map(|r| r.agent_id.as_str()).collect();
1669 assert!(agent_ids.contains("agent-lf"));
1670 assert!(agent_ids.contains("other-agent"));
1671 }
1672
1673 #[test]
1677 fn global_sentinel_is_backwards_compat_landing_zone() {
1678 let conn = fresh_db();
1679 record_op(
1680 &conn,
1681 "agent-bc",
1682 GLOBAL_NAMESPACE,
1683 QuotaOp::Memory { bytes: 42 },
1684 )
1685 .unwrap();
1686 let s = get_status(&conn, "agent-bc", GLOBAL_NAMESPACE).unwrap();
1687 assert_eq!(s.namespace, GLOBAL_NAMESPACE);
1688 assert_eq!(s.current_memories_today, 1);
1689 assert_eq!(s.current_storage_bytes, 42);
1690 }
1691
1692 #[test]
1706 fn issue_1256_i64_max_input_does_not_wrap_under_saturating_add() {
1707 let conn = fresh_db();
1708 check_quota(
1713 &conn,
1714 "agent-1256",
1715 GLOBAL_NAMESPACE,
1716 QuotaOp::Memory { bytes: 1 },
1717 )
1718 .expect("seed call must pass");
1719 record_op(
1720 &conn,
1721 "agent-1256",
1722 GLOBAL_NAMESPACE,
1723 QuotaOp::Memory { bytes: 1 },
1724 )
1725 .unwrap();
1726
1727 let err = check_quota(
1732 &conn,
1733 "agent-1256",
1734 GLOBAL_NAMESPACE,
1735 QuotaOp::Memory { bytes: i64::MAX },
1736 )
1737 .expect_err("i64::MAX bytes input MUST be refused (post-#1256 saturating_add)");
1738 match err {
1739 QuotaCheckError::Quota(q) => {
1740 assert_eq!(
1741 q.limit,
1742 QuotaLimit::StorageBytes,
1743 "#1256: the storage-bytes cap must fire on the saturated sum, \
1744 not any other limit"
1745 );
1746 assert_eq!(q.agent_id, "agent-1256");
1747 }
1748 QuotaCheckError::Sql(e) => {
1749 panic!("#1256: expected QuotaError::StorageBytes, got SQL: {e}")
1750 }
1751 }
1752
1753 let err = check_and_record(
1756 &conn,
1757 "agent-1256-atomic",
1758 GLOBAL_NAMESPACE,
1759 QuotaOp::Memory { bytes: i64::MAX },
1760 )
1761 .expect_err("check_and_record must also refuse i64::MAX bytes (post-#1256)");
1762 match err {
1763 QuotaCheckError::Quota(q) => {
1764 assert_eq!(q.limit, QuotaLimit::StorageBytes);
1765 }
1766 QuotaCheckError::Sql(e) => panic!("#1256: expected QuotaError, got SQL: {e}"),
1767 }
1768 }
1769}