feat(core/relay): add NIP-AM kind 44200 (agent turn metrics) with relay plumbing

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-01 16:51:57 -04:00
parent fec7684366
commit b7480e875c
7 changed files with 608 additions and 11 deletions

View File

@@ -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<u64>,
/// Output tokens.
pub output_tokens: Option<u64>,
/// Provider-reported total — NOT derived by summing input + output.
/// `None` when the provider did not report a total.
pub total_tokens: Option<u64>,
/// Estimated cost in USD. Must be finite and non-negative when present.
pub cost_usd: Option<f64>,
/// Informational: cache-read tokens included in `input_tokens`.
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u64>,
/// Informational: cache-write tokens included in `input_tokens`.
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<u64>,
}
/// 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<String>,
/// Channel UUID the turn served, encrypted inside the payload.
pub channel_id: Option<String>,
/// Session identifier. REQUIRED when `cumulative` is present.
pub session_id: Option<String>,
/// Turn identifier (harness-internal).
pub turn_id: Option<String>,
/// 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<u64>,
/// RFC 3339 timestamp (end-of-turn). REQUIRED.
pub timestamp: String,
/// Usage for this turn (computed delta). Null fields mean not reported.
pub turn: Option<TokenCounts>,
/// Session-cumulative usage as reported at end of this turn.
pub cumulative: Option<TokenCounts>,
/// `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<StopReason>,
}
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<String, ObserverPayloadError> {
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<AgentTurnMetricPayload, ObserverPayloadError> {
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);
}
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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<Scope, &'static s
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT => {
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<nostr::Tag> = 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}"
);
}
}

View File

@@ -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");

View File

@@ -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,

View File

@@ -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,