From 839c4b637b30fc2142f027c7e0ffcbb56e65dd2f Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Mon, 31 Aug 2026 17:10:20 -0700 Subject: [PATCH] refactor(accumulator): cut the relay-kinds + CLI path; daemon is the only door Riley-approved parsimony pass now that the standalone daemon supersedes the relay-event path: - Remove kinds 30640/4640 end to end: kind.rs constants/allowlists/ asserts, ingest.rs scope arms, migration 0041 + schema.sql FTS exclusion + migrator expectations (back to 40), and the entire 'buzz folds' CLI group with its buzz-accumulator dependency. The branch now touches ONLY crates/buzz-accumulator plus the workspace manifests - nothing for the team relay to redeploy. - Estimator keeps tokens, loses dollars: est_cost_usd and the curated PRICING table are gone (a 3-row USD table ignoring output tokens is false precision that goes silently stale); estimate_tokens + window fit stay so the UI can still gate a priced Run button. - Strict citation grammar: a bracket is a citation iff its content is exactly one id; the multiple-of-64 hex-run splitting is deleted and a jammed multi-id bracket is now refused loudly. - Collapse the one-entry schema registry: BUILTIN_SCHEMAS/builtin() and the duplicate unknown-schema checks in plan_run/complete_run are gone; FoldSpec::validate is the single seam and the schema string field stays as the versioning hook. - Doc honesty for the SQLite reality: MAX_PRIOR_OUTPUT_BYTES is a model-input budget (value unchanged), artifacts persist locally instead of as NIP-44 relay events, and the daemon docs state the HTTP API is loopback-only by design - /run spends money, so any non-loopback bind must add auth first. Co-Authored-By: Claude Fable 5 Signed-off-by: Riley Crane --- Cargo.lock | 1 - Cargo.toml | 1 - crates/buzz-accumulator/src/artifact.rs | 8 +- crates/buzz-accumulator/src/daemon/folds.rs | 2 +- crates/buzz-accumulator/src/daemon/http.rs | 2 +- crates/buzz-accumulator/src/daemon/mod.rs | 8 +- crates/buzz-accumulator/src/estimate.rs | 29 +- crates/buzz-accumulator/src/lib.rs | 5 +- crates/buzz-accumulator/src/run.rs | 33 +- crates/buzz-accumulator/src/schema.rs | 26 +- crates/buzz-accumulator/src/spec.rs | 19 +- crates/buzz-accumulator/src/validate.rs | 58 +- crates/buzz-cli/Cargo.toml | 1 - crates/buzz-cli/src/commands/folds.rs | 899 -------------------- crates/buzz-cli/src/commands/mod.rs | 1 - crates/buzz-cli/src/lib.rs | 91 -- crates/buzz-core/src/kind.rs | 29 - crates/buzz-db/src/runtime/migration.rs | 18 +- crates/buzz-relay/src/handlers/ingest.rs | 51 +- migrations/0045_fold_kinds_fts.sql | 39 - schema/schema.sql | 4 +- 21 files changed, 94 insertions(+), 1231 deletions(-) delete mode 100644 crates/buzz-cli/src/commands/folds.rs delete mode 100644 migrations/0045_fold_kinds_fts.sql diff --git a/Cargo.lock b/Cargo.lock index 9d51735743..b556406971 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1008,7 +1008,6 @@ version = "0.1.0" dependencies = [ "axum", "base64 0.22.1", - "buzz-accumulator", "buzz-core", "buzz-persona", "buzz-sdk", diff --git a/Cargo.toml b/Cargo.toml index 141e35b4b9..eb40077802 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,7 +138,6 @@ schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } -buzz-accumulator = { path = "crates/buzz-accumulator", default-features = false } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-deletion = { path = "crates/buzz-deletion" } diff --git a/crates/buzz-accumulator/src/artifact.rs b/crates/buzz-accumulator/src/artifact.rs index 9963f14972..01bb484bf4 100644 --- a/crates/buzz-accumulator/src/artifact.rs +++ b/crates/buzz-accumulator/src/artifact.rs @@ -4,15 +4,15 @@ use serde::{Deserialize, Serialize}; use crate::selection::Selection; -/// One artifact version — the JSON a caller NIP-44-encrypts into the content -/// of an immutable relay event. +/// One artifact version — the JSON the caller persists immutably (the daemon +/// stores it in SQLite under a `(fold, version)` primary key). /// /// Versions form a chain per fold (v1, v2, …), each produced by /// [`crate::run::complete_run`]. The chain is the coverage ledger: the set of /// signals a fold has ever folded is exactly the union of `shown_ids` over /// its versions — computed from what the model was actually shown, so unread -/// signals are never sealed as covered. Append-only is physics here: relay -/// events cannot be rewritten. +/// signals are never sealed as covered. Versions are never updated or +/// deleted, only appended. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ArtifactPayload { /// Fold name this version belongs to. diff --git a/crates/buzz-accumulator/src/daemon/folds.rs b/crates/buzz-accumulator/src/daemon/folds.rs index 257b576a2a..77c9248ce9 100644 --- a/crates/buzz-accumulator/src/daemon/folds.rs +++ b/crates/buzz-accumulator/src/daemon/folds.rs @@ -79,7 +79,7 @@ pub enum PreflightOutcome { pending: usize, /// Whether the window would be chunked. truncated: bool, - /// Zero-spend estimate (tokens; cost only for curated models). + /// Zero-spend input-size estimate (tokens + window fit). estimate: crate::estimate::Estimate, /// Window actually queried, `[since, until_exclusive)`. window: (i64, i64), diff --git a/crates/buzz-accumulator/src/daemon/http.rs b/crates/buzz-accumulator/src/daemon/http.rs index 79f3fb55f1..d4df126a90 100644 --- a/crates/buzz-accumulator/src/daemon/http.rs +++ b/crates/buzz-accumulator/src/daemon/http.rs @@ -185,7 +185,7 @@ struct PutFoldBody { model: String, /// Defaults to the built-in channel digest prompt. instructions: Option, - /// Defaults to `channel-digest@v1` (the only built-in schema). + /// Defaults to `channel-digest@v1` (the only schema). schema: Option, } diff --git a/crates/buzz-accumulator/src/daemon/mod.rs b/crates/buzz-accumulator/src/daemon/mod.rs index a6e34eb453..1f8164ffde 100644 --- a/crates/buzz-accumulator/src/daemon/mod.rs +++ b/crates/buzz-accumulator/src/daemon/mod.rs @@ -7,6 +7,10 @@ //! - [`http`] — loopback HTTP API exposing status and the fold machinery to //! an external client (the future standalone UI). //! +//! The HTTP API is **loopback-only by design** and completely unauthenticated: +//! `POST /folds/{name}/run` spends real money, so any future non-loopback +//! bind must add authentication first. +//! //! Run it with `cargo run -p buzz-accumulator`. pub mod folds; @@ -63,7 +67,9 @@ pub struct Config { #[arg(long, env = "BUZZ_ACCUMULATOR_DB")] pub db: Option, - /// Loopback address for the HTTP status/machinery API. + /// Loopback address for the HTTP status/machinery API. The API is + /// unauthenticated and `/run` spends money — do not bind non-loopback + /// without adding auth first. #[arg( long, env = "BUZZ_ACCUMULATOR_HTTP_ADDR", diff --git a/crates/buzz-accumulator/src/estimate.rs b/crates/buzz-accumulator/src/estimate.rs index f4275b1f09..3ddb0b44c1 100644 --- a/crates/buzz-accumulator/src/estimate.rs +++ b/crates/buzz-accumulator/src/estimate.rs @@ -1,16 +1,15 @@ -//! Zero-spend price estimates and context-window fit. +//! Zero-spend input-size estimates and context-window fit. //! //! Estimates never call a model — by construction, not by discipline: this -//! module has no runner access. Prices and context windows are curated rather -//! than discovered; an unknown model's cost deliberately remains `None` -//! instead of becoming false precision. +//! module has no runner access. Windows are curated rather than discovered; +//! an unknown model's fit deliberately remains `None` instead of becoming +//! false precision. Deliberately no dollar figures: a curated USD table that +//! ignores output tokens goes silently stale — a UI can price tokens × rate +//! client-side if it wants money on screen. use serde::Serialize; -/// USD per million *input* tokens (curated; update deliberately with a source). -const PRICING: &[(&str, f64)] = &[("haiku", 0.80), ("sonnet", 3.00), ("opus", 15.00)]; - -/// Documented context window for the Claude aliases above. +/// Documented context window for the Claude aliases below. const CLAUDE_WINDOW: u64 = 200_000; /// Curated input-context capacities by model alias. @@ -42,8 +41,6 @@ pub struct WindowFit { pub struct Estimate { /// chars/4 heuristic, clearly labeled as such. pub est_input_tokens: u64, - /// `None` for a model with no curated rate — an honest unknown. - pub est_cost_usd: Option, /// Window fit for the model. pub window_fit: WindowFit, } @@ -74,18 +71,12 @@ pub fn window_fit(model: &str, est_input_tokens: u64) -> WindowFit { } } -/// Estimate one run's input cost; never fabricates a dollar cost for an -/// unknown model. +/// Estimate one run's input size and window fit. pub fn estimate(model: &str, chars: usize) -> Estimate { let tokens = estimate_tokens(chars); let fit = window_fit(model, tokens); - let cost = PRICING - .iter() - .find(|(m, _)| *m == model) - .map(|(_, rate)| (tokens as f64 * rate / 1_000_000.0 * 1e8).round() / 1e8); Estimate { est_input_tokens: tokens, - est_cost_usd: cost, window_fit: fit, } } @@ -103,10 +94,9 @@ mod tests { } #[test] - fn known_model_prices_exactly() { + fn known_model_reports_overflow() { let e = estimate("haiku", 4_000_000); assert_eq!(e.est_input_tokens, 1_000_000); - assert_eq!(e.est_cost_usd, Some(0.80)); assert_eq!(e.window_fit.fits, Some(false)); assert_eq!(e.window_fit.headroom_tokens, Some(200_000 - 1_000_000)); } @@ -115,7 +105,6 @@ mod tests { fn unknown_model_is_honest_none() { let e = estimate("mystery-model", 400); assert_eq!(e.est_input_tokens, 100); - assert_eq!(e.est_cost_usd, None); assert_eq!(e.window_fit.fits, None); assert_eq!(e.window_fit.model_window, None); } diff --git a/crates/buzz-accumulator/src/lib.rs b/crates/buzz-accumulator/src/lib.rs index 21de35c021..e7ecba92c7 100644 --- a/crates/buzz-accumulator/src/lib.rs +++ b/crates/buzz-accumulator/src/lib.rs @@ -11,7 +11,8 @@ //! - **selection** — a saveable query over signals (channels, authors, kinds). //! - **fold** — `artifact' = fold(artifact, new_signals, spec)`. //! - **artifact** — compact sectioned output where every claim cites the exact -//! signals it came from; published back to the relay as an immutable event. +//! signals it came from; persisted locally as an immutable, append-only +//! version chain. //! - (a **lens** renders artifacts into UI; rendering is out of scope here.) //! //! The engine modules are deliberately **pure**: they plan runs, price them, @@ -31,7 +32,7 @@ //! Honesty invariants the engine enforces (ported from the X-Ray POC and //! covered by unit tests in each module): //! - Estimates are computed without any model call, and an unknown model's -//! cost is an honest `None`, never a guess ([`estimate`]). +//! window fit is an honest `None`, never a guess ([`estimate`]). //! - Coverage records exactly the signals the model was shown — an oversized //! window stalls or truncates honestly; unread signals are never sealed //! ([`run`]). diff --git a/crates/buzz-accumulator/src/run.rs b/crates/buzz-accumulator/src/run.rs index 79c45b123c..47d8951ef9 100644 --- a/crates/buzz-accumulator/src/run.rs +++ b/crates/buzz-accumulator/src/run.rs @@ -27,14 +27,14 @@ use crate::validate::{splice_append_sections, validate_output}; pub const MAX_CONTEXT_CHARS: usize = 120_000; /// Most signals one run may show (and therefore seal). Bounds `shown_ids` so -/// an artifact payload cannot outgrow its encrypted relay event; the rest of -/// an oversized backlog stays pending for the next run. +/// one version's coverage list stays small; the rest of an oversized backlog +/// stays pending for the next run. pub const MAX_SHOWN_PER_RUN: usize = 250; -/// Largest prior artifact document a run will build on. The artifact JSON -/// must fit one NIP-44-encrypted relay event (65,535-byte plaintext cap); -/// stalling here keeps that failure priced-before-spend instead of a model -/// call whose output can never be published. +/// Largest prior artifact document a run will build on: a model-input budget. +/// The prior digest is re-fed to the model every run, so an unbounded one +/// erodes the transcript budget until runs fold nothing; stalling here keeps +/// that failure priced-before-spend instead of a wasted model call. pub const MAX_PRIOR_OUTPUT_BYTES: usize = 40_000; /// The outcome of planning a run. @@ -60,7 +60,7 @@ pub struct RunPlan { /// True when pending signals were dropped/trimmed to fit the budget; the /// remainder stays pending for the next run. pub truncated: bool, - /// Zero-spend price of `model_input` for the spec's model. + /// Zero-spend input-size estimate of `model_input` for the spec's model. pub estimate: Estimate, /// Total uncovered signals this plan drew from (`shown.len() <=` this). pub pending: usize, @@ -79,12 +79,6 @@ pub fn plan_run( fetched: Vec, names: &BTreeMap, ) -> Result { - if schema::builtin(&spec.schema).is_none() { - return Err(Error::InvalidSpec(format!( - "unknown schema {:?}", - spec.schema - ))); - } let signals = materialize(fetched); let new: Vec = signals .into_iter() @@ -108,7 +102,7 @@ pub fn plan_run( if prior.is_some_and(|p| p.output.len() > MAX_PRIOR_OUTPUT_BYTES) { return Ok(Plan::Stalled { reason: format!( - "standing artifact is over the {MAX_PRIOR_OUTPUT_BYTES}-byte publish ceiling; \ + "standing artifact is over the {MAX_PRIOR_OUTPUT_BYTES}-byte prior-digest budget; \ compact it (tighter instructions, or start a fresh fold) before retrying" ), pending: new.len(), @@ -121,8 +115,8 @@ pub fn plan_run( ), None => String::new(), }; - // A prior under the publish ceiling always leaves ample transcript budget - // (40k bytes of parent against a 120k-char context). + // A prior under the prior-digest budget always leaves ample transcript + // budget (40k bytes of parent against a 120k-char context). let raw_budget = MAX_CONTEXT_CHARS.saturating_sub(parent.chars().count() + 2); let render = render_transcript(&new, names, raw_budget, MAX_SHOWN_PER_RUN); if !new.is_empty() && render.shown.is_empty() { @@ -169,8 +163,7 @@ pub fn complete_run( output: &str, created_at: i64, ) -> Result { - let sch = schema::builtin(&spec.schema) - .ok_or_else(|| Error::InvalidSpec(format!("unknown schema {:?}", spec.schema)))?; + let sch = &schema::CHANNEL_DIGEST_V1; let shown_ids: Vec = plan.shown.iter().map(|s| s.id.clone()).collect(); validate_output(sch, output, prior.map(|p| p.output.as_str()), &shown_ids)?; let spliced = splice_append_sections(sch, prior.map(|p| p.output.as_str()), output); @@ -306,7 +299,7 @@ mod tests { } #[test] - fn stalled_when_standing_artifact_exceeds_publish_ceiling() { + fn stalled_when_standing_artifact_exceeds_prior_digest_budget() { let spec = spec(); let mut prior = artifact_v1(&spec, &[]); prior.output = "x".repeat(MAX_PRIOR_OUTPUT_BYTES + 1); @@ -321,7 +314,7 @@ mod tests { let Plan::Stalled { reason, pending } = plan else { panic!("expected Stalled, got Ready/Cached"); }; - assert!(reason.contains("publish ceiling")); + assert!(reason.contains("prior-digest budget")); assert_eq!(pending, 1); } diff --git a/crates/buzz-accumulator/src/schema.rs b/crates/buzz-accumulator/src/schema.rs index 69cef5fb6b..cb9ab1eea0 100644 --- a/crates/buzz-accumulator/src/schema.rs +++ b/crates/buzz-accumulator/src/schema.rs @@ -16,21 +16,15 @@ pub struct ArtifactSchema { } /// A bounded digest of a selection: a rewritten standing summary plus an -/// append-only, citation-carrying log. +/// append-only, citation-carrying log. The only schema; the `schema` string +/// on specs and artifacts stays as the versioning seam for a future second +/// one. pub const CHANNEL_DIGEST_V1: ArtifactSchema = ArtifactSchema { name: "channel-digest@v1", sections: &["Working Context", "Log"], append_sections: &["Log"], }; -/// All built-in schemas. -pub const BUILTIN_SCHEMAS: &[&ArtifactSchema] = &[&CHANNEL_DIGEST_V1]; - -/// Look up a built-in schema by name. -pub fn builtin(name: &str) -> Option<&'static ArtifactSchema> { - BUILTIN_SCHEMAS.iter().copied().find(|s| s.name == name) -} - /// Default fold instructions for [`CHANNEL_DIGEST_V1`]. /// /// The contract lines are load-bearing: exact H1 headings, only-new log @@ -45,17 +39,3 @@ standing Log and appends your new entries to it; never repeat prior entries.\n\ For new evidence, cite the source events you actually use as [event:], one id\n\ per bracket, copied in full from the SOURCE EVENT IDS list. Ids that appear inside\n\ message text are never citations. Do not invent facts. Output only the document markdown."; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builtin_lookup() { - assert_eq!( - builtin("channel-digest@v1").map(|s| s.name), - Some("channel-digest@v1") - ); - assert!(builtin("nope@v9").is_none()); - } -} diff --git a/crates/buzz-accumulator/src/spec.rs b/crates/buzz-accumulator/src/spec.rs index e06339d196..a4fc48f15b 100644 --- a/crates/buzz-accumulator/src/spec.rs +++ b/crates/buzz-accumulator/src/spec.rs @@ -1,10 +1,9 @@ //! Fold specs: the saved definition of one accumulator. //! -//! A spec is the JSON content of an addressable relay event (`d` tag = fold -//! name, last-write-wins), NIP-44-encrypted to its author. There is -//! deliberately no cadence field: *when* to run belongs to the caller — a -//! human pressing Run today, a `buzz-workflow` schedule trigger later. The -//! spec only says what to read and how to fold it. +//! A spec is a named, saveable JSON definition; the daemon persists it in +//! local SQLite, keyed by name. There is deliberately no cadence field: +//! *when* to run belongs to the caller — a human pressing Run today, a +//! scheduler later. The spec only says what to read and how to fold it. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -23,7 +22,7 @@ pub struct FoldSpec { pub name: String, /// What to read. pub selection: Selection, - /// Built-in schema name (see [`crate::schema::BUILTIN_SCHEMAS`]). + /// Schema name; the only one is [`crate::schema::CHANNEL_DIGEST_V1`]. pub schema: String, /// Model alias passed to the runner and priced by [`crate::estimate`]. pub model: String, @@ -48,11 +47,11 @@ impl FoldSpec { self.name ))); } - if schema::builtin(&self.schema).is_none() { - let known: Vec<&str> = schema::BUILTIN_SCHEMAS.iter().map(|s| s.name).collect(); + if self.schema != schema::CHANNEL_DIGEST_V1.name { return Err(Error::InvalidSpec(format!( - "unknown schema {:?}; built-ins: {known:?}", - self.schema + "unknown schema {:?}; the only schema is {:?}", + self.schema, + schema::CHANNEL_DIGEST_V1.name ))); } if self.model.trim().is_empty() { diff --git a/crates/buzz-accumulator/src/validate.rs b/crates/buzz-accumulator/src/validate.rs index f8769a85bf..5aa0582c29 100644 --- a/crates/buzz-accumulator/src/validate.rs +++ b/crates/buzz-accumulator/src/validate.rs @@ -78,15 +78,13 @@ pub fn headings(output: &str) -> Vec { .collect() } -/// Extract cited event ids from `[event:…]` brackets, splitting brackets that -/// jam several full ids together. +/// Extract cited ids from `[event:…]` brackets under a strict grammar: one +/// bracket, one id. /// -/// `[event:<64 hex>]` yields that id; `[event:, event:]` yields both. -/// A hex run splits into ids only when its length is an exact multiple of 64 — -/// a 64-hex prefix with hex garbage appended must not pass as a valid id, so -/// any other run of ≥ 64 is kept whole. A bracket without a full 64-hex id -/// inside (shortened id, prose) is kept verbatim. Either way the impostor -/// cannot match a real shown id, so it still fails validation loudly. +/// The bracket's trimmed content is kept verbatim, and only content that is +/// exactly 64 lowercase hex characters can ever match a real shown id — +/// anything else (shortened id, several ids jammed together, prose) fails +/// validation loudly as a citation of nothing. pub fn cited_event_ids(citation_text: &str) -> BTreeSet { let mut cited = BTreeSet::new(); let mut rest = citation_text; @@ -95,33 +93,10 @@ pub fn cited_event_ids(citation_text: &str) -> BTreeSet { let Some(close_rel) = rest[inner_start..].find(']') else { break; }; - let chunk = &rest[inner_start..inner_start + close_rel]; + let chunk = rest[inner_start..inner_start + close_rel].trim(); rest = &rest[inner_start + close_rel + 1..]; - if chunk.is_empty() { - continue; - } - let before = cited.len(); - // Non-overlapping 64-char windows over each maximal hex run whose - // length is an exact multiple of 64. - let mut run_start: Option = None; - for (i, c) in chunk.char_indices().chain([(chunk.len(), ' ')]) { - if matches!(c, '0'..='9' | 'a'..='f') { - run_start.get_or_insert(i); - } else if let Some(s) = run_start.take() { - let len = i - s; - if len >= 64 && len % 64 == 0 { - let mut at = s; - while at < i { - cited.insert(chunk[at..at + 64].to_string()); - at += 64; - } - } else if len > 64 { - cited.insert(chunk[s..i].to_string()); - } - } - } - if cited.len() == before { - cited.insert(chunk.trim().to_string()); + if !chunk.is_empty() { + cited.insert(chunk.to_string()); } } cited @@ -301,11 +276,14 @@ mod tests { } #[test] - fn multi_id_bracket_splits_and_short_id_fails_loudly() { - let jam = format!("[event:{}, event:{}]", id('a'), id('b')); - let cited = cited_event_ids(&jam); - assert_eq!(cited.len(), 2); - assert!(cited.contains(&id('a')) && cited.contains(&id('b'))); + fn jammed_or_short_brackets_are_refused_not_split() { + // Several ids jammed into one bracket: the whole chunk is one + // (unmatchable) citation, so the output is refused even though both + // ids were legitimately shown. + let jam = format!("- both [event:{}, event:{}]", id('a'), id('b')); + let err = validate_output(&CHANNEL_DIGEST_V1, &digest(&jam), None, &[id('a'), id('b')]); + assert!(matches!(err, Err(Error::Nonconforming(_)))); + // A shortened id is kept verbatim and can never match a shown id. let short = cited_event_ids("[event:abc123]"); assert_eq!(short.into_iter().collect::>(), vec!["abc123"]); } @@ -313,7 +291,7 @@ mod tests { #[test] fn hex_run_with_trailing_garbage_is_not_a_valid_prefix_id() { // 64 valid hex chars + 6 more hex chars: must NOT yield the 64-char - // prefix (which could match a real shown id); the run is kept whole. + // prefix (which could match a real shown id); the chunk is verbatim. let run = format!("{}abcdef", id('a')); let cited = cited_event_ids(&format!("[event:{run}]")); assert!(!cited.contains(&id('a'))); diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index bef49bbdf8..59d1bb2cee 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -44,7 +44,6 @@ chrono = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } buzz-core = { workspace = true } -buzz-accumulator = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" diff --git a/crates/buzz-cli/src/commands/folds.rs b/crates/buzz-cli/src/commands/folds.rs deleted file mode 100644 index 3080018907..0000000000 --- a/crates/buzz-cli/src/commands/folds.rs +++ /dev/null @@ -1,899 +0,0 @@ -//! `buzz folds` — the accumulator control plane. -//! -//! A fold maintains a small, always-current artifact (digest) over a saved -//! selection of relay events. All state is signed relay events owned by the -//! caller's key: specs are kind-30640 addressable events (`d` = fold name, -//! last-write-wins) and artifact versions are immutable kind-4640 events — -//! both NIP-44 encrypted to self and author-only at the relay. -//! -//! Subcommands: -//! - `buzz folds set --channel …` — create or update a fold spec -//! - `buzz folds list` — list folds with latest-run state -//! - `buzz folds get ` — full spec + latest artifact meta -//! - `buzz folds delete ` — tombstone a spec (artifacts remain) -//! - `buzz folds estimate ` — zero-spend preflight: price the exact run -//! - `buzz folds run ` — plan, invoke the model, validate, persist -//! - `buzz folds artifact ` — latest artifact (or `--version`/`--history`) -//! - `buzz folds share --channel ` — republish the artifact as a channel message -//! -//! Honesty rules carried by the engine (`buzz-accumulator`): estimates never -//! call a model; coverage records exactly what the model was shown; output -//! that breaks the artifact contract is refused and nothing persists; prior -//! log history is spliced back by the engine, so it cannot be rewritten. - -use std::collections::{BTreeMap, BTreeSet}; -use std::time::SystemTime; - -use buzz_accumulator::{ - complete_run, plan_run, ArtifactPayload, FoldRunner, FoldSpec, Plan, Selection, Signal, - SubprocessRunner, -}; -use buzz_core::kind::{KIND_FOLD_ARTIFACT, KIND_FOLD_SPEC, KIND_PROFILE}; -use nostr::nips::nip44; -use nostr::{EventBuilder, Kind, Tag, Timestamp}; - -use crate::client::BuzzClient; -use crate::error::CliError; -use crate::validate::read_or_stdin; - -/// Exhaustive-read bound for a fold's artifact chain. The covered-signal set -/// is the union of `shown_ids` across the chain, so a silently truncated read -/// would re-show already-folded history; `query_all_bounded` errors instead. -const MAX_ARTIFACT_CHAIN: u32 = 2_000; - -/// Largest artifact JSON the CLI will publish. NIP-44 v2 caps plaintext at -/// 65,535 bytes; staying under it with headroom keeps "encrypt then publish" -/// a step that cannot fail after the model spend. -const MAX_ARTIFACT_PLAINTEXT: usize = 60_000; - -fn now_secs() -> i64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -// --- encryption to self ------------------------------------------------------ - -fn encrypt_to_self(client: &BuzzClient, plaintext: &str) -> Result { - let keys = client.keys(); - nip44::encrypt( - keys.secret_key(), - &keys.public_key(), - plaintext, - nip44::Version::V2, - ) - .map_err(|e| CliError::Other(format!("nip44 encrypt failed: {e}"))) -} - -fn decrypt_from_self(client: &BuzzClient, ciphertext: &str) -> Result { - let keys = client.keys(); - nip44::decrypt(keys.secret_key(), &keys.public_key(), ciphertext) - .map_err(|e| CliError::Other(format!("nip44 decrypt failed: {e}"))) -} - -// --- spec envelope ------------------------------------------------------------ - -/// Decrypted content of a kind-30640 event: a live [`FoldSpec`] or a -/// tombstone. Addressable events cannot be deleted from an append-only store, -/// so `delete` publishes a `{"deleted":true}` replacement that hides the fold. -fn parse_spec_envelope(plaintext: &str) -> Result, CliError> { - let value: serde_json::Value = serde_json::from_str(plaintext) - .map_err(|e| CliError::Other(format!("fold spec payload is not JSON: {e}")))?; - if value.get("deleted").and_then(|v| v.as_bool()) == Some(true) { - return Ok(None); - } - serde_json::from_value(value) - .map(Some) - .map_err(|e| CliError::Other(format!("fold spec payload is malformed: {e}"))) -} - -/// Monotonic created_at for LWW replacement: strictly newer than the prior -/// head even when both writes land within one second. -fn monotonic_created_at(now: i64, prior: Option) -> i64 { - match prior { - Some(p) if p >= now => p + 1, - _ => now, - } -} - -// --- relay reads -------------------------------------------------------------- - -fn event_field<'a>(ev: &'a serde_json::Value, key: &str) -> Option<&'a str> { - ev.get(key).and_then(|v| v.as_str()) -} - -/// Tag value for `key` from a raw event JSON value. -fn event_tag<'a>(ev: &'a serde_json::Value, key: &str) -> Option<&'a str> { - ev.get("tags")?.as_array()?.iter().find_map(|t| { - let t = t.as_array()?; - if t.first()?.as_str()? == key { - t.get(1)?.as_str() - } else { - None - } - }) -} - -/// Fetch the head kind-30640 event for `name` (raw JSON), if any. -async fn fetch_spec_event( - client: &BuzzClient, - name: &str, -) -> Result, CliError> { - let me = client.keys().public_key().to_hex(); - let filter = serde_json::json!({ - "kinds": [KIND_FOLD_SPEC], - "authors": [me], - "#d": [name], - "limit": 4, - }); - let raw = client.query(&filter).await?; - let value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("relay returned invalid JSON: {e}")))?; - let events = value - .as_array() - .ok_or_else(|| CliError::Other("relay response is not an array".into()))?; - // The relay stores only the NIP-33 head, but pick the newest defensively. - Ok(events - .iter() - .max_by_key(|ev| ev.get("created_at").and_then(|v| v.as_i64()).unwrap_or(0)) - .cloned()) -} - -/// Load the live spec for `name`. `None` when absent or tombstoned. -async fn load_spec(client: &BuzzClient, name: &str) -> Result, CliError> { - match fetch_spec_event(client, name).await? { - None => Ok(None), - Some(ev) => { - let content = event_field(&ev, "content") - .ok_or_else(|| CliError::Other("spec event has no content".into()))?; - parse_spec_envelope(&decrypt_from_self(client, content)?) - } - } -} - -/// Load every live fold spec owned by this key. -async fn load_all_specs(client: &BuzzClient) -> Result, CliError> { - let me = client.keys().public_key().to_hex(); - let filter = serde_json::json!({ - "kinds": [KIND_FOLD_SPEC], - "authors": [me], - "limit": 500, - }); - let events = client.query_all_bounded(filter, 500).await?; - let mut specs = Vec::new(); - let mut skipped = 0usize; - for ev in &events { - let Some(content) = event_field(ev, "content") else { - skipped += 1; - continue; - }; - // Skip undecryptable or malformed entries rather than failing the - // whole listing; `get` on the specific name surfaces the error. - let Ok(plain) = decrypt_from_self(client, content) else { - skipped += 1; - continue; - }; - match parse_spec_envelope(&plain) { - Ok(Some(spec)) => specs.push(spec), - Ok(None) => {} - Err(_) => skipped += 1, - } - } - if skipped > 0 { - eprintln!("warning: skipped {skipped} unreadable fold-spec event(s)"); - } - specs.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(specs) -} - -/// Load the full artifact chain for `fold`, sorted by version ascending. -/// -/// Artifact events carry no plaintext tags (the fold name stays private), so -/// this reads the caller's whole kind-4640 set (bounded, erroring on overflow -/// rather than silently truncating the covered-ids union) and filters by the -/// decrypted payload's `fold` field. -async fn load_artifacts(client: &BuzzClient, fold: &str) -> Result, CliError> { - let me = client.keys().public_key().to_hex(); - let filter = serde_json::json!({ - "kinds": [KIND_FOLD_ARTIFACT], - "authors": [me], - "limit": 500, - }); - let events = client.query_all_bounded(filter, MAX_ARTIFACT_CHAIN).await?; - let mut artifacts = Vec::new(); - let mut skipped = 0usize; - for ev in &events { - let Some(content) = event_field(ev, "content") else { - skipped += 1; - continue; - }; - let Ok(plain) = decrypt_from_self(client, content) else { - skipped += 1; - continue; - }; - let Ok(payload) = serde_json::from_str::(&plain) else { - skipped += 1; - continue; - }; - if payload.fold == fold { - artifacts.push(payload); - } - } - if skipped > 0 { - eprintln!("warning: skipped {skipped} unreadable artifact event(s)"); - } - artifacts.sort_by_key(|a| a.version); - Ok(artifacts) -} - -/// Latest artifact + union of shown ids over the whole chain. -fn chain_state(artifacts: &[ArtifactPayload]) -> (Option<&ArtifactPayload>, BTreeSet) { - let covered = artifacts - .iter() - .flat_map(|a| a.shown_ids.iter().cloned()) - .collect(); - (artifacts.last(), covered) -} - -/// Fetch the signals a selection matches over `[since, until_exclusive)`. -/// -/// One relay query per compiled filter (multi-channel union filters are not -/// reliable), each read exhaustively up to `limit` — a window with more -/// matches errors loudly (narrow `--since`/`--until` or raise `--limit`) -/// instead of silently keeping only the newest page, which would leave older -/// backlog permanently unfolded while reporting the fold as covered. The -/// engine dedupes and orders during materialization. -async fn fetch_signals( - client: &BuzzClient, - selection: &Selection, - since: i64, - until_exclusive: i64, - limit: u32, -) -> Result, CliError> { - let mut signals = Vec::new(); - for filter in selection.compile_filters(since, until_exclusive, limit as usize) { - let events = client.query_all_bounded(filter, limit).await?; - for ev in &events { - let (Some(id), Some(pubkey)) = (event_field(ev, "id"), event_field(ev, "pubkey")) - else { - continue; - }; - signals.push(Signal { - id: id.to_string(), - pubkey: pubkey.to_string(), - kind: ev.get("kind").and_then(|v| v.as_u64()).unwrap_or(0) as u32, - created_at: ev.get("created_at").and_then(|v| v.as_i64()).unwrap_or(0), - content: event_field(ev, "content").unwrap_or_default().to_string(), - channel: event_tag(ev, "h").map(str::to_string), - }); - } - } - Ok(signals) -} - -/// Resolve display names for transcript lines from kind-0 profiles. -async fn fetch_names(client: &BuzzClient, pubkeys: &BTreeSet) -> BTreeMap { - let mut names = BTreeMap::new(); - if pubkeys.is_empty() { - return names; - } - let filter = serde_json::json!({ - "kinds": [KIND_PROFILE], - "authors": pubkeys.iter().collect::>(), - "limit": pubkeys.len().min(500), - }); - let Ok(raw) = client.query(&filter).await else { - return names; // Names are cosmetic; a failed lookup falls back to pubkey prefixes. - }; - let Ok(value) = serde_json::from_str::(&raw) else { - return names; - }; - for ev in value.as_array().map(|a| a.as_slice()).unwrap_or_default() { - let (Some(pk), Some(content)) = (event_field(ev, "pubkey"), event_field(ev, "content")) - else { - continue; - }; - let Ok(profile) = serde_json::from_str::(content) else { - continue; - }; - let name = profile - .get("display_name") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .or_else(|| profile.get("name").and_then(|v| v.as_str())) - .filter(|s| !s.is_empty()); - if let Some(name) = name { - names.insert(pk.to_string(), name.to_string()); - } - } - names -} - -// --- relay writes --------------------------------------------------------------- - -/// Submit a signed event, mapping a dominated NIP-33 write to -/// [`CliError::Conflict`] via the shared write-response parser. -async fn submit_checked( - client: &BuzzClient, - event: nostr::Event, - conflict_msg: &str, -) -> Result { - let id = event.id.to_hex(); - let raw = client.submit_event(event).await?; - super::parse_write_response(&raw, conflict_msg)?; - Ok(id) -} - -/// Publish the spec envelope (live or tombstone) as the new kind-30640 head. -async fn publish_spec_envelope( - client: &BuzzClient, - name: &str, - plaintext: &str, -) -> Result<(String, bool), CliError> { - let prior = fetch_spec_event(client, name).await?; - let replaced = prior.is_some(); - let prior_ts = prior.and_then(|ev| ev.get("created_at").and_then(|v| v.as_i64())); - let created_at = monotonic_created_at(now_secs(), prior_ts); - let ciphertext = encrypt_to_self(client, plaintext)?; - let tag = Tag::parse(["d", name]) - .map_err(|e| CliError::Other(format!("failed to build d tag: {e}")))?; - let builder = EventBuilder::new(Kind::Custom(KIND_FOLD_SPEC as u16), ciphertext) - .tags([tag]) - .custom_created_at(Timestamp::from_secs(created_at as u64)); - let event = client.sign_event(builder)?; - let id = submit_checked( - client, - event, - "fold spec changed concurrently; re-read and retry", - ) - .await?; - Ok((id, replaced)) -} - -// --- shared preflight --------------------------------------------------------- - -struct Preflight { - spec: FoldSpec, - prior: Option, - plan: Plan, - /// The exact half-open window this plan drew from. `estimate` reports it - /// so a later `run --since … --until …` can replay the priced set. - since: i64, - until_exclusive: i64, -} - -/// Load spec + artifact chain, fetch pending signals, and plan the run. -/// This is the zero-spend half shared by `estimate` and `run`. -async fn preflight( - client: &BuzzClient, - name: &str, - since: Option, - until: Option, - limit: u32, -) -> Result { - let spec = load_spec(client, name).await?.ok_or_else(|| { - CliError::NotFound(format!("no fold named {name:?} (see `buzz folds list`)")) - })?; - let artifacts = load_artifacts(client, name).await?; - let (prior, covered) = chain_state(&artifacts); - let prior = prior.cloned(); - let since = since.unwrap_or(0); - let until_exclusive = until.unwrap_or_else(|| now_secs() + 1); - let fetched = fetch_signals(client, &spec.selection, since, until_exclusive, limit).await?; - let authors: BTreeSet = fetched.iter().map(|s| s.pubkey.clone()).collect(); - let names = fetch_names(client, &authors).await; - let plan = plan_run(&spec, prior.as_ref(), &covered, fetched, &names) - .map_err(|e| CliError::Other(e.to_string()))?; - Ok(Preflight { - spec, - prior, - plan, - since, - until_exclusive, - }) -} - -fn plan_json(name: &str, pre: &Preflight) -> serde_json::Value { - match &pre.plan { - Plan::Cached => serde_json::json!({ - "fold": name, - "action": "cached", - "version": pre.prior.as_ref().map(|p| p.version), - "note": "no new signals and the config is unchanged; the latest artifact already answers", - }), - Plan::Stalled { reason, pending } => serde_json::json!({ - "fold": name, - "action": "stalled", - "reason": reason, - "pending_signals": pending, - }), - Plan::Ready(run) => serde_json::json!({ - "fold": name, - "action": "ready", - "model": pre.spec.model, - "pending_signals": run.pending, - "would_show": run.shown.len(), - "truncated_to_budget": run.truncated, - "est_input_tokens": run.estimate.est_input_tokens, - "est_cost_usd": run.estimate.est_cost_usd, - "window_fit": run.estimate.window_fit, - "next_version": pre.prior.as_ref().map_or(1, |p| p.version + 1), - // Replay window: `run --since --until ` - // executes the same fetch this estimate priced. - "since": pre.since, - "until_exclusive": pre.until_exclusive, - }), - } -} - -// --- subcommands ---------------------------------------------------------------- - -async fn cmd_set( - client: &BuzzClient, - name: String, - channels: Vec, - authors: Vec, - kinds: Vec, - model: String, - instructions: Option, -) -> Result<(), CliError> { - let instructions = match instructions { - Some(value) => read_or_stdin(&value)?, - None => buzz_accumulator::schema::CHANNEL_DIGEST_PROMPT.to_string(), - }; - let mut spec = FoldSpec { - name: name.clone(), - selection: Selection { - channels, - authors, - kinds, - }, - schema: buzz_accumulator::schema::CHANNEL_DIGEST_V1.name.to_string(), - model, - instructions, - }; - spec.validate() - .map_err(|e| CliError::Usage(e.to_string()))?; - let plaintext = serde_json::to_string(&spec) - .map_err(|e| CliError::Other(format!("spec serialization failed: {e}")))?; - let (event_id, replaced) = publish_spec_envelope(client, &name, &plaintext).await?; - println!( - "{}", - serde_json::json!({ - "fold": name, - "event_id": event_id, - "accepted": true, - "replaced": replaced, - "selection": spec.selection.describe(), - "schema": spec.schema, - "model": spec.model, - }) - ); - Ok(()) -} - -async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> { - let specs = load_all_specs(client).await?; - let mut out = Vec::new(); - for spec in &specs { - let artifacts = load_artifacts(client, &spec.name).await?; - let (latest, covered) = chain_state(&artifacts); - out.push(serde_json::json!({ - "fold": spec.name, - "selection": spec.selection.describe(), - "schema": spec.schema, - "model": spec.model, - "latest_version": latest.map(|a| a.version), - "covered_signals": covered.len(), - "coverage_until": latest.and_then(|a| a.coverage_until), - })); - } - println!("{}", serde_json::Value::Array(out)); - Ok(()) -} - -async fn cmd_get(client: &BuzzClient, name: String) -> Result<(), CliError> { - let spec = load_spec(client, &name) - .await? - .ok_or_else(|| CliError::NotFound(format!("no fold named {name:?}")))?; - let artifacts = load_artifacts(client, &name).await?; - let (latest, covered) = chain_state(&artifacts); - println!( - "{}", - serde_json::json!({ - "fold": spec.name, - "selection": spec.selection, - "schema": spec.schema, - "model": spec.model, - "instructions": spec.instructions, - "versions": artifacts.len(), - "covered_signals": covered.len(), - "latest": latest.map(|a| serde_json::json!({ - "version": a.version, - "created_at": a.created_at, - "coverage_since": a.coverage_since, - "coverage_until": a.coverage_until, - "shown_signals": a.shown_ids.len(), - "truncated": a.truncated, - })), - }) - ); - Ok(()) -} - -async fn cmd_delete(client: &BuzzClient, name: String) -> Result<(), CliError> { - if load_spec(client, &name).await?.is_none() { - return Err(CliError::NotFound(format!("no fold named {name:?}"))); - } - let (event_id, _) = publish_spec_envelope(client, &name, "{\"deleted\":true}").await?; - println!( - "{}", - serde_json::json!({ - "fold": name, - "event_id": event_id, - "accepted": true, - "deleted": true, - "note": "spec tombstoned; existing artifact versions remain (append-only)", - }) - ); - Ok(()) -} - -async fn cmd_estimate( - client: &BuzzClient, - name: String, - since: Option, - until: Option, - limit: u32, -) -> Result<(), CliError> { - let pre = preflight(client, &name, since, until, limit).await?; - println!("{}", plan_json(&name, &pre)); - Ok(()) -} - -async fn cmd_run( - client: &BuzzClient, - name: String, - since: Option, - until: Option, - limit: u32, -) -> Result<(), CliError> { - let pre = preflight(client, &name, since, until, limit).await?; - let Plan::Ready(run) = &pre.plan else { - // Cached/stalled runs spend nothing and change nothing — report as data. - println!("{}", plan_json(&name, &pre)); - return Ok(()); - }; - eprintln!( - "running fold {name:?}: {} signal(s), ~{} input tokens{} on {}", - run.shown.len(), - run.estimate.est_input_tokens, - run.estimate - .est_cost_usd - .map(|c| format!(" (~${c:.4})")) - .unwrap_or_default(), - pre.spec.model, - ); - let runner = SubprocessRunner::new(); - let output = runner - .run(&run.model_input, &pre.spec.model) - .map_err(|e| CliError::Other(e.to_string()))?; - // The model spend is behind us: from here on, any failure salvages the - // paid-for output to stdout instead of discarding it with the error. - let salvage = |action: &str, reason: &str| { - println!( - "{}", - serde_json::json!({ - "fold": name, - "action": action, - "reason": reason, - "accepted": false, - "model_output": output, - }) - ); - }; - // Refusal path: nonconforming output persists nothing. - let artifact = match complete_run(&pre.spec, pre.prior.as_ref(), run, &output, now_secs()) { - Ok(artifact) => artifact, - Err(e) => { - salvage("refused", &e.to_string()); - return Err(CliError::Other(e.to_string())); - } - }; - let plaintext = match serde_json::to_string(&artifact) { - Ok(p) => p, - Err(e) => { - let reason = format!("artifact serialization failed: {e}"); - salvage("unpublished", &reason); - return Err(CliError::Other(reason)); - } - }; - if plaintext.len() > MAX_ARTIFACT_PLAINTEXT { - let reason = format!( - "artifact JSON is {} bytes, over the {MAX_ARTIFACT_PLAINTEXT}-byte encrypted-event \ - ceiling; compact the fold before running again", - plaintext.len() - ); - salvage("unpublished", &reason); - return Err(CliError::Other(reason)); - } - // Version fence: if a concurrent run published while the model was - // thinking, abort instead of forking the chain at the same version. - let head = match load_artifacts(client, &name).await { - Ok(chain) => chain.last().map(|a| a.version), - Err(e) => { - salvage( - "unpublished", - &format!("pre-publish chain re-read failed: {e}"), - ); - return Err(e); - } - }; - if head.is_some_and(|v| v >= artifact.version) { - let reason = format!( - "a concurrent run already published v{} for this fold; nothing persisted — \ - re-run to fold what is still pending", - head.unwrap_or_default() - ); - salvage("unpublished", &reason); - return Err(CliError::Other(reason)); - } - // No plaintext tags: the fold name lives only inside the encrypted payload. - let publish = async { - let ciphertext = encrypt_to_self(client, &plaintext)?; - let builder = EventBuilder::new(Kind::Custom(KIND_FOLD_ARTIFACT as u16), ciphertext); - let event = client.sign_event(builder)?; - submit_checked(client, event, "artifact write was reported duplicate").await - }; - let event_id = match publish.await { - Ok(id) => id, - Err(e) => { - salvage("unpublished", &format!("artifact publish failed: {e}")); - return Err(e); - } - }; - println!( - "{}", - serde_json::json!({ - "fold": name, - "action": "run", - "event_id": event_id, - "accepted": true, - "version": artifact.version, - "shown_signals": artifact.shown_ids.len(), - "coverage_since": artifact.coverage_since, - "coverage_until": artifact.coverage_until, - "truncated_to_budget": artifact.truncated, - "est_input_tokens": run.estimate.est_input_tokens, - "est_cost_usd": run.estimate.est_cost_usd, - }) - ); - Ok(()) -} - -async fn cmd_artifact( - client: &BuzzClient, - name: String, - version: Option, - history: bool, - raw: bool, -) -> Result<(), CliError> { - let artifacts = load_artifacts(client, &name).await?; - if history { - let out: Vec = artifacts - .iter() - .map(|a| { - serde_json::json!({ - "version": a.version, - "created_at": a.created_at, - "model": a.model, - "shown_signals": a.shown_ids.len(), - "coverage_since": a.coverage_since, - "coverage_until": a.coverage_until, - "truncated": a.truncated, - }) - }) - .collect(); - println!("{}", serde_json::Value::Array(out)); - return Ok(()); - } - let artifact = match version { - Some(v) => artifacts.iter().find(|a| a.version == v), - None => artifacts.last(), - } - .ok_or_else(|| { - CliError::NotFound(match version { - Some(v) => format!("fold {name:?} has no version {v}"), - None => format!("fold {name:?} has no artifact yet (run `buzz folds run {name}`)"), - }) - })?; - if raw { - println!("{}", artifact.output); - } else { - println!( - "{}", - serde_json::to_value(artifact) - .map_err(|e| CliError::Other(format!("artifact serialization failed: {e}")))? - ); - } - Ok(()) -} - -/// The share taint rule: an artifact may be republished into a channel only -/// when every channel that ever fed its chain (`artifact.channels`, the -/// union over all versions) is exactly that one channel — then every signal -/// that ever folded in is already visible to the audience by construction. -/// The check reads the artifact's provenance, not the live spec: a selection -/// edit cannot launder history folded from elsewhere. Author/kind narrowing -/// within the channel is fine; a second channel, a channel-less selection, -/// or a pre-provenance artifact is not. -fn share_allowed(artifact: &ArtifactPayload, channel: &str) -> bool { - artifact.channels.len() == 1 && artifact.channels[0] == channel -} - -async fn cmd_share(client: &BuzzClient, name: String, channel: String) -> Result<(), CliError> { - // Provenance channels are canonicalized lowercase; match that. - let channel = channel.trim().to_ascii_lowercase(); - if load_spec(client, &name).await?.is_none() { - return Err(CliError::NotFound(format!("no fold named {name:?}"))); - } - let artifacts = load_artifacts(client, &name).await?; - let artifact = artifacts.last().ok_or_else(|| { - CliError::NotFound(format!( - "fold {name:?} has no artifact yet (run `buzz folds run {name}`)" - )) - })?; - if !share_allowed(artifact, &channel) { - return Err(CliError::Usage(format!( - "refusing to share: this artifact's history folded events from channel(s) {:?}, \ - not exactly channel {channel} — sharing is only allowed into the single channel \ - an artifact has ever read from, so everything in it is already visible to the \ - audience", - artifact.channels, - ))); - } - let channel_uuid = channel - .parse::() - .map_err(|e| CliError::Usage(format!("--channel must be a channel UUID: {e}")))?; - let content = format!( - "{}\n\n—\n_{} v{} · digest of this channel · {} signal(s) this run_", - artifact.output, - artifact.fold, - artifact.version, - artifact.shown_ids.len(), - ); - let builder = buzz_sdk::build_message(channel_uuid, &content, None, &[], false, &[]) - .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?; - let event = client.sign_event(builder)?; - let event_id = submit_checked(client, event, "share message was reported duplicate").await?; - println!( - "{}", - serde_json::json!({ - "fold": name, - "event_id": event_id, - "accepted": true, - "shared_to_channel": channel, - "version": artifact.version, - }) - ); - Ok(()) -} - -pub async fn dispatch(cmd: crate::FoldsCmd, client: &BuzzClient) -> Result<(), CliError> { - use crate::FoldsCmd; - match cmd { - FoldsCmd::Set { - name, - channel, - author, - kind, - model, - instructions, - } => cmd_set(client, name, channel, author, kind, model, instructions).await, - FoldsCmd::List => cmd_list(client).await, - FoldsCmd::Get { name } => cmd_get(client, name).await, - FoldsCmd::Delete { name } => cmd_delete(client, name).await, - FoldsCmd::Estimate { - name, - since, - until, - limit, - } => cmd_estimate(client, name, since, until, limit).await, - FoldsCmd::Run { - name, - since, - until, - limit, - } => cmd_run(client, name, since, until, limit).await, - FoldsCmd::Artifact { - name, - version, - history, - raw, - } => cmd_artifact(client, name, version, history, raw).await, - FoldsCmd::Share { name, channel } => cmd_share(client, name, channel).await, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn selection(channels: &[&str], authors: &[&str]) -> Selection { - Selection { - channels: channels.iter().map(|s| s.to_string()).collect(), - authors: authors.iter().map(|s| s.to_string()).collect(), - kinds: vec![], - } - } - - fn artifact_with_channels(channels: &[&str]) -> ArtifactPayload { - ArtifactPayload { - fold: "x".into(), - version: 1, - output: "# Working Context\n\nS.\n\n# Log\n".into(), - shown_ids: vec![], - coverage_since: None, - coverage_until: None, - selection: selection(channels, &[]), - channels: channels.iter().map(|s| s.to_string()).collect(), - model: "haiku".into(), - schema: "channel-digest@v1".into(), - prompt_sha256: "0".repeat(64), - truncated: false, - created_at: 0, - } - } - - #[test] - fn share_requires_exactly_the_target_channel_in_provenance() { - assert!(share_allowed(&artifact_with_channels(&["ch1"]), "ch1")); - assert!(!share_allowed(&artifact_with_channels(&["ch1"]), "ch2")); - // A chain that EVER read a second channel stays untouchable, no - // matter what the live spec reads now. - assert!(!share_allowed( - &artifact_with_channels(&["ch1", "ch2"]), - "ch1" - )); - // Pre-provenance or author-only artifacts have no single-channel - // proof: blocked. - assert!(!share_allowed(&artifact_with_channels(&[]), "ch1")); - } - - #[test] - fn spec_envelope_roundtrip_and_tombstone() { - let spec = FoldSpec { - name: "x".into(), - selection: selection(&["ch1"], &[]), - schema: "channel-digest@v1".into(), - model: "haiku".into(), - instructions: "do it".into(), - }; - let json = serde_json::to_string(&spec).expect("serialize"); - assert_eq!(parse_spec_envelope(&json).expect("parse"), Some(spec)); - assert_eq!( - parse_spec_envelope("{\"deleted\":true}").expect("parse"), - None - ); - assert!(parse_spec_envelope("not json").is_err()); - } - - #[test] - fn lww_created_at_is_strictly_monotonic() { - assert_eq!(monotonic_created_at(100, None), 100); - assert_eq!(monotonic_created_at(100, Some(50)), 100); - assert_eq!(monotonic_created_at(100, Some(100)), 101); - assert_eq!(monotonic_created_at(100, Some(140)), 141); - } - - #[test] - fn event_tag_reads_first_match() { - let ev = serde_json::json!({ - "tags": [["f", "my-fold"], ["v", "3"], ["h", "chan"]], - }); - assert_eq!(event_tag(&ev, "f"), Some("my-fold")); - assert_eq!(event_tag(&ev, "v"), Some("3")); - assert_eq!(event_tag(&ev, "x"), None); - } -} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 5fa0ce22c4..7ed03f9d06 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,7 +5,6 @@ pub mod dms; pub mod emoji; pub mod feed; pub mod gifs; -pub mod folds; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index a195d97d56..3f2bea7397 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -237,9 +237,6 @@ enum Cmd { /// Agent engram management — persistent memory per NIP-AE #[command(subcommand)] Mem(MemCmd), - /// Accumulator folds — standing digests over saved relay selections - #[command(subcommand)] - Folds(FoldsCmd), /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), @@ -1827,88 +1824,6 @@ pub enum MediaCmd { }, } -/// Subcommands for `buzz folds` — the accumulator control plane. -#[derive(Subcommand)] -pub enum FoldsCmd { - /// Create or update a fold spec (last-write-wins by name) - Set { - /// Fold name: lowercase letters, digits, and inner dashes (≤64 chars) - name: String, - /// Channel UUID to read from (repeatable) - #[arg(long = "channel")] - channel: Vec, - /// Author pubkey (hex) to read from (repeatable) - #[arg(long = "author")] - author: Vec, - /// Event kind to include (repeatable; default 9 = channel messages) - #[arg(long = "kind")] - kind: Vec, - /// Model handed to the fold runner (e.g. haiku, sonnet, opus) - #[arg(long, default_value = "haiku")] - model: String, - /// Fold instructions; pass `-` to read from stdin (defaults to the - /// built-in channel-digest prompt) - #[arg(long)] - instructions: Option, - }, - /// List folds with latest-run state - List, - /// Show a fold's full spec and latest artifact metadata - Get { name: String }, - /// Tombstone a fold spec (existing artifact versions remain) - Delete { name: String }, - /// Preflight a run without spending: pending signals, est. tokens, cost - Estimate { - name: String, - /// Only consider signals created at or after this unix timestamp - #[arg(long)] - since: Option, - /// Only consider signals created strictly before this unix timestamp - #[arg(long)] - until: Option, - /// Exhaustive-read bound per compiled selection filter; a window with - /// more matches errors (narrow --since/--until or raise this) - #[arg(long, default_value_t = 500)] - limit: u32, - }, - /// Run the fold: plan, invoke the model, validate, persist a new version - Run { - name: String, - /// Only consider signals created at or after this unix timestamp - #[arg(long)] - since: Option, - /// Only consider signals created strictly before this unix timestamp - #[arg(long)] - until: Option, - /// Exhaustive-read bound per compiled selection filter; a window with - /// more matches errors (narrow --since/--until or raise this) - #[arg(long, default_value_t = 500)] - limit: u32, - }, - /// Print an artifact (latest version by default) - Artifact { - name: String, - /// Print a specific version instead of the latest - #[arg(long)] - version: Option, - /// List version metadata for the whole chain instead - #[arg(long, default_value_t = false)] - history: bool, - /// Print only the artifact's markdown output - #[arg(long, default_value_t = false)] - raw: bool, - }, - /// Republish the latest artifact as a message in the fold's channel - Share { - name: String, - /// Target channel UUID — must be exactly the one channel the - /// artifact's whole history has read from, so everything in it is - /// already visible there - #[arg(long)] - channel: String, - }, -} - /// Subcommands for `buzz mem`. #[derive(Subcommand)] pub enum MemCmd { @@ -2208,7 +2123,6 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Media(sub) => commands::upload::dispatch_media(sub, &client).await, Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, - Cmd::Folds(sub) => commands::folds::dispatch(sub, &client).await, Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Pack(_) => unreachable!("handled above"), } @@ -2345,7 +2259,6 @@ mod tests { "emoji", "feed", "gifs", - "folds", "issues", "media", "mem", @@ -2448,10 +2361,6 @@ mod tests { ] ); assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]); - assert_eq!( - names(&cmd, "folds"), - vec!["artifact", "delete", "estimate", "get", "list", "run", "set", "share"] - ); assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]); assert_eq!( names(&cmd, "emoji"), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index eb5816a103..4e1ab1c7f5 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -117,24 +117,6 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; -/// Accumulator fold spec (parameterized replaceable, author-only). -/// -/// The saved definition of one fold: selection, schema, model, and -/// instructions, addressed by `(pubkey, kind, d_tag = fold name)` with -/// last-write-wins semantics. Content is the `buzz-accumulator` `FoldSpec` -/// JSON, NIP-44 encrypted from the author's key to itself; reads are -/// author-only (see [`AUTHOR_ONLY_KINDS`]). -pub const KIND_FOLD_SPEC: u32 = 30640; - -/// Accumulator artifact version (regular/immutable, author-only). -/// -/// One versioned output of a fold run. Content is the `buzz-accumulator` -/// `ArtifactPayload` JSON (output, shown signal ids, coverage window, -/// provenance), NIP-44 encrypted from the author's key to itself; reads are -/// author-only (see [`AUTHOR_ONLY_KINDS`]). Versions are ordinary immutable -/// events, so a fold's artifact history is append-only by construction. -pub const KIND_FOLD_ARTIFACT: u32 = 4640; - /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -148,8 +130,6 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_EVENT_REMINDER, KIND_PUSH_LEASE, KIND_PRIVATE_MANAGED_AGENT, - KIND_FOLD_SPEC, - KIND_FOLD_ARTIFACT, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -786,8 +766,6 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_PROJECT, - KIND_FOLD_SPEC, - KIND_FOLD_ARTIFACT, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -889,13 +867,6 @@ const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 3062 const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 -const _: () = assert!(is_parameterized_replaceable(KIND_FOLD_SPEC)); // 30640 ∈ 30000–39999 - // Artifact versions are immutable history: regular-range, never replaceable. -const _: () = assert!( - !is_parameterized_replaceable(KIND_FOLD_ARTIFACT) - && !is_replaceable(KIND_FOLD_ARTIFACT) - && !is_ephemeral(KIND_FOLD_ARTIFACT) -); // 4640 regular // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7ce1c8e0a7..5901512504 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(), 45); + assert_eq!(migrations.len(), 44); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -910,20 +910,8 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("kind = 30179")); assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); - - // Accumulator kinds 30640 (fold spec) + 4640 (artifact version) FTS - // exclusion (0041): author-only NIP-44 ciphertext, same - // wrap-the-existing-expression shape as 0014/0033. - assert_eq!(migrations[40].version, 41); - assert!(migrations[40] - .sql - .as_str() - .contains("kind IN (4640, 30640)")); - assert!(migrations[40].sql.as_str().contains("search_tsv")); - assert!(!migrations[0].sql.as_str().contains("30640")); - assert!(include_str!("../../../../schema/schema.sql").contains( - "kind IN (1059, 4640, 30179, 30300, 30350, 30622, 30640, 44100, 44101, 44200)" - )); + assert!(include_str!("../../../../schema/schema.sql") + .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 8b395795dd..ee1d0312be 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -15,26 +15,26 @@ use buzz_core::kind::{ 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_FOLD_ARTIFACT, KIND_FOLD_SPEC, - 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, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, - KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, - KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, - KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, - KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, - KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, 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_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, + 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, + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, + KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + 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_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; @@ -440,10 +440,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result 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_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE - // Accumulator fold specs + artifact versions: user-owned personal - // state, author-only reads, NIP-44 content encrypted to self. - | KIND_FOLD_SPEC | KIND_FOLD_ARTIFACT => { + | 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). @@ -703,12 +700,6 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_TURN_METRIC // NIP-PL leases are author-owned, addressable global state. | super::push_lease::KIND_PUSH_LEASE - // Accumulator fold specs (30640, keyed by (pubkey, kind, d_tag)) - // and artifact versions (4640, immutable) are author-owned - // personal state. Selections live encrypted inside the payload — - // a stray `h` tag must not channel-scope them. - | KIND_FOLD_SPEC - | KIND_FOLD_ARTIFACT ) } diff --git a/migrations/0045_fold_kinds_fts.sql b/migrations/0045_fold_kinds_fts.sql deleted file mode 100644 index 7ade2860c5..0000000000 --- a/migrations/0045_fold_kinds_fts.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Accumulator kinds 30640 (fold spec) and 4640 (artifact version) carry --- NIP-44 ciphertext encrypted from the author's key to itself and are --- author-only (AUTHOR_ONLY_KINDS). Exclude them from full-text search so --- the ciphertext is never tokenized into search_tsv, where a NIP-50 search --- from any member could otherwise probe it. --- --- Same shape as 0014 (kind:30350) and 0033 (kind:30179): PostgreSQL cannot --- alter a generated expression in place, so capture the current expression, --- drop the column, and re-add it wrapped with the new exclusion. Every other --- kind keeps whatever policy the database had before. --- --- Operational cost: DROP COLUMN + ADD ... GENERATED ... STORED rewrites the --- events heap and rebuilds the GIN index under an ACCESS EXCLUSIVE lock — --- see the note on 0033. Operators with large brownfield databases should --- schedule a window. -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 (4640, 30640) 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 d5fb35e39b..0950812562 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -219,9 +219,9 @@ CREATE TABLE events ( -- Privacy: encrypted/private routing wrappers and p-gated membership notices -- must never be discoverable through NIP-50 full-text search. NULL tsvector -- never matches `@@`. - -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033 + 0041). + -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 4640, 30179, 30300, 30350, 30622, 30640, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,