From 72c2903c5b370aaec6480eb4ca0e656955f82522 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 17:46:28 -0400 Subject: [PATCH] feat(multiverse): add private immutable lifecycle transport Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_lifecycle.rs | 195 ++++++++++++++++++ crates/buzz-core/src/desktop_stop.rs | 17 +- crates/buzz-core/src/kind.rs | 8 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 29 ++- .../src/api/desktop_profile_postgres_tests.rs | 58 +++++- crates/buzz-relay/src/handlers/ingest.rs | 21 +- migrations/0049_desktop_lifecycle_fts.sql | 26 +++ schema/schema.sql | 2 +- 9 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 crates/buzz-core/src/desktop_lifecycle.rs create mode 100644 migrations/0049_desktop_lifecycle_fts.sql diff --git a/crates/buzz-core/src/desktop_lifecycle.rs b/crates/buzz-core/src/desktop_lifecycle.rs new file mode 100644 index 00000000000..c437830f4aa --- /dev/null +++ b/crates/buzz-core/src/desktop_lifecycle.rs @@ -0,0 +1,195 @@ +//! Owner-private lifecycle requests. Signed order is intent, not process state. +use crate::{ + desktop_stop::{hex, read, sign, StopTarget}, + kind::{KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT}, +}; +use nostr::{Event, Keys, Tag}; +use serde::{Deserialize, Serialize}; + +/// Start chooses a destination. Restart is a current-host-only one-shot. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Explicit ensure-running, without a remote reachability gate. + Start, + /// Ordinary Stop then one fresh launch, only on the resolved current host. + Restart, + /// Read actual local process status; never starts or stops anything. + Status, +} + +/// Immutable request; retries retain its exact signed bytes. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Request { + /// Existing owner/community/agent/Desktop target shape. + pub target: StopTarget, + /// Requested operation, never shell text or configuration. + pub action: Action, + /// Restart's fresh successful Status request ID. None for other actions. + pub observed: Option, +} + +/// No credentials, paths, PIDs or raw runtime errors on the wire. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// Ordinary process registration/actual status confirms running locally. + Running, + /// Actual status confirms no managed process at this target. + Stopped, + /// Destination-local broker session issuance is not available. + ProvisioningUnavailable, + /// Runtime/readiness/ownership rejected the request. + Failed, + /// Superseded, interrupted, evicted or uncertain; never success. + Unknown, +} + +/// Signed Desktop outcome, not agent-signed termination proof. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ResultMessage { + /// Original immutable payload. + pub request: Request, + /// Original signed event identity. + pub id: String, + /// Local Desktop result. + pub outcome: Outcome, +} + +/// Public envelope gate before persistence; content remains owner encrypted. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + let kind = event.kind.as_u16() as u32; + let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect(); + let result = kind == KIND_DESKTOP_LIFECYCLE_RESULT; + if !matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) + || !(132..=4096).contains(&event.content.len()) + || tags.len() != if result { 2 } else { 1 } + || tags[0].len() != 2 + || tags[0][0] != "d" + || !hex(&tags[0][1], 32) + || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64))) + { + return Err("invalid Desktop lifecycle envelope"); + } + Ok(()) +} + +impl Request { + /// Validate target, action and correlation without inventing credentials. + pub fn validate(&self, community: &str) -> Result<(), String> { + self.target.validate(community)?; + match (self.action, &self.observed) { + (Action::Restart, Some(id)) if hex(id, 64) => Ok(()), + (Action::Start | Action::Status, None) => Ok(()), + _ => Err("invalid Desktop lifecycle observation".into()), + } + } + /// Prepare once; retries must not create a new event/order. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate(&self.target.community)?; + sign( + self, + keys, + KIND_DESKTOP_LIFECYCLE, + vec![Tag::identifier(&self.target.desktop)], + ) + } + /// Authenticate owner, content, routing and captured community. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE)?; + value.validate(community)?; + if event.tags.identifier() != Some(value.target.desktop.as_str()) { + return Err("Desktop lifecycle routing mismatch".into()); + } + Ok(value) + } +} +impl ResultMessage { + /// Sign the actual Desktop result. It is immutable for this request. + pub fn sign(&self, keys: &Keys) -> Result { + self.request.validate(&self.request.target.community)?; + if !hex(&self.id, 64) { + return Err("invalid lifecycle request ID".into()); + } + sign( + self, + keys, + KIND_DESKTOP_LIFECYCLE_RESULT, + vec![ + Tag::identifier(&self.request.target.desktop), + Tag::parse(["e", &self.id]).map_err(|e| e.to_string())?, + ], + ) + } + /// Bind every correlation field to the original authenticated request. + pub fn read( + event: &Event, + keys: &Keys, + request: &Event, + community: &str, + ) -> Result { + let original = Request::read(request, keys, community)?; + let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE_RESULT)?; + if value.request != original + || value.id != request.id.to_hex() + || event.tags.identifier() != Some(original.target.desktop.as_str()) + || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(value.id.as_str()) + { + return Err("Desktop lifecycle result mismatch".into()); + } + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn scope_action_and_result_are_bound_to_one_signed_request() { + let keys = Keys::generate(); + let mut request = Request { + target: StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }, + action: Action::Start, + observed: None, + }; + let event = request.sign(&keys).unwrap(); + assert_eq!( + Request::read(&event, &keys, &request.target.community).unwrap(), + request + ); + assert!(Request::read(&event, &Keys::generate(), &request.target.community).is_err()); + assert!(Request::read(&event, &keys, "wss://other.example").is_err()); + assert!( + crate::desktop_stop::StopTarget::read(&event, &keys, &request.target.community) + .is_err() + ); + let result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::ProvisioningUnavailable, + } + .sign(&keys) + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &event, &request.target.community) + .unwrap() + .outcome, + Outcome::ProvisioningUnavailable + ); + let other = request.sign(&keys).unwrap(); + assert!(ResultMessage::read(&result, &keys, &other, &request.target.community).is_err()); + request.action = Action::Restart; + assert!(request.sign(&keys).is_err()); + request.observed = Some(event.id.to_hex()); + assert!(request.sign(&keys).is_ok()); + request.action = Action::Start; + assert!(request.sign(&keys).is_err()); + } +} diff --git a/crates/buzz-core/src/desktop_stop.rs b/crates/buzz-core/src/desktop_stop.rs index 4f431655e99..9291c323c8b 100644 --- a/crates/buzz-core/src/desktop_stop.rs +++ b/crates/buzz-core/src/desktop_stop.rs @@ -42,7 +42,7 @@ pub struct StopResult { pub outcome: StopOutcome, } -fn hex(value: &str, len: usize) -> bool { +pub(crate) fn hex(value: &str, len: usize) -> bool { value.len() == len && value .bytes() @@ -67,7 +67,12 @@ pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { Ok(()) } -fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Result { +pub(crate) fn sign( + value: &T, + keys: &Keys, + kind: u32, + tags: Vec, +) -> Result { let ciphertext = nip44::encrypt( keys.secret_key(), &keys.public_key(), @@ -81,12 +86,16 @@ fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Resu .map_err(|e| e.to_string()) } -fn read( +pub(crate) fn read( event: &Event, keys: &Keys, kind: u32, ) -> Result { - validate_envelope(event)?; + if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + validate_envelope(event)?; + } else { + crate::desktop_lifecycle::validate_envelope(event)?; + } event .verify() .map_err(|_| "invalid Desktop Stop signature")?; diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 5df974c36cb..3146c511a28 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -129,6 +129,10 @@ pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182; pub const KIND_DESKTOP_STOP: u32 = 50180; /// Owner-private ordinary Desktop Stop outcome, correlated by request event ID. pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181; +/// Owner-private Start/Restart/status request, separate from legacy Stop. +pub const KIND_DESKTOP_LIFECYCLE: u32 = 50182; +/// Correlated owner-private Desktop lifecycle result. +pub const KIND_DESKTOP_LIFECYCLE_RESULT: u32 = 50183; /// Kinds whose stored events are readable only by their author. /// @@ -148,6 +152,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_LIFECYCLE, + KIND_DESKTOP_LIFECYCLE_RESULT, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -682,6 +688,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_LIFECYCLE, + KIND_DESKTOP_LIFECYCLE_RESULT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 4b3874be0f0..d80446ac35e 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; pub mod desktop_capabilities; +pub mod desktop_lifecycle; pub mod desktop_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 35261123b61..8d2bf6ea697 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 48); + assert_eq!(migrations.len(), 49); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -911,7 +911,7 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); assert!(include_str!("../../../../schema/schema.sql").contains( - "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)" + "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183)" )); // Public push-gateway authority is intentionally deployment-global and @@ -2396,6 +2396,8 @@ mod postgres_tests { (6_u8, 30_182_i32), (7_u8, 50_180_i32), (8_u8, 50_181_i32), + (9_u8, 50_182_i32), + (10_u8, 50_183_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2433,7 +2435,9 @@ mod postgres_tests { (30_182, true), (30_350, true), (50_180, true), - (50_181, true) + (50_181, true), + (50_182, true), + (50_183, true) ] ); @@ -2460,7 +2464,9 @@ mod postgres_tests { (30_182, Some(true)), (30_350, None), (50_180, Some(true)), - (50_181, Some(true)) + (50_181, Some(true)), + (50_182, Some(true)), + (50_183, Some(true)) ] ); @@ -2507,6 +2513,17 @@ mod postgres_tests { .await .unwrap(); assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS"); + run_migrations_through(&pool, 48).await.unwrap(); + let lifecycle_indexed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE kind IN (50182, 50183) AND search_tsv IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + lifecycle_indexed, 2, + "0049 must change populated lifecycle FTS" + ); run_migrations(&pool) .await @@ -2528,7 +2545,9 @@ mod postgres_tests { (30_182, None), (30_350, None), (50_180, None), - (50_181, None) + (50_181, None), + (50_182, None), + (50_183, None) ] ); let gin_exists: bool = sqlx::query_scalar( diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index ac35a0dd12e..29b21068d37 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -3,8 +3,8 @@ use super::postgres_tests::bridge_handler_test_state; use super::*; use axum::{body::Body, http::Request}; use buzz_core::kind::{ - KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, - KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT, + KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, }; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; @@ -92,6 +92,13 @@ async fn desktop_stop_authenticated_owner_query_and_private_storage() { assert_private_desktop(KIND_DESKTOP_STOP_RESULT).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_lifecycle_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_LIFECYCLE).await; + assert_private_desktop(KIND_DESKTOP_LIFECYCLE_RESULT).await; +} + async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await @@ -132,6 +139,29 @@ async fn assert_private_desktop(kind: u32) { .sign(&owner) .unwrap() } + } else if matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) { + let request = buzz_core::desktop_lifecycle::Request { + target: buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: id.clone(), + agent: Keys::generate().public_key().to_hex(), + }, + action: buzz_core::desktop_lifecycle::Action::Start, + observed: None, + }; + let event = request.sign(&owner).unwrap(); + if kind == KIND_DESKTOP_LIFECYCLE { + event + } else { + buzz_core::desktop_lifecycle::ResultMessage { + request, + id: event.id.to_hex(), + outcome: buzz_core::desktop_lifecycle::Outcome::Running, + } + .sign(&owner) + .unwrap() + } } else if kind == KIND_DESKTOP_PROFILE { profile.sign(&owner).unwrap() } else if kind == KIND_DESKTOP_CAPABILITIES { @@ -320,6 +350,14 @@ async fn aged_desktop_profile_retries_through_production_ingest_without_resignin #[tokio::test] #[ignore = "requires Postgres and Redis"] async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { + assert_retry(KIND_DESKTOP_STOP).await; +} +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn desktop_lifecycle_retry_redelivers_exact_event_only_to_owner() { + assert_retry(KIND_DESKTOP_LIFECYCLE).await; +} +async fn assert_retry(kind: u32) { use nostr::Filter; use std::sync::atomic::AtomicU8; use tokio::sync::{mpsc, Mutex}; @@ -343,7 +381,17 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { desktop: uuid::Uuid::new_v4().simple().to_string(), agent: Keys::generate().public_key().to_hex(), }; - let prepared = target.sign(&owner).unwrap(); + let prepared = if kind == KIND_DESKTOP_STOP { + target.sign(&owner).unwrap() + } else { + buzz_core::desktop_lifecycle::Request { + target, + action: buzz_core::desktop_lifecycle::Action::Start, + observed: None, + } + .sign(&owner) + .unwrap() + }; let event = EventBuilder::new(prepared.kind, &prepared.content) .tags(prepared.tags.iter().cloned()) .custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 86_400)) @@ -383,7 +431,7 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { tenant, conn, "stop".into(), - vec![Filter::new().kind(Kind::Custom(KIND_DESKTOP_STOP as u16))], + vec![Filter::new().kind(Kind::Custom(kind as u16))], None, ); receivers.push(rx); @@ -407,6 +455,6 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); assert!(drain(&mut receivers[0]).is_empty()); let (_, rows) = post(&state, &host, "/query", &owner, - json!([{"kinds":[KIND_DESKTOP_STOP],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await; + json!([{"kinds":[kind],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await; assert_eq!(rows.as_array().unwrap().len(), 1); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 112bea02085..88d2d43ed4e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -37,8 +37,8 @@ use buzz_core::kind::{ RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::kind::{ - KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, - KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT, + KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -440,7 +440,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { - KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT => Ok(Scope::UsersWrite), 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 @@ -665,7 +665,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP - | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT | 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). @@ -2189,6 +2189,8 @@ fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_LIFECYCLE + | KIND_DESKTOP_LIFECYCLE_RESULT ) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) } @@ -2804,6 +2806,13 @@ async fn ingest_event_inner( } } + if matches!( + kind_u32, + KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT + ) { + buzz_core::desktop_lifecycle::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { buzz_core::desktop_stop::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3250,7 +3259,7 @@ async fn ingest_event_inner( // Stop is a one-shot owned by Desktop, not a replaceable projection. // Explicit transport retry must reach a live receiver even after an ACK // or its result was lost. Never replay history or repeat relay effects. - if kind_u32 == KIND_DESKTOP_STOP { + if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_LIFECYCLE) { super::event::redeliver_desktop_stop(tenant, state, &stored_event.event).await; } return Ok(IngestResult { @@ -3391,6 +3400,8 @@ mod postgres_tests { | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_LIFECYCLE + | KIND_DESKTOP_LIFECYCLE_RESULT ) { profile } else { diff --git a/migrations/0049_desktop_lifecycle_fts.sql b/migrations/0049_desktop_lifecycle_fts.sql new file mode 100644 index 00000000000..ad42a795a2e --- /dev/null +++ b/migrations/0049_desktop_lifecycle_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop lifecycle requests and results must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + 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'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (50182, 50183) THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 0a20e940285..03606b1645f 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,