Skip to main content

ai_memory/
quotas.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 Track K, Task K8 — per-agent + per-namespace rate limits +
5//! storage caps.
6//!
7//! Each `(agent_id, namespace)` tuple gets a single row in the
8//! `agent_quotas` table tracking three rolling-window counters
9//! (memories written today, storage bytes consumed lifetime, links
10//! written today) against three limits (`max_memories_per_day`,
11//! `max_storage_bytes`, `max_links_per_day`). The `store_memory` +
12//! `memory_link` write paths consult [`check_and_record`] before
13//! committing; on exceeded limit the call returns a [`QuotaError`]
14//! naming the limit that was hit, which the MCP layer maps to a
15//! `QUOTA_EXCEEDED` diagnostic.
16//!
17//! Enforcement scope (#1621): quotas gate the daemon-facing write
18//! surfaces — MCP `memory_store` / `memory_link` and HTTP
19//! `POST /api/v1/memories` / `POST /api/v1/links`. CLI one-shot
20//! writes are operator-as-actor and deliberately uncharged (the same
21//! exemption principle as the L1-6 governance pre-write hook, which
22//! the CLI binaries do not install); CLI writes likewise fire no
23//! webhook dispatch. K8 limits target NHI agents reaching the
24//! substrate through a daemon, not the operator at the shell.
25//!
26//! Daily counters reset at UTC midnight via [`reset_daily`], driven by
27//! the K8 sweep loop wired into `daemon_runtime::bootstrap_serve` —
28//! same lifecycle shape as the K2 pending-actions sweeper and the I3
29//! transcript-lifecycle sweeper.
30//!
31//! ## Per-namespace dimension (v0.7.0 #1156, schema v50)
32//!
33//! Pre-v50 the substrate keyed quota accounting on `agent_id` alone:
34//! an agent that wrote generously in a personal scratch namespace
35//! starved their writes against a shared namespace because the same
36//! daily cap applied to both. v50 extends the PK to
37//! `(agent_id, namespace)` so per-namespace allotments hold even when
38//! a single agent operates across many namespaces. Operators carving
39//! tight blast-radius limits on a single shared namespace no longer
40//! need to lower the agent's overall cap.
41//!
42//! The sentinel namespace string `_global` (underscore prefix puts it
43//! outside the validated namespace charset, so no caller-supplied
44//! namespace can collide) carries forward every pre-v50 row's
45//! accounting verbatim. Callers that do not have a per-namespace
46//! context to pass (boundary layers, the daily-reset sweep, the
47//! legacy aggregate view) use `_global` to land on the
48//! historically-shaped row.
49//!
50//! ## NSA CSI MCP mapping
51//!
52//! This module backs **NSA recommendation (c)** "Implement strict
53//! input validation and authorization checks" and **NSA concern (h)**
54//! "Denial of service" by giving operators per-namespace blast-radius
55//! controls on a compromised or misbehaving agent. Defense-in-depth
56//! on top of the seven-layer DoS substrate documented in
57//! `docs/compliance/nsa-csi-mcp-security-mapping.md`.
58//!
59//! Compiled defaults: 1000 memories/day, 100 MiB storage cap, 5000
60//! links/day. Defaults are deliberately generous so the K8 substrate is
61//! invisible to small-scale operations; tuning down is per-deployment.
62
63use anyhow::{Context, Result};
64use rusqlite::{Connection, OptionalExtension, params};
65use serde::{Deserialize, Serialize};
66
67/// Sentinel namespace string used by the v50 backwards-compat path
68/// and by every call site that lacks a per-namespace context.
69///
70/// The leading underscore visibly separates the sentinel from
71/// user-supplied namespaces by convention; while
72/// [`crate::validate::validate_namespace`] does not strictly reject
73/// `_`-prefixed identifiers, the substrate convention is that
74/// operators don't use them for user data.
75///
76/// Pre-v50 rows backfill to this namespace during the v50 schema
77/// migration; the MCP tool / HTTP route boundary defaults to this
78/// string when the caller omits the optional `namespace` argument.
79pub const GLOBAL_NAMESPACE: &str = "_global";
80
81/// Default daily memory store ceiling per (agent, namespace). Generous;
82/// tune down per-deployment by overwriting the row's
83/// `max_memories_per_day` after it auto-inserts on first use.
84pub const DEFAULT_MAX_MEMORIES_PER_DAY: i64 = 1000;
85
86/// Default lifetime storage cap per (agent, namespace) (100 MiB).
87/// Counts the (title + content + metadata) byte length of every memory
88/// the agent writes; not reset by the daily sweep.
89pub const DEFAULT_MAX_STORAGE_BYTES: i64 = 100 * 1024 * 1024;
90
91/// Default daily link creation ceiling per (agent, namespace). Same
92/// shape as the memory ceiling; reset to 0 at UTC midnight.
93pub const DEFAULT_MAX_LINKS_PER_DAY: i64 = 5000;
94
95/// Operator-resolved quota defaults stamped at quota-row auto-insert.
96///
97/// The three quota ceilings live per-row in `agent_quotas`, but a row
98/// only materialises on first use — at which point [`ensure_row`] stamps
99/// these values. Pre-this-change the stamp used the compiled
100/// `DEFAULT_MAX_*` constants directly, so the only way to raise a cap was
101/// an out-of-band `UPDATE`. Now the daemon installs operator-resolved
102/// values (from `[limits]` / `AI_MEMORY_MAX_*`) at boot via
103/// [`set_quota_defaults`], and every fresh row inherits the operator's
104/// configured ceiling.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct QuotaDefaults {
107    /// Per-(agent, namespace) daily memory-write ceiling.
108    pub max_memories_per_day: i64,
109    /// Per-(agent, namespace) lifetime storage cap in bytes.
110    pub max_storage_bytes: i64,
111    /// Per-(agent, namespace) daily link-creation ceiling.
112    pub max_links_per_day: i64,
113}
114
115impl Default for QuotaDefaults {
116    /// The compiled fallback — used in CLI / unit-test contexts where
117    /// the daemon never installed operator overrides.
118    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
129/// Install the operator-resolved quota defaults. Idempotent — the first
130/// successful set wins (subsequent calls are ignored), matching the
131/// once-at-boot lifecycle. Called from `daemon_runtime::run` so serve,
132/// MCP, and CLI write paths all stamp the same operator-configured
133/// ceilings.
134pub fn set_quota_defaults(defaults: QuotaDefaults) {
135    let _ = QUOTA_DEFAULTS.set(defaults);
136}
137
138/// Resolved quota defaults for the auto-insert path. Falls back to
139/// [`QuotaDefaults::default`] (the compiled `DEFAULT_MAX_*` constants)
140/// when the daemon never installed operator overrides.
141#[must_use]
142pub fn quota_defaults() -> QuotaDefaults {
143    QUOTA_DEFAULTS.get().copied().unwrap_or_default()
144}
145
146/// Which write operation to charge against the agent's quota.
147///
148/// Variants:
149/// - [`QuotaOp::Memory`] — one memory store. Charges 1 against
150///   `current_memories_today` and `bytes` against `current_storage_bytes`.
151/// - [`QuotaOp::Link`] — one link create. Charges 1 against
152///   `current_links_today`. Storage is unaffected (links are a single
153///   row keyed on a 3-tuple, not user-supplied bytes).
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum QuotaOp {
156    /// Storing one memory of `bytes` payload size. The byte count is
157    /// the sum of (title + content + metadata) lengths — same shape the
158    /// `current_storage_bytes` counter accumulates.
159    Memory { bytes: i64 },
160    /// Creating one link. Single-row insert; no storage delta.
161    Link,
162}
163
164/// Which limit was hit. The MCP error string surfaces this name so a
165/// caller can switch on it without parsing the human-readable message.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum QuotaLimit {
169    /// `current_memories_today >= max_memories_per_day` after the
170    /// pending op would post.
171    MemoriesPerDay,
172    /// `current_storage_bytes + op.bytes > max_storage_bytes`.
173    StorageBytes,
174    /// `current_links_today >= max_links_per_day` after the pending op
175    /// would post.
176    LinksPerDay,
177}
178
179impl QuotaLimit {
180    /// Canonical lower-snake-case name for diagnostic strings + the
181    /// MCP wire format.
182    #[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/// Failure returned by [`check_quota`] when a write would push the
193/// agent's counters past one of the three limits.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct QuotaError {
196    /// Agent whose quota was exceeded.
197    pub agent_id: String,
198    /// Namespace whose quota was exceeded (v50; #1156). Pre-v50
199    /// surfaces always reported `_global` here for byte-for-byte
200    /// compatibility with the legacy single-PK accounting.
201    pub namespace: String,
202    /// Which limit was hit.
203    pub limit: QuotaLimit,
204    /// The current value of the counter the limit applies to.
205    pub current: i64,
206    /// The configured ceiling.
207    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/// Snapshot of one `(agent_id, namespace)` quota row, returned by
227/// [`get_status`] and surfaced over the MCP `memory_quota_status`
228/// tool.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct QuotaStatus {
231    pub agent_id: String,
232    /// Per-namespace dimension (v50, #1156). `_global` for callers
233    /// that did not pass a namespace; otherwise the caller-supplied
234    /// namespace string. The aggregate-view path
235    /// ([`get_aggregate_status`]) reports `_global` here too because
236    /// the rollup is keyed by `agent_id` alone.
237    #[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
254/// Auto-insert a default quota row for an `(agent_id, namespace)`
255/// tuple that doesn't have one yet, then return the row. Idempotent —
256/// concurrent calls converge on a single row because
257/// `(agent_id, namespace)` is the PRIMARY KEY.
258fn 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
287/// Load a quota row by `(agent_id, namespace)`, returning `None` if
288/// the row does not exist. Pure read — does not insert defaults.
289fn 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
318/// Return the YYYY-MM-DD bucket for an RFC3339 UTC timestamp. Used to
319/// compare `day_started_at` against "today" without crossing into a
320/// chrono date type — the SQL column is RFC3339 string-typed.
321fn day_bucket(rfc3339: &str) -> String {
322    rfc3339.get(..10).unwrap_or(rfc3339).to_string()
323}
324
325/// v0.7 K8 — pre-write quota check.
326///
327/// Auto-inserts the default row on first call for an `(agent_id,
328/// namespace)` tuple. If the row's `day_started_at` rolled over since
329/// the last write, the counters are zeroed inline (the sweeper is the
330/// bulk path; this path keeps the per-write quota honest even if the
331/// sweeper hasn't fired yet).
332///
333/// On a clean check, returns `Ok(())`. On a quota breach, returns
334/// `Err(QuotaError)` naming the limit that was hit and the
335/// counter/ceiling values at the moment of the check.
336///
337/// ## v0.7.0 #1156 — per-namespace dimension
338///
339/// `namespace` keys the per-namespace accounting row. Callers that
340/// lack a per-namespace context (boundary layers, daily reset)
341/// pass [`GLOBAL_NAMESPACE`].
342///
343/// # Errors
344///
345/// - [`QuotaError`] when one of the three limits would be exceeded by
346///   the pending op.
347/// - Wrapped SQL errors when the substrate read fails.
348pub 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    // Inline daily-bucket roll: if the stored bucket isn't today, treat
357    // the daily counters as 0 for this check. The sweeper performs the
358    // matching SQL UPDATE so a downstream `get_status` reports zeros
359    // even if no further writes happen until midnight.
360    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            // #1256 (LOW, 2026-05-25) — `saturating_add` keeps the cap
371            // check honest even when a synthetic `i64::MAX` bytes (or
372            // any other adversarial input that crossed the
373            // saturating-cast boundary upstream) would otherwise wrap
374            // the unchecked `+` to a negative and bypass the
375            // `> max_storage_bytes` comparison. The clamp at
376            // `i64::MAX` is fine for the comparison because the
377            // ceiling fields themselves are bounded by
378            // `DEFAULT_MAX_STORAGE_BYTES` (100 MB) — `i64::MAX` is
379            // unambiguously over cap.
380            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            // #1256 — same saturating-add safety net for the daily
401            // links counter.
402            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/// Wire-shape error for [`check_quota`] — separates the "the agent
418/// hit the limit" case from "the substrate read failed" so callers can
419/// surface the former as a `QUOTA_EXCEEDED` diagnostic and the latter
420/// as a 500-class internal error.
421#[derive(Debug)]
422pub enum QuotaCheckError {
423    /// The pending op would exceed one of the three limits.
424    Quota(QuotaError),
425    /// The substrate read failed (DB error, missing migration, etc.).
426    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
440/// #1558 batch 5 wave 2 — canonical `"update failed: {e}"` →
441/// [`QuotaCheckError::Sql`] mapping for the four `agent_quotas`
442/// UPDATE statements in [`check_and_record`]. Byte-identical detail.
443fn quota_update_failed(e: impl std::fmt::Display) -> QuotaCheckError {
444    QuotaCheckError::Sql(anyhow::anyhow!("update failed: {e}"))
445}
446
447/// #1558 batch 5 wave 2 — canonical refund-failure WARN line shared by
448/// the three quota-refund rollback sites (HTTP create, MCP store, MCP
449/// link). Message bytes identical to the prior inline `tracing::warn!`.
450pub(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
454/// v0.7 K8 / H12 (#628 blocker) — atomic check + record. Combines the
455/// quota check with the counter increment under a single
456/// `BEGIN IMMEDIATE` SQLite transaction so concurrent writers cannot
457/// each pass the check and then both increment the counter past the
458/// cap. `BEGIN IMMEDIATE` acquires a `RESERVED` lock on the database
459/// at the start of the transaction; SQLite serialises every other
460/// would-be writer behind the lock until COMMIT/ROLLBACK, which is
461/// the SQLite analogue of `SELECT ... FOR UPDATE` against the
462/// `(agent_id, namespace)` `agent_quotas` row.
463///
464/// On a clean check + increment, returns `Ok(())`. On a quota breach,
465/// returns `Err(QuotaError)` naming the limit that was hit and the
466/// counter / ceiling values at the moment of the check; the
467/// transaction is rolled back so no counter mutation persists.
468///
469/// ## v0.7.0 #1156 — per-namespace dimension
470///
471/// `namespace` keys the per-namespace accounting row. The K8 write
472/// paths (`store_memory`, `memory_link`) supply the target memory's
473/// namespace so per-namespace allotments hold even when one agent
474/// writes across many namespaces.
475///
476/// # Errors
477///
478/// - [`QuotaCheckError::Quota`] when one of the three limits would be
479///   exceeded by the pending op.
480/// - [`QuotaCheckError::Sql`] when the substrate read or write fails.
481pub fn check_and_record(
482    conn: &Connection,
483    agent_id: &str,
484    namespace: &str,
485    op: QuotaOp,
486) -> std::result::Result<(), QuotaCheckError> {
487    // Make sure the row exists OUTSIDE the immediate transaction;
488    // `INSERT OR IGNORE` itself is atomic and contention-free.
489    let _ = ensure_row(conn, agent_id, namespace).map_err(QuotaCheckError::Sql)?;
490
491    // BEGIN IMMEDIATE — acquires a RESERVED lock immediately. This is
492    // the SQLite shape of "SELECT ... FOR UPDATE": no other connection
493    // can begin a write transaction until we COMMIT or ROLLBACK. The
494    // window between SELECT and UPDATE inside the transaction is
495    // therefore safe from another writer's UPDATE racing past us.
496    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        // Inline daily-bucket roll: if the stored bucket isn't today,
509        // the daily counters are treated as zero for the check AND
510        // the UPDATE below resets them.
511        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                // #1256 (LOW, 2026-05-25) — saturating-add safety net
524                // on the in-transaction cap check too, matching the
525                // pre-transaction `check_quota` arm. See the comment
526                // there for the full threat-model rationale.
527                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                // #1256 (LOW, 2026-05-25) — saturating-add safety net
571                // on the daily-link counter check.
572                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            // Rollback is best-effort — even if it fails, the
615            // transaction is implicitly aborted on connection drop.
616            let _ = conn.execute_batch(crate::storage::connection::SQL_ROLLBACK);
617            Err(e)
618        }
619    }
620}
621
622/// v0.7 K8 / H12 — refund a previously-recorded op. Used by callers
623/// that have already incremented the counters via
624/// [`check_and_record`] but whose downstream insert failed AFTER the
625/// quota commit. Decrements the same counters [`check_and_record`]
626/// incremented; storage bytes is decremented for `QuotaOp::Memory`.
627///
628/// Counters never go below zero (saturating) so a buggy double-refund
629/// cannot poison the substrate.
630///
631/// ## v0.7.0 #1156 — per-namespace dimension
632///
633/// Pass the same `(agent_id, namespace)` pair the matching
634/// [`check_and_record`] call used so the refund lands on the same
635/// accounting row.
636///
637/// # Errors
638///
639/// Wrapped SQL errors on update failure.
640pub 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
666/// v0.7 K8 — record a successful write against the
667/// `(agent_id, namespace)` quota counters. Called AFTER the underlying
668/// insert succeeds so a failed store does not consume quota.
669///
670/// **DEPRECATED for new code paths**: prefer [`check_and_record`]
671/// which combines the check + record into a single atomic transaction
672/// (closes H12 TOCTOU). `record_op` remains for callers (and tests)
673/// that bypass the check phase entirely.
674///
675/// If the stored `day_started_at` rolled over since the row was last
676/// touched, the daily counters are reset before the new op posts —
677/// matching the inline-roll semantics in [`check_quota`] so the two
678/// stay coherent without an intervening sweep.
679///
680/// # Errors
681///
682/// Wrapped SQL errors on update failure.
683pub fn record_op(conn: &Connection, agent_id: &str, namespace: &str, op: QuotaOp) -> Result<()> {
684    // ensure_row is idempotent so callers that skip check_quota (none
685    // today, but defensive) still produce a coherent counter.
686    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
741/// v0.7 K8 — daily counter reset. Zeros `current_memories_today` +
742/// `current_links_today` for every `(agent_id, namespace)` row whose
743/// `day_started_at` is not the current UTC date. Driven by the K8
744/// sweep loop on a 60-second cadence; the inline-roll branch in
745/// [`check_quota`] / [`record_op`] is the per-write fallback so the
746/// substrate stays honest even if the sweeper is delayed.
747///
748/// Operates across every namespace in one statement — no per-namespace
749/// loop is needed because the WHERE clause hits every stale row by
750/// definition.
751///
752/// Returns the number of rows that were reset (0 when no agent has
753/// crossed midnight since the previous sweep).
754///
755/// # Errors
756///
757/// Wrapped SQL errors on update failure.
758pub 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
773/// v0.7 K8 — read the current quota row for an
774/// `(agent_id, namespace)` tuple, auto-inserting a default row if
775/// none exists. Backs the namespace-scoped form of the
776/// `memory_quota_status` MCP tool.
777///
778/// ## v0.7.0 #1156 — per-namespace dimension
779///
780/// Callers that lack a per-namespace context (boundary layer with no
781/// `namespace` arg supplied, legacy tests) pass [`GLOBAL_NAMESPACE`]
782/// to land on the historically-shaped row.
783///
784/// # Errors
785///
786/// Wrapped SQL errors on read failure.
787pub fn get_status(conn: &Connection, agent_id: &str, namespace: &str) -> Result<QuotaStatus> {
788    ensure_row(conn, agent_id, namespace)
789}
790
791/// v0.7 K8 / #1156 — read the aggregate quota row for an agent,
792/// summing every per-namespace row. Returns a synthesised
793/// [`QuotaStatus`] with `namespace = "_global"` and the *summed*
794/// daily counters + summed lifetime storage bytes; the ceiling
795/// columns report the maximum observed across every namespace row
796/// (so the surfaced numbers don't lie about the per-namespace caps).
797///
798/// When the agent has no rows at all, falls back to
799/// [`ensure_row`] against [`GLOBAL_NAMESPACE`] — the same shape
800/// pre-v50 callers expected (auto-inserts a default `_global` row).
801///
802/// Backs the namespace-omitted form of `memory_quota_status` so the
803/// pre-#1156 tool shape continues to make sense even after the
804/// per-namespace dimension lands.
805///
806/// # Errors
807///
808/// Wrapped SQL errors on read failure.
809pub 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    // No rows at all: fall back to ensure_row at the global sentinel.
860    ensure_row(conn, agent_id, GLOBAL_NAMESPACE)
861}
862
863/// v0.7 K8 — read every quota row in the substrate. Backs the
864/// `memory_quota_status` MCP tool when the operator omits the
865/// `agent_id` parameter (operator-facing surface).
866///
867/// ## v0.7.0 #1156 — per-namespace dimension
868///
869/// When `namespace_filter` is `Some(ns)`, only rows in that namespace
870/// are returned (drives the `?namespace=` HTTP query param + the
871/// MCP tool's optional `namespace` arg). When `None`, every row in
872/// the substrate is returned, ordered by `(agent_id ASC, namespace ASC)`
873/// for stable output across calls.
874///
875/// # Errors
876///
877/// Wrapped SQL errors on read failure.
878pub 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        // Apply the K8 substrate via the production migration ladder:
941        // v28 creates the legacy single-PK table; v50 migrates it to
942        // the compound `(agent_id, namespace)` PK shape #1156 ships.
943        // Hand-apply both so unit tests see the v50 shape.
944        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        // First call inserts the default row.
973        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        // Roll agent-e's day_started_at back to yesterday.
1086        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        // Storage is lifetime, never reset.
1102        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        // Tighten the cap so the next write would exceed.
1223        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        // Counter NOT incremented (rollback).
1243        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    // ─────────────────────────────────────────────────────────────────
1473    // v0.7.0 #1156 — per-namespace dimension regression tests
1474    // ─────────────────────────────────────────────────────────────────
1475
1476    /// #1156 — quota counters MUST stay isolated across namespaces.
1477    /// An agent that hits their memories/day cap in namespace A still
1478    /// has full headroom in namespace B.
1479    #[test]
1480    fn per_namespace_isolation_memories() {
1481        let conn = fresh_db();
1482        // Auto-insert default rows in two namespaces.
1483        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        // Tighten ONLY the alice/scratch row.
1498        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        // Second write to alice/scratch trips the cap.
1505        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        // But team/policies still has headroom: writes succeed.
1520        check_and_record(
1521            &conn,
1522            "agent-ns",
1523            "team/policies",
1524            QuotaOp::Memory { bytes: 1 },
1525        )
1526        .unwrap();
1527    }
1528
1529    /// #1156 — storage cap is per-namespace too. Bytes recorded in
1530    /// namespace A do not consume the cap of namespace B.
1531    #[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        // Tighten alice/scratch storage cap to 100 bytes.
1542        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        // A 60-byte write in alice/scratch trips storage cap.
1549        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        // Same 60-byte write in shared/team-a goes through (independent
1558        // accounting row, 100 MiB default cap).
1559        check_and_record(
1560            &conn,
1561            "agent-ns2",
1562            "shared/team-a",
1563            QuotaOp::Memory { bytes: 60 },
1564        )
1565        .unwrap();
1566    }
1567
1568    /// #1156 — per-namespace links/day isolation.
1569    #[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        // Different namespace still has headroom.
1583        check_and_record(&conn, "agent-ns3", "team/policies", QuotaOp::Link).unwrap();
1584    }
1585
1586    /// #1156 — `get_aggregate_status` sums counters across every
1587    /// namespace row for an agent.
1588    #[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        // Two memory ops in two namespaces.
1613        assert_eq!(agg.current_memories_today, 2);
1614        // 100 + 200 bytes.
1615        assert_eq!(agg.current_storage_bytes, 300);
1616        // 1 + 2 links.
1617        assert_eq!(agg.current_links_today, 3);
1618    }
1619
1620    /// #1156 — `list_status(None)` returns every row across every
1621    /// namespace, sorted by (agent_id ASC, namespace ASC).
1622    #[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        // Should have 2 rows, both for agent-ls.
1629        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        // Namespaces are sorted ascending: a-ns before z-ns.
1633        assert_eq!(agent_ls_rows[0].namespace, "a-ns");
1634        assert_eq!(agent_ls_rows[1].namespace, "z-ns");
1635    }
1636
1637    /// #1156 — `list_status(Some(ns))` filters down to one namespace.
1638    #[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        // Two agents wrote in team/policies — both must appear.
1667        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    /// #1156 — sentinel namespace `_global` is the backwards-compat
1674    /// landing zone. Pre-v50 callers (who pass `_global`) see the
1675    /// historically-shaped accounting row.
1676    #[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    /// #1256 (LOW, 2026-05-25) — regression: a synthetic `i64::MAX`
1693    /// storage-bytes input must NOT wrap to a negative under
1694    /// unchecked `+` and bypass the `> max_storage_bytes` cap check.
1695    /// Pre-#1256 `row.current_storage_bytes + bytes` was a plain `+`;
1696    /// with `current_storage_bytes = 1` and `bytes = i64::MAX`,
1697    /// the sum overflows to `i64::MIN` (a large negative number),
1698    /// which is `< max_storage_bytes` (any positive integer), so the
1699    /// cap check incorrectly passed and the quota system silently
1700    /// accepted the over-cap write.
1701    ///
1702    /// With `saturating_add` the sum clamps at `i64::MAX`, which is
1703    /// unambiguously `>` the bounded `max_storage_bytes` ceiling
1704    /// (defaults to 100 MB) so the cap check refuses the write.
1705    #[test]
1706    fn issue_1256_i64_max_input_does_not_wrap_under_saturating_add() {
1707        let conn = fresh_db();
1708        // Bootstrap: store a small memory under one agent so the row
1709        // exists with sane defaults. The default
1710        // `max_storage_bytes` = 100 MB is fine — `i64::MAX` is way
1711        // above that.
1712        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        // Adversarial input: `bytes = i64::MAX`. Pre-#1256 the
1728        // unchecked `+` overflows the i64 cap to a negative,
1729        // bypassing the comparison. Post-#1256 `saturating_add`
1730        // clamps at i64::MAX > max_storage_bytes so the check refuses.
1731        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        // Repeat for the atomic `check_and_record` arm — same i64::MAX
1754        // input, same saturating-add safety net.
1755        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}