Hmbown/CodeWhale · error · anyhow::Error

Agent Mail ownership denied: source and destination must bel

Error message

Agent Mail ownership denied: source and destination must belong to the same runtime owner and workspace

What it means

queue_agent_mail resolved both threads to AgentMailAddresses and found the source and destination do not share owner_id and workspace_id. Mail is single-owner, single-workload: envelopes never cross runtime owners or workspaces, so this is an authorization boundary failure, not a connectivity issue.

Source

Thrown at crates/tui/src/runtime_threads.rs:3620

                *label = sanitize_agent_mail_text(
                    label,
                    codewhale_protocol::agent_mail::MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES,
                );
            }
        }
        request.validate().map_err(|error| anyhow!(error))?;
        if request.source_thread_id == request.destination_thread_id {
            bail!("Agent Mail source and destination threads must differ");
        }

        let source_thread = self.get_thread(&request.source_thread_id).await?;
        let destination_thread = self.get_thread(&request.destination_thread_id).await?;
        let source = agent_mail_address(&self.store.owner_id, &source_thread)?;
        let destination = agent_mail_address(&self.store.owner_id, &destination_thread)?;
        if source.owner_id != destination.owner_id
            || source.workspace_id != destination.workspace_id
        {
            bail!(
                "Agent Mail ownership denied: source and destination must belong to the same runtime owner and workspace"
            );
        }
        let expected_sender = agent_mail_sender_identity(&source_thread)?;
        if request.sender.identity != expected_sender {
            bail!(
                "Agent Mail ownership denied: sender identity does not own the source task/session"
            );
        }

        let (envelope, idempotent_replay) = {
            let _mail_mutation = self.store.mail_mutation.lock();
            let path = self.store.mail_path(&request.message_id)?;
            if path.exists() {
                let persisted = self.store.load_agent_mail(&request.message_id)?;
                if !persisted.matches_send_request(&request) {
                    bail!(
                        "Agent Mail message id '{}' already exists with different delivery intent",

View on GitHub (pinned to 0c42157ee5)

Solutions

  1. Verify both thread ids were created by the same runtime owner and workspace (list threads and compare their records)
  2. If the workspace legitimately changed, re-create or migrate the thread record so both sides resolve within one workspace
  3. Never accept destination thread ids from untrusted input - resolve and authorize them server-side
  4. Check for accidental cross-environment id leakage (prod id in a dev runtime)
Defensive patterns

Strategy: validation

Validate before calling

// Both threads must resolve under the same owner and workspace.
let src = manager.get_thread(&request.source_thread_id).await?;
let dst = manager.get_thread(&request.destination_thread_id).await?;
if src.owner_id != dst.owner_id || src.workspace_id != dst.workspace_id {
    return Err(anyhow::anyhow!("cross-owner/cross-workspace mail is not permitted"));
}

Prevention

When it happens

Trigger: get_thread resolves either thread whose stored owner/workspace differs from the other - e.g. one id belongs to a thread created under a different owner or workspace layout after a migration, or a guessed/foreign thread id was passed. Check at runtime_threads.rs:3615-3623.

Common situations: Mixing thread ids from two runtime deployments or profiles; workspace renamed/re-keyed so one side resolves to the old workspace; test fixtures reusing ids across isolated stores.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@0c42157ee5 (2026-08-20). Data as JSON: /api/errors/5d6ac78f5fc4ec1f. Report an issue: GitHub.