zeroclaw-labs/zeroclaw · error

AgentScopedMemory refuses purge_namespace: cross-agent bulk

Error message

AgentScopedMemory refuses purge_namespace: cross-agent bulk delete must run through an admin Memory handle

What it means

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.

Source

Thrown at crates/zeroclaw-memory/src/agent_scoped.rs:375

            })
            .count())
    }

    async fn purge_namespace(&self, namespace: &str) -> Result<usize> {
        // Bulk cross-agent destruction has no agent-scoped form on the
        // trait. Refuse rather than passing through; the operator path
        // for purges is an admin Memory handle, not an agent loop.
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                .with_attrs(::serde_json::json!({
                    "namespace": namespace,
                    "bound_agent": self.agent_id,
                })),
            "purge_namespace refused: cross-agent bulk delete requires an admin Memory handle"
        );
        anyhow::bail!(
            "AgentScopedMemory refuses purge_namespace: cross-agent bulk delete must run through an admin Memory handle"
        );
    }

    async fn purge_session(&self, session_id: &str) -> Result<usize> {
        // Bulk session deletes must be scoped by both session and bound
        // agent at the backend boundary. Listing a session and deleting by
        // `(key, agent_id)` can delete the bound agent's row from a
        // different session when keys collide.
        self.inner
            .purge_session_for_agent(session_id, &self.agent_id)
            .await
    }

    async fn reindex(&self) -> Result<usize> {
        // Reindex is an admin-shaped op (rebuilds FTS / re-embeds
        // missing vectors). Touching the inner backend here is
        // contained: it does not mutate row attribution or expose

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. 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.
  2. 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.
  3. If you meant to delete specific keys, use forget/forget_for_agent, which the wrapper permits for the bound agent.
  4. If you expected to already hold an admin handle, audit how the handle was created/wired — an AgentScopedMemory was injected somewhere in the chain.

Example fix

// before: agent-scoped handle, always refused
let removed = scoped.purge_namespace("default").await?; // bails: refuses purge_namespace

// after: run the bulk delete through an admin handle on the same backend
let admin: Arc<dyn Memory> = Arc::new(SqliteMemory::new(alias, workspace_dir)?);
let removed = admin.purge_namespace("default").await?;
Defensive patterns

Strategy: fallback

Try / catch

// Route the refusal instead of crashing the agent loop
match scoped.purge_namespace(ns).await {
    Ok(n) => log::info!("purged {n} rows"),
    Err(e) if e.to_string().contains("AgentScopedMemory refuses purge_namespace") => {
        // Escalate to the operator/admin-handle path; never retry on the scoped handle
        return admin_queue.enqueue_purge(ns);
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/7f4b3287280547f8. Report an issue: GitHub.