From 36957226b199da16a3df121ec6fe9686ae9c133d Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 5 Sep 2026 14:37:15 -0400 Subject: [PATCH] fix(node)!: read a message that predates the `asks` tag A hook installed before the tag existed sends what it always sent, and the daemon refused all of it with `missing field asks`. That is the skew the wire version exists to survive, so an absent tag means the only thing there was to ask. Something the daemon cannot do is still named rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ideb773b99a6cbfd5b6a2a23916c5ac1bf9550b7f --- crates/didbot-agentd/src/protocol.rs | 45 +++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index 1d3007fe..1147f9ab 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -20,7 +20,12 @@ use serde::{Deserialize, Serialize}; pub const VERSION: u32 = 1; /// What each caller wants. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// +/// Serialized with an `asks` tag. Read without needing one: a sender that +/// predates the tag is asking the only thing there was to ask, and refusing +/// it would make every adapter update a flag day. That is the skew [`VERSION`] +/// exists to survive, and a missing tag is the cheapest kind. +#[derive(Debug, Clone, Serialize)] #[serde(tag = "asks", rename_all = "snake_case")] pub enum Message { /// A hook saying what the harness just did. @@ -29,6 +34,24 @@ pub enum Message { Confirm(Confirm), } +impl<'de> Deserialize<'de> for Message { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let value = serde_json::Value::deserialize(deserializer)?; + match value.get("asks").and_then(serde_json::Value::as_str) { + None | Some("report") => serde_json::from_value(value) + .map(Message::Report) + .map_err(D::Error::custom), + Some("confirm") => serde_json::from_value(value) + .map(Message::Confirm) + .map_err(D::Error::custom), + Some(other) => Err(D::Error::custom(format!( + "this daemon does not know how to `{other}`" + ))), + } + } +} + /// What the adapter observed, in the vocabulary the daemon works in. /// /// Deliberately not the harness's own event names: the daemon has no opinion @@ -198,6 +221,26 @@ mod tests { ); } + #[test] + fn a_sender_that_predates_the_tag_is_still_read() { + // What every adapter sent before there was more than one thing to ask. + let line = r#"{"version":1,"observed":"acted","session":"s1","context":"a1"}"#; + let message: Message = serde_json::from_str(line).unwrap(); + let Message::Report(report) = message else { + panic!("a report"); + }; + assert_eq!(report.observed, Observed::Acted); + } + + #[test] + fn something_this_daemon_cannot_do_is_named_rather_than_guessed() { + let line = r#"{"asks":"revoke","version":1}"#; + let err = serde_json::from_str::(line) + .unwrap_err() + .to_string(); + assert!(err.contains("revoke"), "{err}"); + } + #[test] fn an_unknown_observation_is_refused_rather_than_guessed() { let line = r#"{"asks":"report","version":1,"observed":"vanished","session":"s1"}"#; -- 2.51.2