From b7480e875cadbe295a03f9a4532d0bd155fad49a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 16:51:57 -0400 Subject: [PATCH 1/4] feat(core/relay): add NIP-AM kind 44200 (agent turn metrics) with relay plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add kind:44200 (KIND_AGENT_TURN_METRIC) as the durable, p-gated, owner-encrypted per-turn token-usage event defined in NIP-AM (docs/nips/NIP-AM.md, PR #1441). Changes: - buzz-core/kind.rs: add KIND_AGENT_TURN_METRIC = 44200 to P_GATED_KINDS and ALL_KINDS; compile-time asserts confirm regular (non-ephemeral, non-replaceable) kind shape - buzz-core/agent_turn_metric.rs (new): AgentTurnMetricPayload type matching the NIP schema (harness+timestamp required; nullable token fields; turn/cumulative objects; sessionId+turnSeq required when cumulative present; deltaReliable; stopReason enum); encrypt_agent_turn_metric/decrypt_agent_turn_metric helpers reusing encrypt_observer_payload/decrypt_observer_payload from observer.rs; round-trip, wrong-key, null-field, and stop-reason tests - buzz-relay/handlers/req.rs: extend p_gated_filters_authorized to deny the ids-filter exemption for kind:44200 (same carve-out shape as KIND_DM_VISIBILITY); new test covering both {kinds:[44200], ids:[...]} deny and the kindless-ids pass-through path (with documented defense-in-depth note) - buzz-relay/handlers/ingest.rs: validate_agent_turn_metric_envelope (p tag, agent tag == event.pubkey, no h tag, NIP-44 content); async is_agent_owner ownership check; required_scope_for_kind → MessagesWrite; is_global_only_kind addition; envelope and ownership tests - migrations/0001_initial_schema.sql + schema/schema.sql: add 44200 to the NULL search_tsv CASE so the p_gated_persistent_kinds_have_storage_null_tsvector drift test passes No emit logic, no adapters — Task B (goose adapter, buzz-acp) is a separate PR. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/agent_turn_metric.rs | 264 ++++++++++++++++++++++ crates/buzz-core/src/kind.rs | 19 ++ crates/buzz-core/src/lib.rs | 2 + crates/buzz-relay/src/handlers/ingest.rs | 244 +++++++++++++++++++- crates/buzz-relay/src/handlers/req.rs | 82 ++++++- migrations/0001_initial_schema.sql | 6 +- schema/schema.sql | 2 +- 7 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 crates/buzz-core/src/agent_turn_metric.rs diff --git a/crates/buzz-core/src/agent_turn_metric.rs b/crates/buzz-core/src/agent_turn_metric.rs new file mode 100644 index 000000000..325a94f9d --- /dev/null +++ b/crates/buzz-core/src/agent_turn_metric.rs @@ -0,0 +1,264 @@ +//! NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. +//! +//! One `kind:44200` event is published per completed agent turn. Its content +//! is a NIP-44 v2 ciphertext (agent key → owner pubkey) that decodes to an +//! [`AgentTurnMetricPayload`] JSON object. +//! +//! See `docs/nips/NIP-AM.md` for the full specification. + +use nostr::{Event, Keys, PublicKey}; +use serde::{Deserialize, Serialize}; + +use crate::observer::{ + decrypt_observer_payload, encrypt_observer_payload, ObserverPayloadError, +}; + +// Re-export for callers that only need the error type. +pub use crate::observer::ObserverPayloadError as AgentTurnMetricError; + +/// Token-usage counters for a single measurement window (one turn or cumulative). +/// +/// All token fields are nullable — `None` means the harness did not report them, +/// NOT that the count was zero. See NIP-AM §Numeric validity and token semantics. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenCounts { + /// Input tokens (inclusive of cache reads/writes where applicable). + pub input_tokens: Option, + + /// Output tokens. + pub output_tokens: Option, + + /// Provider-reported total — NOT derived by summing input + output. + /// `None` when the provider did not report a total. + pub total_tokens: Option, + + /// Estimated cost in USD. Must be finite and non-negative when present. + pub cost_usd: Option, + + /// Informational: cache-read tokens included in `input_tokens`. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + + /// Informational: cache-write tokens included in `input_tokens`. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, +} + +/// Why a turn ended. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + /// Model reached a natural end-of-turn. + EndTurn, + /// Model hit the max-tokens limit. + MaxTokens, + /// Turn was cancelled by the owner or harness. + Cancelled, + /// Turn ended with an error. + Error, + /// Stop reason is unknown. + Unknown, +} + +/// Decrypted payload of a `kind:44200` Agent Turn Metric event. +/// +/// `harness` and `timestamp` are REQUIRED. All other fields are optional or +/// nullable unless constrained by the NIP (e.g. `session_id` + `turn_seq` +/// are required whenever `cumulative` is present). +/// +/// Consumers MUST ignore unknown fields (forward compatibility). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTurnMetricPayload { + /// Harness identifier (e.g. `"goose"`, `"buzz-agent"`). REQUIRED. + pub harness: String, + + /// Model identifier as reported by the harness, or `None` if unknown. + pub model: Option, + + /// Channel UUID the turn served, encrypted inside the payload. + pub channel_id: Option, + + /// Session identifier. REQUIRED when `cumulative` is present. + pub session_id: Option, + + /// Turn identifier (harness-internal). + pub turn_id: Option, + + /// Monotonically increasing per-session sequence number. + /// REQUIRED when `cumulative` is present; strictly increasing within one + /// `session_id`. A publisher restart that loses the counter MUST start a + /// new `session_id`. + pub turn_seq: Option, + + /// RFC 3339 timestamp (end-of-turn). REQUIRED. + pub timestamp: String, + + /// Usage for this turn (computed delta). Null fields mean not reported. + pub turn: Option, + + /// Session-cumulative usage as reported at end of this turn. + pub cumulative: Option, + + /// `false` when the publisher could not observe the previous cumulative + /// baseline (e.g. harness restart mid-session), making `turn` unreliable. + /// Defaults to `true` on the wire when not explicitly set. + #[serde(default = "default_delta_reliable")] + pub delta_reliable: bool, + + /// Why the turn ended. Unrecognized values MUST be treated as `Unknown`. + pub stop_reason: Option, +} + +fn default_delta_reliable() -> bool { + true +} + +/// Encrypt an [`AgentTurnMetricPayload`] into a NIP-44 v2 ciphertext string +/// using the agent's key pair and the owner's public key. +/// +/// This is the content field of a `kind:44200` event. +pub fn encrypt_agent_turn_metric( + agent_keys: &Keys, + owner_pubkey: &PublicKey, + payload: &AgentTurnMetricPayload, +) -> Result { + encrypt_observer_payload(agent_keys, owner_pubkey, payload) +} + +/// Decrypt and deserialize an [`AgentTurnMetricPayload`] from a `kind:44200` event. +/// +/// `recipient_keys` is the owner's key pair. +pub fn decrypt_agent_turn_metric( + recipient_keys: &Keys, + event: &Event, +) -> Result { + decrypt_observer_payload(recipient_keys, event) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Kind, Tag}; + + fn sample_payload() -> AgentTurnMetricPayload { + AgentTurnMetricPayload { + harness: "goose".to_string(), + model: Some("claude-sonnet-4-5".to_string()), + channel_id: Some("12345678-1234-1234-1234-123456789abc".to_string()), + session_id: Some("sess-abc".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T20:11:03.213Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(1234), + output_tokens: Some(567), + total_tokens: Some(1801), + cost_usd: Some(0.0123), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: Some(TokenCounts { + input_tokens: Some(45210), + output_tokens: Some(9876), + total_tokens: Some(55086), + cost_usd: Some(0.41), + cache_read_tokens: None, + cache_write_tokens: None, + }), + delta_reliable: true, + stop_reason: Some(StopReason::EndTurn), + } + } + + #[test] + fn round_trip_encrypt_decrypt() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + + let payload = sample_payload(); + let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload) + .expect("encrypt"); + + // Build a minimal event envelope so decrypt_observer_payload can use event.pubkey. + let event = EventBuilder::new(Kind::Custom(44200), ciphertext) + .tags([ + Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .expect("sign"); + + let decoded = + decrypt_agent_turn_metric(&owner_keys, &event).expect("decrypt"); + + assert_eq!(decoded, payload); + } + + #[test] + fn wrong_key_decrypt_fails() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); + + let payload = sample_payload(); + let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload) + .expect("encrypt"); + + let event = EventBuilder::new(Kind::Custom(44200), ciphertext) + .tags([ + Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .expect("sign"); + + let result = decrypt_agent_turn_metric(&wrong_keys, &event); + assert!(result.is_err(), "expected decrypt error with wrong key"); + } + + #[test] + fn delta_reliable_defaults_to_true_when_absent() { + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z"}"#; + let payload: AgentTurnMetricPayload = + serde_json::from_str(json).expect("parse"); + assert!(payload.delta_reliable, "deltaReliable should default to true"); + } + + #[test] + fn stop_reason_round_trips() { + for (variant, json_val) in [ + (StopReason::EndTurn, "\"end_turn\""), + (StopReason::MaxTokens, "\"max_tokens\""), + (StopReason::Cancelled, "\"cancelled\""), + (StopReason::Error, "\"error\""), + (StopReason::Unknown, "\"unknown\""), + ] { + let serialized = serde_json::to_string(&variant).unwrap(); + assert_eq!(serialized, json_val); + let deserialized: StopReason = serde_json::from_str(json_val).unwrap(); + assert_eq!(deserialized, variant); + } + } + + #[test] + fn null_token_counts_round_trip() { + // Verify that None fields serialize to `null` (not absent), as required + // by the NIP — consumers must distinguish "not reported" from "zero". + let counts = TokenCounts { + input_tokens: None, + output_tokens: None, + total_tokens: None, + cost_usd: None, + cache_read_tokens: None, + cache_write_tokens: None, + }; + let json = serde_json::to_string(&counts).unwrap(); + // cache_* are skip_serializing_if = None, others serialize as null + assert!(json.contains("\"inputTokens\":null")); + assert!(json.contains("\"outputTokens\":null")); + let back: TokenCounts = serde_json::from_str(&json).unwrap(); + assert_eq!(back, counts); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index f2e918424..1da402cf1 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -131,6 +131,10 @@ pub const P_GATED_KINDS: &[u32] = &[ KIND_MEMBER_REMOVED_NOTIFICATION, KIND_GIFT_WRAP, KIND_DM_VISIBILITY, + // NIP-AM: agent turn metrics are encrypted to the owner and must not be + // readable by any unauthenticated or non-owner party, including via `ids` + // filters — see NIP-AM §Relay Behavior. + KIND_AGENT_TURN_METRIC, ]; /// NIP-AP: Agent Persona (parameterized replaceable, owner-authored). @@ -341,6 +345,15 @@ pub const KIND_MEMBER_ADDED_NOTIFICATION: u32 = 44100; /// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID. pub const KIND_MEMBER_REMOVED_NOTIFICATION: u32 = 44101; +/// NIP-AM: Agent Turn Metric — durable per-turn token-usage record (agent-authored). +/// +/// Regular stored event (append-only, never replaced). The agent publishes one +/// event per completed turn, NIP-44 encrypted to its owner. Tags: exactly one `p` +/// (owner pubkey) and one `agent` (agent pubkey == event pubkey); no `h` tag. +/// Stored globally (channel_id = NULL); owner-scoped reads only (p-gated, NIP-42). +/// See `docs/nips/NIP-AM.md`. +pub const KIND_AGENT_TURN_METRIC: u32 = 44200; + // Forum / social (45000–45999) // V1 used addressable range (30001–30003) — wrong. /// A forum post (thread root). @@ -504,6 +517,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_JOB_ERROR, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_AGENT_TURN_METRIC, KIND_WORKFLOW_DEF, KIND_LONG_FORM, KIND_USER_STATUS, @@ -648,6 +662,11 @@ const _: () = assert!(KIND_AUTH <= u16::MAX as u32); const _: () = assert!(KIND_CANVAS <= u16::MAX as u32); const _: () = assert!(KIND_HUDDLE_GUIDELINES <= u16::MAX as u32); const _: () = assert!(EPHEMERAL_KIND_MIN < EPHEMERAL_KIND_MAX); +// Compile-time: KIND_AGENT_TURN_METRIC is a regular stored kind (not ephemeral, not replaceable). +const _: () = assert!(!is_ephemeral(KIND_AGENT_TURN_METRIC)); +const _: () = assert!(!is_replaceable(KIND_AGENT_TURN_METRIC)); +const _: () = assert!(!is_parameterized_replaceable(KIND_AGENT_TURN_METRIC)); +const _: () = assert!(KIND_AGENT_TURN_METRIC <= u16::MAX as u32); #[cfg(test)] mod tests { diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dee40e988..7c3a1c38d 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -24,6 +24,8 @@ pub mod kind; pub mod network; /// Agent observer frame helpers. pub mod observer; +/// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. +pub mod agent_turn_metric; /// NIP-AB device pairing — crypto primitives, message types, and errors. pub mod pairing; /// Presence status types shared across crates. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 057ea972f..38e6ed95e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use buzz_auth::Scope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_APPROVAL_DENY, + is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, + KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, @@ -156,6 +157,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::UsersWrite) } + // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). + KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite), // NIP-51 standard lists and NIP-65 relay list — user-owned global state, // same ownership shape as kind:3 (contacts) and kind:0 (profile). KIND_MUTE_LIST @@ -376,6 +379,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // Mesh-LLM relay status is relay-signed and global. Clients may // subscribe to it, but must not channel-scope or submit it. | KIND_MESH_LLM_RELAY_STATUS + // NIP-AM: agent turn metrics are owner-scoped global events. + // Channel identity is encrypted inside the payload — no `h` tag. + | KIND_AGENT_TURN_METRIC ) } @@ -1055,6 +1061,78 @@ fn validate_engram_nip44_content(content: &str) -> Result<(), String> { Ok(()) } +/// Validate the public envelope of a NIP-AM `kind:44200` event. +/// +/// Enforces (without touching the encrypted payload): +/// - Exactly one `p` tag: 64 lowercase hex chars (the owner pubkey). +/// - Exactly one `agent` tag: 64 lowercase hex chars equal to `event.pubkey`. +/// - No `h` tag (channel identity belongs inside the encrypted payload). +/// - Content syntactically resembles NIP-44 v2 ciphertext (delegated to +/// `validate_engram_nip44_content`, which does the same length/base64/version check). +/// +/// Ownership (`is_agent_owner`) is an async DB check performed separately in +/// `ingest_event_inner` after this synchronous envelope check. +fn validate_agent_turn_metric_envelope(event: &nostr::Event) -> Result<(), String> { + let event_pubkey_hex = event.pubkey.to_hex(); + let mut p_tags: Vec<&str> = Vec::new(); + let mut agent_tags: Vec<&str> = Vec::new(); + let mut has_h_tag = false; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 2 { + continue; + } + match parts[0].as_str() { + "p" => p_tags.push(&parts[1]), + "agent" => agent_tags.push(&parts[1]), + "h" => has_h_tag = true, + _ => {} + } + } + + if has_h_tag { + return Err( + "agent-turn-metric event must not have an `h` tag (channel identity belongs inside the encrypted payload)".to_string(), + ); + } + + if p_tags.len() != 1 { + return Err(format!( + "agent-turn-metric event must have exactly one `p` tag (got {})", + p_tags.len() + )); + } + let p = p_tags[0]; + if p.len() != 64 || !p.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { + return Err("agent-turn-metric `p` tag must be 64 lowercase hex chars".to_string()); + } + + if agent_tags.len() != 1 { + return Err(format!( + "agent-turn-metric event must have exactly one `agent` tag (got {})", + agent_tags.len() + )); + } + let agent = agent_tags[0]; + if agent.len() != 64 + || !agent.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("agent-turn-metric `agent` tag must be 64 lowercase hex chars".to_string()); + } + if agent != event_pubkey_hex { + return Err( + "agent-turn-metric `agent` tag must equal event pubkey".to_string(), + ); + } + + // Content must look like a NIP-44 v2 ciphertext (length, base64, version prefix). + validate_engram_nip44_content(&event.content) + .map_err(|e| e.replace("agent-engram", "agent-turn-metric"))?; + + Ok(()) +} + /// Parse a NIP-ER `not_before` tag value into a Unix timestamp. /// /// The value MUST be a decimal integer string containing only ASCII digits, with @@ -1622,6 +1700,43 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_AGENT_TURN_METRIC { + validate_agent_turn_metric_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + + // Ownership check: `p` tag must be the registered owner of `event.pubkey`. + // Tag shape is already verified above; these extractions are infallible. + let owner_hex = event + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + if parts.len() >= 2 && parts[0].as_str() == "p" { + Some(parts[1].as_str()) + } else { + None + } + }) + .expect("p tag present (validated above)"); + let agent_bytes = event.pubkey.to_bytes().to_vec(); + let owner_bytes = hex::decode(owner_hex).expect("hex validated above"); + let is_owner = state + .db + .is_agent_owner(tenant.community(), &agent_bytes, &owner_bytes) + .await + .map_err(|e| { + IngestError::Internal(format!( + "error: db error checking agent-turn-metric ownership: {e}" + )) + })?; + if !is_owner { + return Err(IngestError::AuthFailed( + "restricted: agent-turn-metric `p` tag must be the registered owner of this agent" + .into(), + )); + } + } + if kind_u32 == KIND_EVENT_REMINDER { validate_event_reminder(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -2260,6 +2375,7 @@ mod tests { KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_AGENT_TURN_METRIC, ]; for kind in migrated { assert!( @@ -2297,6 +2413,24 @@ mod tests { assert!(!requires_h_channel_scope(KIND_MESH_LLM_RELAY_STATUS)); } + #[test] + fn agent_turn_metric_is_global_only_and_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert!( + is_global_only_kind(KIND_AGENT_TURN_METRIC), + "kind:44200 must be global-only (no h tag)" + ); + assert!( + !requires_h_channel_scope(KIND_AGENT_TURN_METRIC), + "kind:44200 must not require an h-tag" + ); + assert_eq!( + required_scope_for_kind(KIND_AGENT_TURN_METRIC, &dummy).unwrap(), + Scope::MessagesWrite, + "kind:44200 requires MessagesWrite scope" + ); + } + #[test] fn nip51_and_nip65_lists_are_global_only() { for kind in [ @@ -2930,4 +3064,112 @@ mod tests { let err = validate_persona_envelope(&ev).unwrap_err(); assert!(err.contains("`d` tag"), "got: {err}"); } + + // ─── agent_turn_metric envelope tests ──────────────────────────────────── + + /// Build an event for kind:44200 with the given tags and content. + /// The signing key IS the agent key, so `event.pubkey` matches the agent. + fn make_agent_turn_metric( + agent_keys: &nostr::Keys, + tags: &[&[&str]], + content: &str, + ) -> nostr::Event { + let nostr_tags: Vec = tags + .iter() + .map(|t| nostr::Tag::parse(t.iter().copied()).unwrap()) + .collect(); + nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16), + content, + ) + .tags(nostr_tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + #[test] + fn agent_turn_metric_envelope_accepts_canonical() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let agent_hex = agent.public_key().to_hex(); + let ev = make_agent_turn_metric( + &agent, + &[&["p", &owner_hex], &["agent", &agent_hex]], + &fake_nip44_v2(), + ); + assert!(validate_agent_turn_metric_envelope(&ev).is_ok()); + } + + #[test] + fn agent_turn_metric_envelope_rejects_h_tag() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let agent_hex = agent.public_key().to_hex(); + let ev = make_agent_turn_metric( + &agent, + &[ + &["p", &owner_hex], + &["agent", &agent_hex], + &["h", "some-channel-uuid"], + ], + &fake_nip44_v2(), + ); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!(err.contains("`h` tag"), "got: {err}"); + } + + #[test] + fn agent_turn_metric_envelope_rejects_missing_p() { + let agent = nostr::Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let ev = + make_agent_turn_metric(&agent, &[&["agent", &agent_hex]], &fake_nip44_v2()); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn agent_turn_metric_envelope_rejects_missing_agent() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let ev = + make_agent_turn_metric(&agent, &[&["p", &owner_hex]], &fake_nip44_v2()); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!(err.contains("`agent` tag"), "got: {err}"); + } + + #[test] + fn agent_turn_metric_envelope_rejects_agent_mismatch() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let wrong_agent_hex = "c".repeat(64); // not event.pubkey + let ev = make_agent_turn_metric( + &agent, + &[&["p", &owner_hex], &["agent", &wrong_agent_hex]], + &fake_nip44_v2(), + ); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!( + err.contains("equal event pubkey"), + "got: {err}" + ); + } + + #[test] + fn agent_turn_metric_envelope_rejects_bad_content() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let agent_hex = agent.public_key().to_hex(); + let ev = make_agent_turn_metric( + &agent, + &[&["p", &owner_hex], &["agent", &agent_hex]], + "not-a-ciphertext", + ); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + // error comes from validate_engram_nip44_content with label replaced + assert!( + err.contains("agent-turn-metric"), + "got: {err}" + ); + } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2e697eafa..b43c04bca 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -6,7 +6,10 @@ use std::sync::Arc; use tracing::{debug, warn}; use buzz_core::filter::filters_match; -use buzz_core::kind::{AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_DM_VISIBILITY, P_GATED_KINDS}; +use buzz_core::kind::{ + AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, + P_GATED_KINDS, +}; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; use buzz_pubsub::EventTopic; @@ -974,13 +977,18 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: // safe for kinds whose id is author-bound or whose content is encrypted. // KIND_DM_VISIBILITY is relay-signed (id not author-bound) and exposes // plaintext private hide choices, so its `#p` owner check MUST hold even - // when `ids` is present. Only filters that explicitly name the kind lose - // the exemption — a kindless `ids` lookup is unaffected. - let explicitly_dm_visibility = filter.kinds.as_ref().is_some_and(|ks| { - ks.iter() - .any(|kind| kind.as_u16() as u32 == KIND_DM_VISIBILITY) + // when `ids` is present. KIND_AGENT_TURN_METRIC events are long-lived + // and their cleartext envelope (pubkey, agent tag, created_at) leaks + // turn-activity metadata — knowing an event id is NOT authorization + // (NIP-AM §Relay Behavior). Only filters that explicitly name the kind + // lose the exemption — a kindless `ids` lookup is unaffected. + let explicitly_no_ids_exemption = filter.kinds.as_ref().is_some_and(|ks| { + ks.iter().any(|kind| { + let k = kind.as_u16() as u32; + k == KIND_DM_VISIBILITY || k == KIND_AGENT_TURN_METRIC + }) }); - if !explicitly_dm_visibility && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { + if !explicitly_no_ids_exemption && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { return true; } @@ -1284,6 +1292,66 @@ mod tests { assert!(p_gated_filters_authorized(&[member_notif_ids], authed)); } + /// NIP-AM: kind 44200 must deny `{kinds:[44200], ids:[...]}` by non-owner. + /// Thufir's implementation note: the helper treats explicit-kind+ids and + /// kindless ids differently. Explicit `{kinds:[44200], ids:[...]}` is denied; + #[test] + fn agent_turn_metric_requires_p_tag_even_with_ids() { + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let authed = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_id = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let metric_kind = nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16); + + // Case 1: {kinds:[44200], ids:[...]} — explicit kind, should require #p owner. + let explicit_kind_ids_only = Filter::new() + .kind(metric_kind) + .id(nostr::EventId::from_hex(event_id).unwrap()); + assert!( + !p_gated_filters_authorized(&[explicit_kind_ids_only], authed), + "kind:44200 + ids without matching #p must be denied" + ); + + let explicit_kind_wrong_p = Filter::new() + .kind(metric_kind) + .id(nostr::EventId::from_hex(event_id).unwrap()) + .custom_tags(p_tag, [other]); + assert!( + !p_gated_filters_authorized(&[explicit_kind_wrong_p], authed), + "kind:44200 + ids + wrong #p must be denied" + ); + + // Case 2: kindless {ids:[...]} — the existing ids exemption applies + // (consistent with other p-gated kinds like member notifications). The + // relay's defense-in-depth for kind:44200 is: (a) the explicit-kind+ids + // carve-out above, (b) NULL tsvector storage preventing search discovery, + // and (c) the subscription delivery layer not returning 44200 events to + // non-owners. A kindless ids filter is authorized here because + // p_gated_filters_authorized cannot know which kind the id resolves to. + let kindless_ids = Filter::new().id(nostr::EventId::from_hex(event_id).unwrap()); + assert!( + p_gated_filters_authorized(&[kindless_ids], authed), + "kindless ids filter passes this gate (consistent with member-notif behavior)" + ); + + // Case 3: owner querying by #p is allowed. + let owner_by_p = Filter::new().kind(metric_kind).custom_tags(p_tag, [authed]); + assert!( + p_gated_filters_authorized(&[owner_by_p], authed), + "kind:44200 with matching #p must be allowed" + ); + + // Case 4: owner querying by #p + ids is allowed. + let owner_p_and_ids = Filter::new() + .kind(metric_kind) + .id(nostr::EventId::from_hex(event_id).unwrap()) + .custom_tags(p_tag, [authed]); + assert!( + p_gated_filters_authorized(&[owner_p_and_ids], authed), + "kind:44200 with matching #p and ids must be allowed" + ); + } + #[test] fn test_mixed_search_and_non_search_detection() { let search_filter = Filter::new().search("hello"); diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 2d4035f8a..a653de3c0 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -211,16 +211,18 @@ CREATE TABLE events ( -- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state) -- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice) -- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice) + -- 44200 = KIND_AGENT_TURN_METRIC (NIP-AM: p-gated encrypted turn metrics) -- NULL tsvector never matches `@@`, so excluded rows are storage-level -- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP, -- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY, - -- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION); inlined + -- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + -- KIND_AGENT_TURN_METRIC); inlined -- here because a sqlx -- migration is frozen SQL and cannot import the Rust constant. If a new -- privacy-sensitive kind is added there, update this list and add a -- regression test in `buzz-search/tests/fts_integration.rs`. search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, diff --git a/schema/schema.sql b/schema/schema.sql index 1c6fcfb98..c3247f103 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -208,7 +208,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations/0001_initial_schema.sql. search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From 23b522d992c50744bdf8070daa18e77f68eb7413 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 16:58:25 -0400 Subject: [PATCH 2/4] fix(relay/core): close result-level read gate for kind:44200 (NIP-AM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reader_authorized_for_event in filter.rs now gates KIND_AGENT_TURN_METRIC alongside KIND_DM_VISIBILITY — reader must match the #p tag (owner). This single function closes all kindless-ids retrieval paths: WS historical pull (req.rs:330, req.rs:652), HTTP bridge (bridge.rs:608, bridge.rs:863), and live fan-out (event.rs). Live fan-out extended likewise: owner_only_kind now covers both 44200 and 30622, so kindless-ids subscriptions cannot receive 44200 events for non-owners. Tests added: reader_authorized_for_event_gates_agent_turn_metric_by_p (owner allow, non-owner deny, authoring-agent deny). Case-2 rationale in the existing req.rs test updated: pass-through at the filter-authorization gate is correct because the result-level gate is now the enforcement point for this path. NIP-AM ref: docs/nips/NIP-AM.md at 19889ba0c (PR #1441). Resolves blocking gap from PR #1445 review. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/filter.rs | 48 +++++++++++++++++++++---- crates/buzz-relay/src/handlers/event.rs | 13 ++++--- crates/buzz-relay/src/handlers/req.rs | 14 ++++---- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index a3c0bef59..1671f7622 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -12,14 +12,17 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { } /// Result-level read authorization for relay-signed events whose content is -/// private to a single viewer. Currently only `KIND_DM_VISIBILITY`: the reader -/// MUST equal the snapshot's `#p` (owner). Returns `true` for every other kind. +/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY` and +/// `KIND_AGENT_TURN_METRIC`: the reader MUST equal the event's `#p` tag +/// (owner). Returns `true` for every other kind. /// -/// This guards the delivery surfaces directly, so a query that bypasses the -/// filter-level `#p` gate (e.g. a kindless `ids:[…]` lookup of a known snapshot -/// id) still cannot read another viewer's hidden-DM set. +/// This guards every delivery surface — WS historical pull (`req.rs`), HTTP +/// bridge (`bridge.rs`), and live fan-out (`event.rs`) — so a query that +/// bypasses the filter-level `#p` gate (e.g. a kindless `ids:[…]` lookup of +/// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { - if crate::kind::event_kind_u32(event) != crate::kind::KIND_DM_VISIBILITY { + let kind = crate::kind::event_kind_u32(event); + if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { return true; } let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); @@ -261,4 +264,37 @@ mod tests { .expect("sign"); assert!(reader_authorized_for_event(¬e, other)); } + + #[test] + fn reader_authorized_for_event_gates_agent_turn_metric_by_p() { + let agent_keys = Keys::generate(); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let attacker = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + // Agent turn metric event: pubkey=agent, p tag=owner (NIP-AM envelope shape). + let metric = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_TURN_METRIC as u16), + "encrypted-payload", + ) + .tags([ + Tag::parse(["p", owner]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .expect("sign"); + + assert!( + reader_authorized_for_event(&metric, owner), + "owner must be authorized to read their own agent turn metric" + ); + assert!( + !reader_authorized_for_event(&metric, attacker), + "non-owner must NOT be authorized to read an agent turn metric via kindless ids" + ); + // The authoring agent also does not get read-back (NIP-AM: owner-only read). + assert!( + !reader_authorized_for_event(&metric, &agent_keys.public_key().to_hex()), + "the authoring agent must NOT be authorized to read its own metric event (owner-only)" + ); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index f655ae057..a269db32b 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -287,10 +287,13 @@ pub(crate) async fn dispatch_persistent_event( let event_json = serde_json::to_string(&stored_event.event) .expect("nostr::Event serialization is infallible for well-formed events"); - // For viewer-private snapshots (kind:30622), live fan-out must reach only the - // owner — a kindless `ids:[…]` subscription can otherwise match it. Pull paths - // (HTTP /query, WS historical) are gated separately by reader_authorized_for_event. - let dm_visibility_owner: Option = (kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY) + // For viewer-private events (kind:30622 DM visibility, kind:44200 agent turn + // metrics), live fan-out must reach only the owner — a kindless `ids:[…]` + // subscription can otherwise match it. Pull paths (HTTP /query, WS historical) + // are gated separately by reader_authorized_for_event. + let owner_only_kind = kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY + || kind_u32 == buzz_core::kind::KIND_AGENT_TURN_METRIC; + let private_event_owner: Option = owner_only_kind .then(|| { let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); stored_event @@ -304,7 +307,7 @@ pub(crate) async fn dispatch_persistent_event( // filter_fanout_by_access, applied to `matches` above before this loop. let mut drop_count = 0u32; for (target_conn_id, sub_id) in &matches { - if let Some(ref owner_hex) = dm_visibility_owner { + if let Some(ref owner_hex) = private_event_owner { let is_owner = state .conn_manager .pubkey_for(*target_conn_id) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index b43c04bca..7fdae503e 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1322,16 +1322,16 @@ mod tests { ); // Case 2: kindless {ids:[...]} — the existing ids exemption applies - // (consistent with other p-gated kinds like member notifications). The - // relay's defense-in-depth for kind:44200 is: (a) the explicit-kind+ids - // carve-out above, (b) NULL tsvector storage preventing search discovery, - // and (c) the subscription delivery layer not returning 44200 events to - // non-owners. A kindless ids filter is authorized here because - // p_gated_filters_authorized cannot know which kind the id resolves to. + // at this filter-authorization gate (consistent with other p-gated kinds). + // The kindless path is closed at the result level by + // `reader_authorized_for_event` (buzz-core/src/filter.rs), which gates + // kind:44200 delivery to the #p owner across all pull paths (WS historical, + // HTTP bridge) and live fan-out. Pass-through here is correct; the + // result-level gate is the enforcement point for this path. let kindless_ids = Filter::new().id(nostr::EventId::from_hex(event_id).unwrap()); assert!( p_gated_filters_authorized(&[kindless_ids], authed), - "kindless ids filter passes this gate (consistent with member-notif behavior)" + "kindless ids filter passes this filter gate — result-level gate closes the path" ); // Case 3: owner querying by #p is allowed. From c9a1458f5e3f95461df79a34442bc0f6819ffd5b Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 18:47:14 -0400 Subject: [PATCH 3/4] fix(relay/core): plug COUNT existence-leak and StopReason forward-compat for NIP-AM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Thufir-flagged IMPORTANT fixes for PR #1445. Count gate (COUNT existence leak): - Add RESULT_GATED_KINDS = [KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC] to kind.rs — explicit list of kinds that require per-event owner verification even for COUNT queries. - Add filter_can_match_result_gated_kinds() to req.rs — returns true when filter has no kinds constraint (wildcard) or includes a result-gated kind. - Add result_gated_count_safe_for_pushdown() to req.rs — safe to use fast SQL count_events() only when filter's #p tag is non-empty and all values equal the authenticated reader's pubkey. - Apply the guard in count.rs (WS): both with-channel and without-channel fast-path conditions now require !needs_result_gated_filtering; both fallback loops now call reader_authorized_for_event per event. - Apply the guard in bridge.rs (HTTP): same two fast-path conditions and same two fallback loops. - 6 unit tests covering wildcard/explicit/safe-pushdown/unsafe cases. StopReason forward-compatibility: - Replace #[derive(Deserialize)] on StopReason with a custom impl that maps any unrecognized string to StopReason::Unknown instead of returning an error; NIP-AM requires consumers to accept future stopReason values. - Add test unknown_stop_reason_maps_to_unknown_not_error: future value tool_limit deserializes to Unknown; token counts remain intact. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/agent_turn_metric.rs | 51 ++++++++++- crates/buzz-core/src/kind.rs | 9 ++ crates/buzz-relay/src/api/bridge.rs | 23 +++++ crates/buzz-relay/src/handlers/count.rs | 26 +++++- crates/buzz-relay/src/handlers/req.rs | 100 +++++++++++++++++++++- 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/crates/buzz-core/src/agent_turn_metric.rs b/crates/buzz-core/src/agent_turn_metric.rs index 325a94f9d..83565c4cd 100644 --- a/crates/buzz-core/src/agent_turn_metric.rs +++ b/crates/buzz-core/src/agent_turn_metric.rs @@ -46,7 +46,11 @@ pub struct TokenCounts { } /// Why a turn ended. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// NIP-AM: consumers MUST treat unrecognized `stopReason` values as `Unknown` +/// and keep the token counts valid. Custom deserialization maps any unrecognized +/// string to `Unknown` instead of failing the whole payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum StopReason { /// Model reached a natural end-of-turn. @@ -57,10 +61,24 @@ pub enum StopReason { Cancelled, /// Turn ended with an error. Error, - /// Stop reason is unknown. + /// Stop reason is unknown or unrecognized. Unknown, } +impl<'de> Deserialize<'de> for StopReason { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(match s.as_str() { + "end_turn" => StopReason::EndTurn, + "max_tokens" => StopReason::MaxTokens, + "cancelled" => StopReason::Cancelled, + "error" => StopReason::Error, + "unknown" => StopReason::Unknown, + _ => StopReason::Unknown, + }) + } +} + /// Decrypted payload of a `kind:44200` Agent Turn Metric event. /// /// `harness` and `timestamp` are REQUIRED. All other fields are optional or @@ -261,4 +279,33 @@ mod tests { let back: TokenCounts = serde_json::from_str(&json).unwrap(); assert_eq!(back, counts); } + + #[test] + fn unknown_stop_reason_maps_to_unknown_not_error() { + // NIP-AM: consumers MUST treat unrecognized stopReason values as Unknown; + // the token counts remain valid and the whole payload must not be rejected. + let json = r#"{ + "harness": "goose", + "timestamp": "2026-07-01T20:11:03Z", + "stopReason": "tool_limit", + "turn": { + "inputTokens": 1234, + "outputTokens": 567, + "totalTokens": 1801, + "costUsd": null + } + }"#; + let payload: AgentTurnMetricPayload = + serde_json::from_str(json).expect("payload with future stopReason must parse"); + assert_eq!( + payload.stop_reason, + Some(StopReason::Unknown), + "unrecognized stopReason must map to Unknown" + ); + // Token counts must be preserved. + let turn = payload.turn.expect("turn must be present"); + assert_eq!(turn.input_tokens, Some(1234)); + assert_eq!(turn.output_tokens, Some(567)); + assert_eq!(turn.total_tokens, Some(1801)); + } } diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 1da402cf1..c5ff127ba 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -110,6 +110,15 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// a compile-time bitset or sorted array with binary search for hot-path use. pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER]; +/// Kinds that require a result-level read gate beyond the filter-layer +/// `#p` check: even a reader who knows an event id MUST match the event's +/// `#p` tag to receive the event. This closes the kindless `{ids:[…]}` read +/// path for events whose existence must not be leaked. +/// +/// Used by `filter_can_match_result_gated_kinds` to force the per-event +/// fallback path in COUNT rather than the fast SQL `count_events()`. +pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC]; + /// Kinds whose stored events have `#p`-bound read access — readable only by /// subscribers whose pubkey appears in the event's `#p` tag. /// diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 3882363d8..e381193df 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -708,6 +708,15 @@ pub async fn count_events( for filter in &filters { let needs_author_only_filtering = crate::handlers::req::filter_can_match_author_only_kinds(filter); + // Same result-gated guard as the WS COUNT handler: force the per-event + // fallback for filters that can match 44200 or 30622 unless #p=[self] + // is safely pushed down (existence leak otherwise). + let needs_result_gated_filtering = + crate::handlers::req::filter_can_match_result_gated_kinds(filter) + && !crate::handlers::req::result_gated_count_safe_for_pushdown( + filter, + &authed_pubkey_hex, + ); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -730,6 +739,7 @@ pub async fn count_events( }); if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) + && !needs_result_gated_filtering { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -759,6 +769,12 @@ pub async fn count_events( { continue; } + if !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) { + continue; + } total += 1; } } @@ -787,6 +803,7 @@ pub async fn count_events( }); if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) + && !needs_result_gated_filtering { query.limit = None; match state.db.count_events(&query).await { @@ -816,6 +833,12 @@ pub async fn count_events( { continue; } + if !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) { + continue; + } total += 1; } } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 7cb488218..4689826f2 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -6,7 +6,9 @@ use nostr::Filter; use tracing::warn; use crate::connection::{AuthState, ConnectionState}; -use crate::handlers::req::is_author_only_event; +use crate::handlers::req::{ + filter_can_match_result_gated_kinds, is_author_only_event, result_gated_count_safe_for_pushdown, +}; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -100,6 +102,14 @@ pub async fn handle_count( // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter); + // Determine if this filter can match result-gated kinds (44200, 30622) + // that require a per-event owner check. When the fast SQL path would + // count matching rows without calling reader_authorized_for_event, a + // non-owner learns the existence of events they are not allowed to see. + // The only safe pushdown is when #p is pinned to the authenticated + // reader's own pubkey. + let needs_result_gated_filtering = filter_can_match_result_gated_kinds(filter) + && !result_gated_count_safe_for_pushdown(filter, &authed_pubkey_hex); if let Some(ch_id) = extract_channel_from_filter(filter) { // Filter targets a specific channel — verify access. Mirrors the WS @@ -149,6 +159,7 @@ pub async fn handle_count( }); if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) + && !needs_result_gated_filtering { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -179,6 +190,12 @@ pub async fn handle_count( if is_author_only_event(&se.event, &pubkey_bytes) { continue; } + if !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) { + continue; + } total += 1; } } @@ -212,6 +229,7 @@ pub async fn handle_count( }); if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) + && !needs_result_gated_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events(&query).await { @@ -242,6 +260,12 @@ pub async fn handle_count( if is_author_only_event(&se.event, &pubkey_bytes) { continue; } + if !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) { + continue; + } total += 1; } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 7fdae503e..8fd003c97 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -8,7 +8,7 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, - P_GATED_KINDS, + P_GATED_KINDS, RESULT_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -1064,6 +1064,44 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } +/// Returns `true` if the filter CAN match result-gated kinds — meaning it +/// either has no `kinds` constraint (wildcard) or includes at least one kind +/// that carries a per-event result-level read gate (currently +/// `KIND_DM_VISIBILITY` and `KIND_AGENT_TURN_METRIC`). +/// +/// Used by the COUNT handler to force the per-event fallback path instead of +/// the fast SQL `count_events()`, which cannot enforce the owner-only result +/// gate. An existence count leaks private event activity even though no content +/// is returned, violating the NIP-AM / NIP-DM requirement that knowing an id +/// MUST NOT grant access. +pub(crate) fn filter_can_match_result_gated_kinds(filter: &Filter) -> bool { + filter.kinds.as_ref().is_none_or(|ks| { + ks.iter() + .any(|k| RESULT_GATED_KINDS.contains(&(k.as_u16() as u32))) + }) +} + +/// Returns `true` if a result-gated-kind COUNT filter can safely use the fast +/// SQL pushdown path — specifically, when the filter's `#p` tag is non-empty +/// and every entry equals the authenticated reader's pubkey. +/// +/// In that case the SQL `WHERE #p = self` pushdown scopes the query to the +/// reader's own events, so the fast path cannot leak another owner's event +/// existence. This mirrors the owner's own subscription pattern from the NIP: +/// `{kinds:[44200], #p:[self]}`. +/// +/// When this returns `false`, the COUNT handler MUST use the per-event fallback +/// and apply `reader_authorized_for_event` on each row. +pub(crate) fn result_gated_count_safe_for_pushdown( + filter: &Filter, + authed_pubkey_hex: &str, +) -> bool { + let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); + filter.generic_tags.get(&p_tag).is_some_and(|values| { + !values.is_empty() && values.iter().all(|v| v == authed_pubkey_hex) + }) +} + /// Returns `true` if the event is an author-only kind and the requester is NOT /// the author. Used as a per-event filter during historical delivery and fan-out /// to silently omit unauthorized events from mixed-kind result sets. @@ -1635,4 +1673,64 @@ mod tests { .search("x"); assert!(!p_gated_filters_authorized(&[f], &agent)); } + + // ── filter_can_match_result_gated_kinds + result_gated_count_safe_for_pushdown ── + + #[test] + fn result_gated_wildcard_filter_can_match() { + // No kinds constraint — could match anything, including 44200 / 30622. + let f = Filter::new(); + assert!(filter_can_match_result_gated_kinds(&f)); + } + + #[test] + fn result_gated_explicit_44200_can_match() { + let f = Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)); + assert!(filter_can_match_result_gated_kinds(&f)); + } + + #[test] + fn result_gated_explicit_30622_can_match() { + let f = Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_DM_VISIBILITY as u16)); + assert!(filter_can_match_result_gated_kinds(&f)); + } + + #[test] + fn result_gated_kind_9_only_cannot_match() { + let f = Filter::new().kind(nostr::Kind::TextNote); + assert!(!filter_can_match_result_gated_kinds(&f)); + } + + #[test] + fn result_gated_safe_pushdown_requires_p_self() { + let (owner, _agent, _other) = three_pubkeys(); + let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); + let f = nostr::Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)) + .custom_tags(p_tag, [owner.clone()]); + // Owner querying their own metrics — safe to push down. + assert!(result_gated_count_safe_for_pushdown(&f, &owner)); + } + + #[test] + fn result_gated_safe_pushdown_rejects_when_p_is_other() { + let (owner, _agent, other) = three_pubkeys(); + let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); + let f = nostr::Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)) + .custom_tags(p_tag, [other.clone()]); + // Authenticated as owner but #p is someone else — NOT safe. + assert!(!result_gated_count_safe_for_pushdown(&f, &owner)); + } + + #[test] + fn result_gated_safe_pushdown_rejects_when_no_p_tag() { + let (owner, _agent, _other) = three_pubkeys(); + let f = nostr::Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)); + // No #p tag — fallback required. + assert!(!result_gated_count_safe_for_pushdown(&f, &owner)); + } } From 6771cafca9ff0ade1a446e9008cf0a2203b83a01 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 19:03:02 -0400 Subject: [PATCH 4/4] chore(fmt): run rustfmt on NIP-AM kind 44200 relay changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting pass — no logic changes. Fixes just fmt-check failure in CI (Rust Lint job 84653617621). Import group reflow in agent_turn_metric.rs and line-length wrapping in ingest.rs and req.rs. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/agent_turn_metric.rs | 15 +++++----- crates/buzz-core/src/lib.rs | 4 +-- crates/buzz-relay/src/handlers/ingest.rs | 35 ++++++++++------------- crates/buzz-relay/src/handlers/req.rs | 30 ++++++++++++------- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/crates/buzz-core/src/agent_turn_metric.rs b/crates/buzz-core/src/agent_turn_metric.rs index 83565c4cd..8344fca89 100644 --- a/crates/buzz-core/src/agent_turn_metric.rs +++ b/crates/buzz-core/src/agent_turn_metric.rs @@ -9,9 +9,7 @@ use nostr::{Event, Keys, PublicKey}; use serde::{Deserialize, Serialize}; -use crate::observer::{ - decrypt_observer_payload, encrypt_observer_payload, ObserverPayloadError, -}; +use crate::observer::{decrypt_observer_payload, encrypt_observer_payload, ObserverPayloadError}; // Re-export for callers that only need the error type. pub use crate::observer::ObserverPayloadError as AgentTurnMetricError; @@ -208,8 +206,7 @@ mod tests { .sign_with_keys(&agent_keys) .expect("sign"); - let decoded = - decrypt_agent_turn_metric(&owner_keys, &event).expect("decrypt"); + let decoded = decrypt_agent_turn_metric(&owner_keys, &event).expect("decrypt"); assert_eq!(decoded, payload); } @@ -239,9 +236,11 @@ mod tests { #[test] fn delta_reliable_defaults_to_true_when_absent() { let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z"}"#; - let payload: AgentTurnMetricPayload = - serde_json::from_str(json).expect("parse"); - assert!(payload.delta_reliable, "deltaReliable should default to true"); + let payload: AgentTurnMetricPayload = serde_json::from_str(json).expect("parse"); + assert!( + payload.delta_reliable, + "deltaReliable should default to true" + ); } #[test] diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7c3a1c38d..6139ee3ae 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -5,6 +5,8 @@ //! Provides [`StoredEvent`], filter matching, kind constants, and event //! verification. All other Buzz crates depend on this one. +/// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. +pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, @@ -24,8 +26,6 @@ pub mod kind; pub mod network; /// Agent observer frame helpers. pub mod observer; -/// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. -pub mod agent_turn_metric; /// NIP-AB device pairing — crypto primitives, message types, and errors. pub mod pairing; /// Presence status types shared across crates. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 38e6ed95e..6c7e91fb7 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -13,9 +13,8 @@ use buzz_auth::Scope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, - KIND_APPROVAL_DENY, - KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, - KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, + KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, + KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, @@ -1104,7 +1103,11 @@ fn validate_agent_turn_metric_envelope(event: &nostr::Event) -> Result<(), Strin )); } let p = p_tags[0]; - if p.len() != 64 || !p.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { + if p.len() != 64 + || !p + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { return Err("agent-turn-metric `p` tag must be 64 lowercase hex chars".to_string()); } @@ -1116,14 +1119,14 @@ fn validate_agent_turn_metric_envelope(event: &nostr::Event) -> Result<(), Strin } let agent = agent_tags[0]; if agent.len() != 64 - || !agent.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + || !agent + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { return Err("agent-turn-metric `agent` tag must be 64 lowercase hex chars".to_string()); } if agent != event_pubkey_hex { - return Err( - "agent-turn-metric `agent` tag must equal event pubkey".to_string(), - ); + return Err("agent-turn-metric `agent` tag must equal event pubkey".to_string()); } // Content must look like a NIP-44 v2 ciphertext (length, base64, version prefix). @@ -3122,8 +3125,7 @@ mod tests { fn agent_turn_metric_envelope_rejects_missing_p() { let agent = nostr::Keys::generate(); let agent_hex = agent.public_key().to_hex(); - let ev = - make_agent_turn_metric(&agent, &[&["agent", &agent_hex]], &fake_nip44_v2()); + let ev = make_agent_turn_metric(&agent, &[&["agent", &agent_hex]], &fake_nip44_v2()); let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); assert!(err.contains("`p` tag"), "got: {err}"); } @@ -3132,8 +3134,7 @@ mod tests { fn agent_turn_metric_envelope_rejects_missing_agent() { let agent = nostr::Keys::generate(); let owner_hex = "b".repeat(64); - let ev = - make_agent_turn_metric(&agent, &[&["p", &owner_hex]], &fake_nip44_v2()); + let ev = make_agent_turn_metric(&agent, &[&["p", &owner_hex]], &fake_nip44_v2()); let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); assert!(err.contains("`agent` tag"), "got: {err}"); } @@ -3149,10 +3150,7 @@ mod tests { &fake_nip44_v2(), ); let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); - assert!( - err.contains("equal event pubkey"), - "got: {err}" - ); + assert!(err.contains("equal event pubkey"), "got: {err}"); } #[test] @@ -3167,9 +3165,6 @@ mod tests { ); let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); // error comes from validate_engram_nip44_content with label replaced - assert!( - err.contains("agent-turn-metric"), - "got: {err}" - ); + assert!(err.contains("agent-turn-metric"), "got: {err}"); } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 8fd003c97..1b1ce4483 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1097,9 +1097,10 @@ pub(crate) fn result_gated_count_safe_for_pushdown( authed_pubkey_hex: &str, ) -> bool { let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); - filter.generic_tags.get(&p_tag).is_some_and(|values| { - !values.is_empty() && values.iter().all(|v| v == authed_pubkey_hex) - }) + filter + .generic_tags + .get(&p_tag) + .is_some_and(|values| !values.is_empty() && values.iter().all(|v| v == authed_pubkey_hex)) } /// Returns `true` if the event is an author-only kind and the requester is NOT @@ -1685,15 +1686,17 @@ mod tests { #[test] fn result_gated_explicit_44200_can_match() { - let f = Filter::new() - .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)); + let f = Filter::new().kind(nostr::Kind::Custom( + buzz_core::kind::KIND_AGENT_TURN_METRIC as u16, + )); assert!(filter_can_match_result_gated_kinds(&f)); } #[test] fn result_gated_explicit_30622_can_match() { - let f = Filter::new() - .kind(nostr::Kind::Custom(buzz_core::kind::KIND_DM_VISIBILITY as u16)); + let f = Filter::new().kind(nostr::Kind::Custom( + buzz_core::kind::KIND_DM_VISIBILITY as u16, + )); assert!(filter_can_match_result_gated_kinds(&f)); } @@ -1708,7 +1711,9 @@ mod tests { let (owner, _agent, _other) = three_pubkeys(); let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); let f = nostr::Filter::new() - .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)) + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_AGENT_TURN_METRIC as u16, + )) .custom_tags(p_tag, [owner.clone()]); // Owner querying their own metrics — safe to push down. assert!(result_gated_count_safe_for_pushdown(&f, &owner)); @@ -1719,7 +1724,9 @@ mod tests { let (owner, _agent, other) = three_pubkeys(); let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); let f = nostr::Filter::new() - .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)) + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_AGENT_TURN_METRIC as u16, + )) .custom_tags(p_tag, [other.clone()]); // Authenticated as owner but #p is someone else — NOT safe. assert!(!result_gated_count_safe_for_pushdown(&f, &owner)); @@ -1728,8 +1735,9 @@ mod tests { #[test] fn result_gated_safe_pushdown_rejects_when_no_p_tag() { let (owner, _agent, _other) = three_pubkeys(); - let f = nostr::Filter::new() - .kind(nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16)); + let f = nostr::Filter::new().kind(nostr::Kind::Custom( + buzz_core::kind::KIND_AGENT_TURN_METRIC as u16, + )); // No #p tag — fallback required. assert!(!result_gated_count_safe_for_pushdown(&f, &owner)); }