{"record":{"id":"7f4b3287280547f8","repo":"zeroclaw-labs/zeroclaw","slug":"agentscopedmemory-refuses-purge-namespace-cross-a","errorCode":null,"errorMessage":"AgentScopedMemory refuses purge_namespace: cross-agent bulk delete must run through an admin Memory handle","messagePattern":"AgentScopedMemory refuses purge_namespace: cross-agent bulk delete must run through an admin Memory handle","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-memory/src/agent_scoped.rs","lineNumber":375,"sourceCode":"            })\n            .count())\n    }\n\n    async fn purge_namespace(&self, namespace: &str) -> Result<usize> {\n        // Bulk cross-agent destruction has no agent-scoped form on the\n        // trait. Refuse rather than passing through; the operator path\n        // for purges is an admin Memory handle, not an agent loop.\n        ::zeroclaw_log::record!(\n            WARN,\n            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)\n                .with_outcome(::zeroclaw_log::EventOutcome::Failure)\n                .with_attrs(::serde_json::json!({\n                    \"namespace\": namespace,\n                    \"bound_agent\": self.agent_id,\n                })),\n            \"purge_namespace refused: cross-agent bulk delete requires an admin Memory handle\"\n        );\n        anyhow::bail!(\n            \"AgentScopedMemory refuses purge_namespace: cross-agent bulk delete must run through an admin Memory handle\"\n        );\n    }\n\n    async fn purge_session(&self, session_id: &str) -> Result<usize> {\n        // Bulk session deletes must be scoped by both session and bound\n        // agent at the backend boundary. Listing a session and deleting by\n        // `(key, agent_id)` can delete the bound agent's row from a\n        // different session when keys collide.\n        self.inner\n            .purge_session_for_agent(session_id, &self.agent_id)\n            .await\n    }\n\n    async fn reindex(&self) -> Result<usize> {\n        // Reindex is an admin-shaped op (rebuilds FTS / re-embeds\n        // missing vectors). Touching the inner backend here is\n        // contained: it does not mutate row attribution or expose","sourceCodeStart":357,"sourceCodeEnd":393,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-memory/src/agent_scoped.rs#L357-L393","documentation":"AgentScopedMemory is a security wrapper around a Memory backend that is bound to exactly one agent_id (plus a recall-only allowlist of peers). The Memory trait's purge_namespace would bulk-delete rows of ALL agents in a namespace, so the wrapper refuses the call unconditionally — there is no input that makes it succeed — and logs a WARN Reject event with the namespace and bound_agent before bailing. The intended operator path for a namespace purge is an admin Memory handle on the same backend (e.g. the inner SqliteMemory/PostgresMemory), not the agent-scoped wrapper handed to an agent loop.","triggerScenarios":"Calling purge_namespace on any Memory trait object that is actually an AgentScopedMemory (constructed via AgentScopedMemory::new(inner, agent_id, ...)). Commonly hit when DI hands your loop the agent-scoped handle and you attempt an install-wide cleanup: scoped.purge_namespace(\"default\").await. Note purge_session on the same wrapper succeeds because it is routed to purge_session_for_agent and stays agent-bound.","commonSituations":"Writing a maintenance/cleanup job that receives the same Memory handle the agent uses; refactoring admin tooling to reuse the runtime's scoped handle; tests that assert the refusal (purge_namespace_is_refused); assuming the trait method is callable on every implementation.","solutions":["Obtain an admin Memory handle to the same backend (construct/open the inner backend directly instead of going through AgentScopedMemory) and run purge_namespace there, in operator-controlled code.","If you only meant to clear one session's data, call purge_session(session_id) on the scoped handle — it deletes only the bound agent's rows via purge_session_for_agent.","If you meant to delete specific keys, use forget/forget_for_agent, which the wrapper permits for the bound agent.","If you expected to already hold an admin handle, audit how the handle was created/wired — an AgentScopedMemory was injected somewhere in the chain."],"exampleFix":"// before: agent-scoped handle, always refused\nlet removed = scoped.purge_namespace(\"default\").await?; // bails: refuses purge_namespace\n\n// after: run the bulk delete through an admin handle on the same backend\nlet admin: Arc<dyn Memory> = Arc::new(SqliteMemory::new(alias, workspace_dir)?);\nlet removed = admin.purge_namespace(\"default\").await?;","handlingStrategy":"fallback","validationCode":null,"typeGuard":null,"tryCatchPattern":"// Route the refusal instead of crashing the agent loop\nmatch scoped.purge_namespace(ns).await {\n    Ok(n) => log::info!(\"purged {n} rows\"),\n    Err(e) if e.to_string().contains(\"AgentScopedMemory refuses purge_namespace\") => {\n        // Escalate to the operator/admin-handle path; never retry on the scoped handle\n        return admin_queue.enqueue_purge(ns);\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["Never call purge_namespace on a handle you did not construct yourself; treat trait objects from DI as possibly agent-scoped.","Keep admin operations (purge_namespace, reindex) in operator-run code paths that open the backend directly.","Prefer purge_session or forget on scoped handles — they are the agent-safe delete surfaces.","Assert in tests which handle type your cleanup code receives before relying on trait parity."],"tags":["rust","zeroclaw","memory","security","access-control","purge","agent-scoped"],"backgroundTag":"permission-denied","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}