{"record":{"id":"3a2e87ece72550a4","repo":"Hmbown/CodeWhale","slug":"agent-mail-can-be-marked-read-only-after-delivery","errorCode":null,"errorMessage":"Agent Mail can be marked read only after delivery","messagePattern":"Agent Mail can be marked read only after delivery","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/tui/src/runtime_threads.rs","lineNumber":3716,"sourceCode":"        message_id: &AgentMailMessageId,\n    ) -> Result<AgentMailEnvelope> {\n        let thread = self.get_thread(thread_id).await?;\n        let address = agent_mail_address(&self.store.owner_id, &thread)?;\n        let envelope = {\n            let _mail_mutation = self.store.mail_mutation.lock();\n            let mut envelope = self.store.load_agent_mail(message_id)?;\n            if envelope.destination != address {\n                bail!(\"Agent Mail ownership denied: message does not belong to this destination\");\n            }\n            match envelope.status {\n                AgentMailStatus::Read => envelope,\n                AgentMailStatus::Delivered => {\n                    envelope.status = AgentMailStatus::Read;\n                    envelope.read_at = Some(Utc::now());\n                    self.store.save_agent_mail(&envelope)?;\n                    envelope\n                }\n                _ => bail!(\"Agent Mail can be marked read only after delivery\"),\n            }\n        };\n        self.emit_agent_mail_event(AGENT_MAIL_EVENT_READ, &envelope)\n            .await?;\n        Ok(envelope)\n    }\n\n    /// Claim and project one envelope into the existing destination turn\n    /// queue. A busy thread keeps queued mail untouched; retryable failures are\n    /// claimed again only below the bounded attempt ceiling.\n    pub async fn deliver_agent_mail(\n        &self,\n        thread_id: &str,\n        message_id: &AgentMailMessageId,\n    ) -> Result<(AgentMailEnvelope, Option<TurnRecord>)> {\n        let thread = self.get_thread(thread_id).await?;\n        let address = agent_mail_address(&self.store.owner_id, &thread)?;\n        {","sourceCodeStart":3698,"sourceCodeEnd":3734,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/0c42157ee52f9d55af2b506d71b46249910f77d3/crates/tui/src/runtime_threads.rs#L3698-L3734","documentation":"mark_agent_mail_read only permits the Delivered -> Read transition (Read -> Read is an idempotent no-op). The envelope's status was Queued, Delivering, or Failed, so it has not been delivered yet and cannot be marked read.","triggerScenarios":"Calling mark_read on a just-queued envelope before deliver_agent_mail projected it into the destination turn; racing the delivery state machine; or marking read a permanently Failed envelope. Match at runtime_threads.rs:3708-3717 (AgentMailStatus in protocol/src/agent_mail.rs:220-226).","commonSituations":"A mail client optimistically marking messages read on render while delivery is still in flight; retry storms that read before deliver; polling loops that treat 'visible in store' as 'delivered'.","solutions":["Only call mark_read for envelopes whose status is Delivered (or Read, for idempotency) - check status via list/load first","If delivery is pending, drive deliver_agent_mail to completion, then mark read","For Failed envelopes, inspect envelope.failure: retryable failures need re-delivery, not read-marking","Sequence the client as deliver -> present -> mark_read, never mark_read on store visibility alone"],"exampleFix":"// before\nmanager.mark_agent_mail_read(thread_id, &msg.id).await?; // may run while status == Queued\n\n// after\nlet envelope = manager.load_agent_mail(&msg.id).await?;\nif matches!(envelope.status, AgentMailStatus::Delivered | AgentMailStatus::Read) {\n    manager.mark_agent_mail_read(thread_id, &msg.id).await?;\n}","handlingStrategy":"validation","validationCode":"// Gate on status before marking read.\nlet envelope = manager.load_agent_mail(message_id).await?;\nmatch envelope.status {\n    AgentMailStatus::Delivered | AgentMailStatus::Read => {\n        manager.mark_agent_mail_read(thread_id, message_id).await?;\n    }\n    _ => {/* still queued/delivering/failed: not readable yet */}\n}","typeGuard":"fn is_readable(status: AgentMailStatus) -> bool {\n    matches!(status, AgentMailStatus::Delivered | AgentMailStatus::Read)\n}","tryCatchPattern":null,"preventionTips":["Model the status machine: Queued -> Delivering -> Delivered -> Read; Failed is terminal until retried","Never mark read based on store visibility alone - wait for the delivered event","For Failed envelopes, inspect envelope.failure.retryable and re-drive delivery instead"],"tags":["agent-mail","state-machine","invalid-transition","mailbox"],"backgroundTag":"invalid-state-transition","analyzedSha":"0c42157ee52f9d55af2b506d71b46249910f77d3","analyzedAt":"2026-08-20T21:50:45.477Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}