Skip to content

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

Draft
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/nip-am-kind-relay
Draft

feat(core/relay): add NIP-AM kind 44200 (agent turn metrics) with relay plumbing#1445
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/nip-am-kind-relay

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the relay-side infrastructure for NIP-AM agent turn metrics (kind 44200), as specified in docs/nips/NIP-AM.md (PR #1441, Thufir-cleared). This is Task A of Phase 2; Task B (goose adapter, buzz-acp) is a separate parallel PR.

No emit logic, no adapters. This PR lands the kind, its full read-gate enforcement (filter-level and result-level), storage/search handling, payload type, COUNT existence-leak closure, and StopReason forward-compatibility.

Changes

crates/buzz-core/src/kind.rs

  • Add KIND_AGENT_TURN_METRIC = 44200 constant referencing NIP-AM
  • Add to P_GATED_KINDS and ALL_KINDS
  • Add RESULT_GATED_KINDS = [KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC] — kinds requiring per-event owner verification in COUNT queries
  • Compile-time asserts: not ephemeral, not replaceable, fits u16

crates/buzz-core/src/agent_turn_metric.rs (new)

  • AgentTurnMetricPayload struct matching NIP-AM schema: harness/timestamp required; nullable turn/cumulative TokenCounts; sessionId+turnSeq required when cumulative present; deltaReliable defaulting true; StopReason enum
  • Custom Deserialize for StopReason: unrecognized values map to Unknown (NIP-AM forward-compat requirement — consumers must not reject future stop reasons)
  • encrypt_agent_turn_metric / decrypt_agent_turn_metric helpers
  • Tests: round-trip, wrong-key, null-field, stop-reason serialization, unknown-stop-reason maps to Unknown with token counts intact

crates/buzz-core/src/filter.rs

  • reader_authorized_for_event extended to gate KIND_AGENT_TURN_METRIC alongside KIND_DM_VISIBILITY — reader must match the #p tag. Closes all kindless-ids retrieval paths (WS historical, HTTP bridge, live fan-out).
  • New test: owner allow, non-owner deny, authoring-agent deny

crates/buzz-relay/src/handlers/event.rs

  • Live fan-out owner-only carve-out extended to cover kind 44200 (renamed private_event_owner, covers both 30622 and 44200)

crates/buzz-relay/src/handlers/req.rs

  • p_gated_filters_authorized: deny ids-filter exemption for kind 44200
  • filter_can_match_result_gated_kinds(): returns true when filter has no kinds constraint or includes a result-gated kind
  • result_gated_count_safe_for_pushdown(): safe to use fast SQL COUNT only when filter's #p is non-empty and all values equal the authenticated reader's pubkey
  • 6 unit tests covering wildcard/explicit/safe-pushdown/unsafe cases

crates/buzz-relay/src/handlers/count.rs (WS COUNT)

  • Both fast-path conditions guarded by !needs_result_gated_filtering
  • Both fallback loops apply reader_authorized_for_event per event
  • Closes COUNT existence-leak for both 44200 and 30622

crates/buzz-relay/src/api/bridge.rs (HTTP COUNT)

  • Same guard pattern as count.rs — both fast-path conditions and both fallback loops

crates/buzz-relay/src/handlers/ingest.rs

  • validate_agent_turn_metric_envelope: exactly-one-p, exactly-one-agent = event.pubkey, no h tag, NIP-44 content
  • Async is_agent_owner ownership check
  • required_scope_for_kindMessagesWrite for 44200
  • is_global_only_kind includes 44200

migrations/0001_initial_schema.sql + schema/schema.sql

  • Add 44200 to the NULL tsvector CASE — encrypted ciphertext must not enter FTS indexes

Read-gate coverage

Path Gate
{kinds:[44200], ids:[...]} filter p_gated_filters_authorized carve-out (req.rs)
Kindless {ids:[...]} historical pull (WS + HTTP bridge) reader_authorized_for_event (filter.rs)
Kindless {ids:[...]} live subscription private_event_owner fan-out carve-out (event.rs)
COUNT queries (WS + HTTP) result_gated_count_safe_for_pushdown + per-event fallback (count.rs, bridge.rs)
Search discovery NULL tsvector storage

Related

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 1, 2026 16:51
…ay 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>
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 19889ba (PR #1441).
Resolves blocking gap from PR #1445 review.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 1, 2026 18:47
…pat for NIP-AM

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant