feat(relay): accept kind:30179 private managed-agent events at ingest (#5133)

## What

Relay-only carve-out of the ingest half of #4999: generic EVENT ingest
now accepts kind:30179 (NIP-PMA private managed-agent config). One file,
`crates/buzz-relay/src/handlers/ingest.rs`, 16 insertions / 15
deletions; **two semantic lines**, byte-identical to the ingest hunk of
#4999 at `6f486e88`:

1. `required_scope_for_kind`: 30179 requires `Scope::UsersWrite` — same
arm as its public sibling 30177 and the other owner-authored NIP-AP
kinds.
2. `is_global_only_kind`: 30179 is owner-global, keyed `(pubkey, kind,
d-tag)`; a stray `h` tag must not channel-scope it.

The rest is import reflow plus replacing the guard test with a positive
one (`private_managed_agent_kind_is_owner_scoped_global_user_data`:
asserts UsersWrite scope, global-only, no h-channel scope).

## Why the guard test can be retired

The removed test
(`private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists`)
pinned a stated precondition: *"must not enter generic EVENT ingest
before privacy and aggregate CAS deploy."* Both halves are resolved:

- **Privacy** — the author-only read gates for 30179 shipped to main
with #4593: `AUTHOR_ONLY_KINDS` membership, `req.rs` pre-filter + result
gates, `count.rs`, `event.rs` fanout, and the bridge pre-filter
(`bridge.rs:999-1000` returns `restricted: author-only kinds require
authors=[self]` / 403). Only the author can read the event back.
- **Aggregate CAS** — #4999 settled generation as **advisory**: the `g`
tag is shape-validated, never relay-enforced. Last-write-wins per
coordinate is the contract of record (see the kind:30179 contract blurb
in #4999), so no CAS mechanism is pending on the relay side.

## Why this is inert to existing relays and clients

- No production desktop code on main authors kind:30179 — the codec
(`private_managed_agent.rs`) has zero non-test callers. This PR accepts
a kind nobody can produce yet.
- Content is opaque NIP-44 ciphertext to the relay; the relay never
decrypts it.
- Reads remain author-only via the already-shipped gates above.
- Storage is the standard parameterized-replaceable path already
exercised by kinds 30175–30178. No schema, config, or migration changes.

## Testing

- Full `buzz-relay` package suite at this commit: 859 passed, 1 failed —
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504
vs 200), which **reproduces identically on clean main `769ac70b`** with
this change stashed; pre-existing/environmental, not introduced here.
- New positive ingest test passes.
- Pre-push hooks green (branch-skew, rust-tests, desktop-tauri-checks).

## Relationship to #4999

#4999 (relay-primary agent config, desktop half) stays DO-NOT-MERGE
pending live relay receipts + real CI; once this lands and deploys, its
live test simplifies to plain `desktop-standalone` against the real
relay, and #4999 rebases to drop its now-duplicate ingest hunk
(identical bytes → trivial rebase).

Originating thread:
buzz://message?channel=06f13ed3-0557-4ac2-922c-1545dd00bf97&id=2a43b3b4933a2ea78b77088619251c061355f9b7b6dc29ea0d702193f2344149


## Brownfield FTS note (review findings, operator-ruled non-blocking for
this PR)

Max and Sami independently identified that the FTS privacy skip-set is
regime-dependent: migration 0008 installs the positive allowlist (`kind
IN (0, 9, 40002, 45001, 45003)`) **only on an empty events table**; an
already-populated database keeps the 0001/0005 negative skip-list
(wrapped by 0014 to add 30350), which omits 30179 — so on such an
installation this PR admits 30179 rows whose NIP-44 ciphertext gets
indexed by `to_tsvector`. Sami measured both regimes against real
Postgres (brownfield: 30179 INDEXED; fresh: NULL) and demonstrated the
existing drift test only exercises the fresh regime.
`schema/schema.sql:222`'s canonical literal is also the negative list
and omits 30179. Migration dates put any relay deployed with data before
0008 landed (2026-07-13) in the brownfield class.

**Scope of exposure (Sami's trace):** not a content leak —
`event_visible_to_reader` / `is_author_only_event` gates hold on both
search surfaces (`req.rs:725`, `bridge.rs:1770`), so foreign readers
receive nothing. Lost is the storage-level NULL-tsv backstop plus FTS
page budget burned on post-filtered hits.

**Operator ruling (Tyler, events `1472e5b6`, `cbd368ed`):** ship this PR
without an exclusion migration. Safety argument that makes this sound
rather than merely accepted: main has **zero non-test 30179 writers**
until #4999's desktop half deploys — no 30179 rows can exist, so nothing
can be indexed in any regime while this PR is the only half live.



**Additional review characterizations (Sami, non-blocking, on the
record):**
- *Behavioral delta enumerated:* routing triple
(`required_scope_for_kind` / `is_global_only_kind` /
`requires_h_channel_scope`) compared for all 65,536 kinds at base
`769ac70b` vs head `77eeba6e` — exactly one row differs (30179). No
other kind or client changes behavior.
- *"SQL visibility before LIMIT" (NIP-PMA step 2):* no
`AUTHOR_ONLY_KINDS` pushdown clause exists in `buzz-db` (only
`SHARED_GATED_KINDS` has one). Author-only kinds are protected by the
pre-filter (`author_only_filters_authorized`) plus post-filter omission;
mixed-kind filters can burn candidate-page budget on discarded rows.
Pre-existing and identical for 30300/30350 — not introduced here; noted
so the NIP's step-2 checkbox is not read as fully ticked.
- *Envelope validation gap:* 30179 is the only parameterized-replaceable
kind at ingest with no per-kind envelope validator (codec grammar checks
run in the desktop writer, not the relay). Generic limits only (256 KiB,
±15 min, pubkey==identity, d-tag bound). Self-inflicted footgun bounded
to the author's own coordinate — candidate companion to the exclusion
migration in the #4999 rebase, deliberately not added here.

**Bound follow-up (required before/with the #4999 desktop half):** a
0014-shape additive migration (`pg_get_expr` capture + `CASE WHEN kind =
30179 THEN NULL ELSE (<existing>) END` wrap), add 30179 to the
`schema/schema.sql:221` literal, and a brownfield-regime variant of the
FTS drift test, per Sami's finding. Deploy-time spot check if ever
wanted: `SELECT pg_get_expr(d.adbin, d.adrelid) FROM pg_attrdef d JOIN
pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum WHERE
d.adrelid = 'events'::regclass AND a.attname = 'search_tsv';`

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-08-06 22:26:09 -04:00
committed by GitHub
parent 769ac70b74
commit ad923353a2

View File

@@ -28,12 +28,13 @@ use buzz_core::kind::{
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION,
KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM,
KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
@@ -263,7 +264,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
| KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => {
| KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => {
Ok(Scope::UsersWrite)
}
// NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner).
@@ -476,6 +477,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
// (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
| KIND_TEAM
| KIND_MANAGED_AGENT
| KIND_PRIVATE_MANAGED_AGENT
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
@@ -3338,15 +3340,14 @@ mod tests {
}
#[test]
fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() {
assert!(
required_scope_for_kind(
buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT,
&make_dummy_event(),
)
.is_err(),
"kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy"
fn private_managed_agent_kind_is_owner_scoped_global_user_data() {
let event = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_PRIVATE_MANAGED_AGENT, &event),
Ok(Scope::UsersWrite)
);
assert!(is_global_only_kind(KIND_PRIVATE_MANAGED_AGENT));
assert!(!requires_h_channel_scope(KIND_PRIVATE_MANAGED_AGENT));
}
#[test]