fix(accumulator): harden engine, CLI, and desktop seam after adversarial review

Engine (buzz-accumulator):
- Citations are always judged: a run shown no signals may cite nothing,
  so fabricated citations can no longer ride config-only reruns.
- Hex runs split into event ids only at exact multiples of 64 chars;
  a valid-id prefix with hex garbage appended now fails loudly.
- Oversized transcript lines keep a >=256-char tail or stay pending;
  a sliver can no longer seal an event as covered.
- Runs cap shown signals (250) and stall when the standing artifact
  exceeds a 40k-byte publish ceiling, keeping the NIP-44 65,535-byte
  plaintext cap a priced-before-spend stall instead of a post-spend
  publish failure. Empty selections with no prior stall too.
- Artifacts carry their selection and the chain-wide channel union;
  the cached-run shortcut now misses on selection edits.
- The subprocess runner leads a process group and group-kills on
  timeout, and pipe drains are joined with a deadline so a grandchild
  cannot orphan a billed model call or wedge the run.
- Selections canonicalize lowercase and validate UUID/pubkey/kind
  shapes; the unused freeform@v1 schema is gone.

CLI (buzz folds):
- share checks the artifact's channel provenance, not the live spec:
  history folded from another channel can never be laundered into a
  single-channel share by editing the selection.
- Artifact events publish without plaintext tags (fold names private).
- Signal fetches are exhaustive within the window and error on the
  --limit bound instead of silently keeping only the newest page.
- run salvages the paid-for model output to stdout on every
  post-spend failure (refusal, size ceiling, version fence, publish),
  and aborts before forking the chain if a concurrent run published.
- estimate reports the priced window so run can replay it exactly;
  the redundant --schema flag is gone.

Desktop:
- run_folds_cli scrubs ambient BUZZ_AUTH_TAG/BUZZ_API_TOKEN/
  NOSTR_PRIVATE_KEY, runs from home, leads a process group, and uses
  900s/120s timeouts with headroom over the CLI's own.
- The settings card pins Run to the preflight's priced window and
  re-locks Run when a run fails; dead kind consts removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
This commit is contained in:
Riley Crane
2026-08-28 17:29:24 -07:00
committed by Sol
parent 84e86f085f
commit 6c8b1c865a
17 changed files with 663 additions and 261 deletions

1
Cargo.lock generated
View File

@@ -830,6 +830,7 @@ name = "buzz-accumulator"
version = "0.1.0"
dependencies = [
"chrono",
"libc",
"serde",
"serde_json",
"sha2 0.11.0",

View File

@@ -13,3 +13,6 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
chrono = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View File

@@ -2,6 +2,8 @@
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.
///
@@ -26,7 +28,21 @@ pub struct ArtifactPayload {
pub coverage_since: Option<i64>,
/// Half-open end of the covered window: latest shown timestamp + 1;
/// `None` when the run showed no signals.
///
/// The window is advisory provenance: coverage *truth* is `shown_ids`.
/// Signals timestamped inside the window that were dropped to fit the
/// budget stay pending — the next plan filters by id, not by window.
pub coverage_until: Option<i64>,
/// The exact selection this version was planned from. A later selection
/// change makes the cached-run shortcut miss, so edits always re-fold.
#[serde(default)]
pub selection: Selection,
/// Every channel any version in this chain has ever read (sorted union
/// over the chain). Sharing is taint-checked against this, not the live
/// spec: an artifact that ever folded another channel's events never
/// leaks them into a single-channel share.
#[serde(default)]
pub channels: Vec<String>,
/// Model that produced this version.
pub model: String,
/// Schema the output conforms to.

View File

@@ -49,10 +49,8 @@ pub mod validate;
pub use artifact::ArtifactPayload;
pub use error::Error;
pub use estimate::Estimate;
pub use run::{complete_run, plan_run, Plan, RunPlan, MAX_CONTEXT_CHARS};
pub use run::{complete_run, plan_run, Plan, RunPlan};
pub use runner::{FoldRunner, SubprocessRunner};
pub use schema::ArtifactSchema;
pub use selection::Selection;
pub use signal::Signal;
pub use spec::FoldSpec;

View File

@@ -26,6 +26,17 @@ use crate::validate::{splice_append_sections, validate_output};
/// Hard character budget for one run's model input (prior digest + transcript).
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.
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.
pub const MAX_PRIOR_OUTPUT_BYTES: usize = 40_000;
/// The outcome of planning a run.
#[derive(Debug, Clone, PartialEq)]
pub enum Plan {
@@ -80,11 +91,29 @@ pub fn plan_run(
.filter(|s| !covered.contains(&s.id))
.collect();
let config_matches = prior.is_some_and(|p| {
p.model == spec.model && p.schema == spec.schema && p.prompt_sha256 == spec.prompt_sha256()
p.model == spec.model
&& p.schema == spec.schema
&& p.prompt_sha256 == spec.prompt_sha256()
&& p.selection == spec.selection
});
if config_matches && new.is_empty() {
return Ok(Plan::Cached);
}
if prior.is_none() && new.is_empty() {
return Ok(Plan::Stalled {
reason: "selection matched no signals in the window; nothing to fold yet".to_string(),
pending: 0,
});
}
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; \
compact it (tighter instructions, or start a fresh fold) before retrying"
),
pending: new.len(),
});
}
let parent = match prior {
Some(p) => format!(
"--- PRIOR DIGEST ({} v{}) ---\n{}",
@@ -92,18 +121,10 @@ 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).
let raw_budget = MAX_CONTEXT_CHARS.saturating_sub(parent.chars().count() + 2);
if !new.is_empty() && raw_budget == 0 {
// The standing summary alone fills the context. Calling the model
// could not cover any pending signal and would persist an
// empty-coverage version forever.
return Ok(Plan::Stalled {
reason: "standing summary exhausts the context budget; compact it before retrying"
.to_string(),
pending: new.len(),
});
}
let render = render_transcript(&new, names, raw_budget);
let render = render_transcript(&new, names, raw_budget, MAX_SHOWN_PER_RUN);
if !new.is_empty() && render.shown.is_empty() {
return Ok(Plan::Stalled {
reason: "no pending event fits the remaining context budget".to_string(),
@@ -153,12 +174,22 @@ pub fn complete_run(
let shown_ids: Vec<String> = 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);
// Taint travels with the chain: once a channel's events fold in, every
// later version keeps carrying that channel, even after a selection edit.
let channels: BTreeSet<String> = prior
.map(|p| p.channels.iter().cloned().collect::<BTreeSet<_>>())
.unwrap_or_default()
.union(&spec.selection.channels.iter().cloned().collect())
.cloned()
.collect();
Ok(ArtifactPayload {
fold: spec.name.clone(),
version: prior.map_or(1, |p| p.version + 1),
output: spliced,
coverage_since: plan.shown.iter().map(|s| s.created_at).min(),
coverage_until: plan.shown.iter().map(|s| s.created_at).max().map(|t| t + 1),
selection: spec.selection.clone(),
channels: channels.into_iter().collect(),
shown_ids,
model: spec.model.clone(),
schema: spec.schema.clone(),
@@ -214,6 +245,8 @@ mod tests {
shown_ids: shown.iter().map(|(id, _)| id.to_string()).collect(),
coverage_since: shown.iter().map(|(_, ts)| *ts).min(),
coverage_until: shown.iter().map(|(_, ts)| *ts).max().map(|t| t + 1),
selection: spec.selection.clone(),
channels: spec.selection.channels.clone(),
model: spec.model.clone(),
schema: spec.schema.clone(),
prompt_sha256: spec.prompt_sha256(),
@@ -273,10 +306,10 @@ mod tests {
}
#[test]
fn stalled_when_standing_summary_exhausts_budget() {
fn stalled_when_standing_artifact_exceeds_publish_ceiling() {
let spec = spec();
let mut prior = artifact_v1(&spec, &[]);
prior.output = "x".repeat(MAX_CONTEXT_CHARS);
prior.output = "x".repeat(MAX_PRIOR_OUTPUT_BYTES + 1);
let plan = plan_run(
&spec,
Some(&prior),
@@ -288,10 +321,60 @@ mod tests {
let Plan::Stalled { reason, pending } = plan else {
panic!("expected Stalled, got Ready/Cached");
};
assert!(reason.contains("standing summary exhausts"));
assert!(reason.contains("publish ceiling"));
assert_eq!(pending, 1);
}
#[test]
fn selection_change_is_not_cached() {
let mut spec = spec();
let a = hex_id('a');
let prior = artifact_v1(&spec, &[(&a, 100)]);
spec.selection.channels.push("ch2".to_string());
let covered = BTreeSet::from([a]);
let plan = plan_run(&spec, Some(&prior), &covered, vec![], &BTreeMap::new()).expect("plan");
assert!(matches!(plan, Plan::Ready(_)), "got {plan:?}");
}
#[test]
fn no_prior_and_no_signals_stalls_instead_of_folding_nothing() {
let spec = spec();
let plan = plan_run(&spec, None, &BTreeSet::new(), vec![], &BTreeMap::new()).expect("plan");
let Plan::Stalled { reason, pending } = plan else {
panic!("expected Stalled, got {plan:?}");
};
assert!(reason.contains("nothing to fold"));
assert_eq!(pending, 0);
}
#[test]
fn channels_union_survives_a_selection_change() {
let mut spec = spec();
let a = hex_id('a');
let b = hex_id('b');
let prior = artifact_v1(&spec, &[(&a, 100)]);
// The fold once read ch1; the selection now reads only ch2.
spec.selection.channels = vec!["ch2".to_string()];
let covered = BTreeSet::from([a]);
let mut new_sig = sig(&b, 200, "from ch2");
new_sig.channel = Some("ch2".to_string());
let plan = plan_run(
&spec,
Some(&prior),
&covered,
vec![new_sig],
&BTreeMap::new(),
)
.expect("plan");
let Plan::Ready(run) = plan else {
panic!("expected Ready");
};
let out = digest(&format!("- new entry [event:{b}]"));
let v2 = complete_run(&spec, Some(&prior), &run, &out, 1_700_000_500).expect("v2");
assert_eq!(v2.channels, vec!["ch1".to_string(), "ch2".to_string()]);
assert_eq!(v2.selection, spec.selection);
}
#[test]
fn ready_plan_prices_the_exact_model_input() {
let spec = spec();

View File

@@ -40,12 +40,32 @@ const SENTINELS: &[&str] = &["not logged in", "please run /login", "invalid api
/// essentially just an auth sentinel ("Not logged in"), is refused —
/// a real digest may legitimately *discuss* login issues, so the sentinel
/// check applies only to outputs ≤ 80 chars;
/// - a hard timeout kills the child rather than waiting forever.
/// - a hard timeout kills the child rather than waiting forever — on unix the
/// whole process group, so a wrapper script cannot leave a billed model
/// call running as an orphan;
/// - pipe drains are joined with a deadline, so a grandchild inheriting the
/// pipes cannot wedge the run after the child exits (the group is killed
/// in that case too).
pub struct SubprocessRunner {
binary: String,
timeout: Duration,
}
/// How long after child exit the pipe drains may take to finish.
const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// Kill the child — on unix the entire process group it leads (see
/// `process_group(0)` below), elsewhere the direct child only.
fn kill_group(child: &mut Child) {
#[cfg(unix)]
// SAFETY: plain syscall; a negative pid signals the process group.
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.kill();
let _ = child.wait();
}
impl Default for SubprocessRunner {
fn default() -> Self {
Self::new()
@@ -90,6 +110,13 @@ impl SubprocessRunner {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Lead a fresh process group so a timeout kill reaches every
// descendant (the model CLI may itself be a wrapper script).
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd
}
@@ -100,8 +127,7 @@ impl SubprocessRunner {
Ok(Some(status)) => return Ok(status),
Ok(None) => {
if start.elapsed() > self.timeout {
let _ = child.kill();
let _ = child.wait();
kill_group(child);
return Err(Error::Runner(format!(
"fold runner timed out after {}s",
self.timeout.as_secs()
@@ -121,21 +147,26 @@ fn snip(s: &str) -> String {
}
/// Drain a child pipe to a string on a background thread (avoids pipe-buffer
/// deadlock against the concurrent stdin write).
/// deadlock against the concurrent stdin write). The result arrives on a
/// channel so the caller can bound how long it waits: a grandchild holding
/// the pipe open must not wedge the run after the child exits.
fn drain<R: Read + Send + 'static>(
reader: Option<R>,
label: &'static str,
) -> std::thread::JoinHandle<Result<String, Error>> {
) -> std::sync::mpsc::Receiver<Result<String, Error>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
match reader {
let result = match reader {
Some(mut r) => r
.read_to_string(&mut text)
.map(|_| text)
.map_err(|e| Error::Runner(format!("failed reading runner {label}: {e}"))),
None => Ok(text),
}
})
};
let _ = tx.send(result);
});
rx
}
impl FoldRunner for SubprocessRunner {
@@ -157,12 +188,22 @@ impl FoldRunner for SubprocessRunner {
let stderr = drain(child.stderr.take(), "stderr");
let status = self.wait_with_deadline(&mut child)?;
let _ = writer.join();
let out = stdout
.join()
.map_err(|_| Error::Runner("runner stdout reader panicked".to_string()))??;
let err = stderr
.join()
.map_err(|_| Error::Runner("runner stderr reader panicked".to_string()))??;
let mut recv = |rx: std::sync::mpsc::Receiver<Result<String, Error>>,
label: &str|
-> Result<String, Error> {
rx.recv_timeout(DRAIN_TIMEOUT).map_err(|_| {
// Something (a grandchild) is still holding the pipe open
// after exit — kill the group so nothing billed lives on.
kill_group(&mut child);
Error::Runner(format!(
"runner {label} still open {}s after exit (orphaned descendant?); killed \
the process group and treated the run as failed",
DRAIN_TIMEOUT.as_secs()
))
})?
};
let out = recv(stdout, "stdout")?;
let err = recv(stderr, "stderr")?;
let out = out.trim().to_string();
if !status.success() {
return Err(Error::Runner(format!(
@@ -258,4 +299,25 @@ mod tests {
let err = runner(&path).run("input", "haiku");
assert!(matches!(err, Err(Error::Runner(msg)) if msg.contains("no usable output")));
}
#[test]
fn grandchild_holding_the_pipe_cannot_wedge_the_run() {
// The child exits immediately but leaves a background grandchild
// holding stdout open. The drain must give up on a deadline and kill
// the group instead of blocking until the grandchild finishes.
let path = stub(
"orphan-runner.sh",
"(sleep 30; echo late) & echo now; exit 0",
);
let started = Instant::now();
let err = runner(&path).run("input", "haiku");
assert!(
matches!(err, Err(Error::Runner(msg)) if msg.contains("still open")),
"expected drain-deadline error"
);
assert!(
started.elapsed() < Duration::from_secs(20),
"run should fail on the drain deadline, not the grandchild's sleep"
);
}
}

View File

@@ -8,8 +8,7 @@
pub struct ArtifactSchema {
/// Versioned schema name, e.g. `channel-digest@v1`.
pub name: &'static str,
/// Required H1 sections, in order. Empty means freeform (no structural or
/// citation contract; the only dishonest freeform output is an empty one).
/// Required H1 sections, in order.
pub sections: &'static [&'static str],
/// Sections that accumulate: prior content is preserved by construction
/// and new entries must carry signal citations.
@@ -24,15 +23,8 @@ pub const CHANNEL_DIGEST_V1: ArtifactSchema = ArtifactSchema {
append_sections: &["Log"],
};
/// No structural contract; output must merely be non-empty.
pub const FREEFORM_V1: ArtifactSchema = ArtifactSchema {
name: "freeform@v1",
sections: &[],
append_sections: &[],
};
/// All built-in schemas.
pub const BUILTIN_SCHEMAS: &[&ArtifactSchema] = &[&CHANNEL_DIGEST_V1, &FREEFORM_V1];
pub const BUILTIN_SCHEMAS: &[&ArtifactSchema] = &[&CHANNEL_DIGEST_V1];
/// Look up a built-in schema by name.
pub fn builtin(name: &str) -> Option<&'static ArtifactSchema> {
@@ -64,7 +56,6 @@ mod tests {
builtin("channel-digest@v1").map(|s| s.name),
Some("channel-digest@v1")
);
assert_eq!(builtin("freeform@v1").map(|s| s.sections.len()), Some(0));
assert!(builtin("nope@v9").is_none());
}
}

View File

@@ -28,9 +28,16 @@ pub struct Selection {
}
impl Selection {
/// Validate and canonicalize (sort + dedupe) in place, so equal selections
/// serialize identically.
/// Validate and canonicalize (lowercase + sort + dedupe) in place, so
/// equal selections serialize identically and case variants of the same
/// id cannot silently select nothing.
pub fn canonicalize(&mut self) -> Result<(), Error> {
for c in &mut self.channels {
*c = c.trim().to_ascii_lowercase();
}
for a in &mut self.authors {
*a = a.trim().to_ascii_lowercase();
}
self.channels.sort();
self.channels.dedup();
self.authors.sort();
@@ -42,15 +49,20 @@ impl Selection {
"selection must name at least one channel or author".into(),
));
}
if self.channels.iter().any(|c| c.trim().is_empty()) {
return Err(Error::InvalidSpec(
"selection has an empty channel id".into(),
));
if let Some(c) = self.channels.iter().find(|c| !is_uuid(c)) {
return Err(Error::InvalidSpec(format!(
"channel {c:?} is not a channel UUID"
)));
}
if self.authors.iter().any(|a| a.trim().is_empty()) {
return Err(Error::InvalidSpec(
"selection has an empty author pubkey".into(),
));
if let Some(a) = self.authors.iter().find(|a| !is_hex_pubkey(a)) {
return Err(Error::InvalidSpec(format!(
"author {a:?} is not a 64-hex pubkey"
)));
}
if let Some(k) = self.kinds.iter().find(|k| **k > u16::MAX as u32) {
return Err(Error::InvalidSpec(format!(
"kind {k} is outside the event-kind range"
)));
}
Ok(())
}
@@ -118,6 +130,21 @@ impl Selection {
}
}
/// Lowercase hyphenated UUID: 8-4-4-4-12 hex digits.
fn is_uuid(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 36
&& b.iter().enumerate().all(|(i, c)| match i {
8 | 13 | 18 | 23 => *c == b'-',
_ => c.is_ascii_hexdigit() && !c.is_ascii_uppercase(),
})
}
/// Lowercase 64-hex pubkey.
fn is_hex_pubkey(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
}
/// Deterministically materialize fetched signals: `created_at ASC, id ASC`,
/// duplicates (same id) removed.
///
@@ -155,6 +182,9 @@ mod tests {
assert_eq!(ids, vec!["cc", "aa", "bb"]);
}
const CH_A: &str = "59ca5528-71ea-4a53-a7f5-90c9fb2b1729";
const CH_B: &str = "a2228687-0000-4000-8000-000000000000";
#[test]
fn empty_selection_is_rejected() {
let mut s = Selection::default();
@@ -162,17 +192,39 @@ mod tests {
}
#[test]
fn canonicalize_sorts_and_dedupes() {
fn canonicalize_lowercases_sorts_and_dedupes() {
let mut s = Selection {
channels: vec!["b".into(), "a".into(), "b".into()],
channels: vec![CH_B.into(), CH_A.to_uppercase(), CH_B.into()],
authors: vec![],
kinds: vec![40002, 9, 9],
};
s.canonicalize().expect("valid");
assert_eq!(s.channels, vec!["a", "b"]);
assert_eq!(s.channels, vec![CH_A.to_string(), CH_B.to_string()]);
assert_eq!(s.kinds, vec![9, 40002]);
}
#[test]
fn malformed_ids_and_out_of_range_kinds_are_rejected() {
let mut s = Selection {
channels: vec!["not-a-uuid".into()],
authors: vec![],
kinds: vec![],
};
assert!(s.canonicalize().is_err());
let mut s = Selection {
channels: vec![],
authors: vec!["abc123".into()],
kinds: vec![],
};
assert!(s.canonicalize().is_err());
let mut s = Selection {
channels: vec![CH_A.into()],
authors: vec![],
kinds: vec![70_000],
};
assert!(s.canonicalize().is_err());
}
#[test]
fn one_filter_per_channel_with_half_open_window() {
let s = Selection {

View File

@@ -83,7 +83,7 @@ mod tests {
FoldSpec {
name: "team-digest".to_string(),
selection: Selection {
channels: vec!["ch1".to_string()],
channels: vec!["59ca5528-71ea-4a53-a7f5-90c9fb2b1729".to_string()],
authors: vec![],
kinds: vec![],
},

View File

@@ -60,15 +60,24 @@ fn render_line(signal: &Signal, names: &BTreeMap<String, String>) -> String {
format!("[{iso}] {who} <{}>: {text}", kind_label(signal.kind))
}
/// Smallest tail of an oversized line worth showing the model. A shorter
/// sliver (a few characters of a long message) is not honest evidence — the
/// line is dropped and stays pending rather than being sealed as covered.
pub const MIN_TAIL_CHARS: usize = 256;
/// Render `signals` (already materialized: time-ordered, deduped) into a
/// transcript of at most `max_chars` characters, newest-first fill.
/// transcript of at most `max_chars` characters and at most `max_items`
/// lines, newest-first fill.
///
/// If even the single newest line exceeds the budget, its identity is kept
/// with a bounded `…`-prefixed tail rather than claiming an unseen event.
/// with a `…`-prefixed tail of at least [`MIN_TAIL_CHARS`] — when the budget
/// cannot afford even that, the line is dropped (stays pending) rather than
/// sealed on a sliver.
pub fn render_transcript(
signals: &[Signal],
names: &BTreeMap<String, String>,
max_chars: usize,
max_items: usize,
) -> TranscriptRender {
let rendered: Vec<(ShownSignal, String)> = signals
.iter()
@@ -86,21 +95,20 @@ pub fn render_transcript(
let mut used = 0usize;
let mut trimmed = false;
for (shown, line) in rendered.iter().rev() {
if kept.len() >= max_items {
break;
}
let mut line = line.clone();
let mut cost = line.chars().count() + usize::from(!kept.is_empty());
if !kept.is_empty() && used + cost > max_chars {
break;
}
if kept.is_empty() && cost > max_chars {
if max_chars == 0 {
if max_chars < MIN_TAIL_CHARS {
break;
}
line = if max_chars == 1 {
"…".to_string()
} else {
let tail_start = line.chars().count() - (max_chars - 1);
format!("…{}", line.chars().skip(tail_start).collect::<String>())
};
let tail_start = line.chars().count() - (max_chars - 1);
line = format!("…{}", line.chars().skip(tail_start).collect::<String>());
cost = line.chars().count();
trimmed = true;
}
@@ -150,7 +158,7 @@ mod tests {
sig("e1", 1_700_000_000, "hello there"),
sig("e2", 1_700_000_060, "hi"),
];
let r = render_transcript(&signals, &names, 10_000);
let r = render_transcript(&signals, &names, 10_000, usize::MAX);
assert!(!r.truncated);
assert_eq!(r.shown.len(), 2);
assert!(r.body.contains("riley <message>: hello there"));
@@ -161,11 +169,11 @@ mod tests {
fn drops_oldest_first_and_marks_truncation() {
let names = BTreeMap::new();
let signals = vec![sig("e1", 100, "old old old"), sig("e2", 200, "new")];
let newest_len = render_transcript(&signals[1..], &names, 10_000)
let newest_len = render_transcript(&signals[1..], &names, 10_000, usize::MAX)
.body
.chars()
.count();
let r = render_transcript(&signals, &names, newest_len);
let r = render_transcript(&signals, &names, newest_len, usize::MAX);
assert!(r.truncated);
assert_eq!(
r.shown.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
@@ -179,20 +187,46 @@ mod tests {
#[test]
fn oversized_single_line_keeps_identity_with_bounded_tail() {
let names = BTreeMap::new();
let signals = vec![sig("e1", 100, &"x".repeat(500))];
let r = render_transcript(&signals, &names, 40);
let signals = vec![sig("e1", 100, &"x".repeat(5_000))];
let r = render_transcript(&signals, &names, 400, usize::MAX);
assert!(r.truncated);
assert_eq!(r.shown.len(), 1);
let last_line = r.body.lines().last().unwrap_or("");
assert!(last_line.starts_with('…'));
assert_eq!(last_line.chars().count(), 40);
assert_eq!(last_line.chars().count(), 400);
}
#[test]
fn sliver_budget_drops_the_line_instead_of_sealing_it() {
// A tail below MIN_TAIL_CHARS is not honest evidence: show nothing,
// seal nothing — the event stays pending.
let names = BTreeMap::new();
let signals = vec![sig("e1", 100, &"x".repeat(5_000))];
let r = render_transcript(&signals, &names, MIN_TAIL_CHARS - 1, usize::MAX);
assert!(r.truncated);
assert!(r.shown.is_empty());
assert_eq!(r.body, "[…older events truncated to fit context budget…]");
}
#[test]
fn item_cap_keeps_newest_and_marks_truncation() {
let names = BTreeMap::new();
let signals: Vec<Signal> = (0..10)
.map(|i| sig(&format!("e{i}"), 100 + i, "m"))
.collect();
let r = render_transcript(&signals, &names, 10_000, 3);
assert!(r.truncated);
assert_eq!(
r.shown.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
vec!["e7", "e8", "e9"]
);
}
#[test]
fn zero_budget_shows_nothing_and_says_so() {
let names = BTreeMap::new();
let signals = vec![sig("e1", 100, "hello")];
let r = render_transcript(&signals, &names, 0);
let r = render_transcript(&signals, &names, 0, usize::MAX);
assert!(r.truncated);
assert!(r.shown.is_empty());
assert_eq!(r.body, "[…older events truncated to fit context budget…]");
@@ -203,13 +237,13 @@ mod tests {
let names = BTreeMap::new();
let mut s = sig("e1", 1_700_000_000, "deployed");
s.kind = 40002;
let r = render_transcript(&[s], &names, 10_000);
let r = render_transcript(&[s], &names, 10_000, usize::MAX);
assert!(r.body.contains("aabbccdd <kind-40002>:"));
}
#[test]
fn empty_input_is_empty_and_honest() {
let r = render_transcript(&[], &BTreeMap::new(), 100);
let r = render_transcript(&[], &BTreeMap::new(), 100, usize::MAX);
assert_eq!(r.body, "");
assert!(!r.truncated);
assert!(r.shown.is_empty());

View File

@@ -82,8 +82,11 @@ pub fn headings(output: &str) -> Vec<String> {
/// jam several full ids together.
///
/// `[event:<64 hex>]` yields that id; `[event:<id>, event:<id>]` yields both.
/// A bracket without a full 64-hex id inside (shortened id, prose) is kept
/// verbatim so it still fails validation loudly.
/// 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.
pub fn cited_event_ids(citation_text: &str) -> BTreeSet<String> {
let mut cited = BTreeSet::new();
let mut rest = citation_text;
@@ -98,16 +101,22 @@ pub fn cited_event_ids(citation_text: &str) -> BTreeSet<String> {
continue;
}
let before = cited.len();
// Consecutive non-overlapping 64-char windows over each maximal hex run.
// Non-overlapping 64-char windows over each maximal hex run whose
// length is an exact multiple of 64.
let mut run_start: Option<usize> = 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 mut at = s;
while i - at >= 64 {
cited.insert(chunk[at..at + 64].to_string());
at += 64;
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());
}
}
}
@@ -120,13 +129,13 @@ pub fn cited_event_ids(citation_text: &str) -> BTreeSet<String> {
/// Validate model output against the artifact contract.
///
/// - Freeform schemas (no sections) require only non-empty output.
/// - Sectioned schemas require exactly the schema's H1 sections, in order.
/// - When signals were shown (`source_event_ids` non-empty), the append
/// sections must cite ≥ 1 of them and may cite nothing else. Citations the
/// model merely re-emitted from the prior artifact's append sections are
/// stripped before checking, so only this run's citations are judged
/// against this run's shown ids.
/// - The output must have exactly the schema's H1 sections, in order.
/// - Append-section citations are always judged: every cited id must be one of
/// this run's `source_event_ids` — a run that was shown nothing may cite
/// nothing. Citations the model merely re-emitted from the prior artifact's
/// append sections are stripped first, so only this run's citations are
/// judged against this run's shown ids.
/// - When signals were shown, the append sections must cite ≥ 1 of them.
///
/// Any failure is [`Error::Nonconforming`]: the caller persists nothing.
pub fn validate_output(
@@ -135,15 +144,6 @@ pub fn validate_output(
previous_output: Option<&str>,
source_event_ids: &[String],
) -> Result<(), Error> {
if schema.sections.is_empty() {
if output.trim().is_empty() {
return Err(Error::Nonconforming(format!(
"schema {:?} requires non-empty output",
schema.name
)));
}
return Ok(());
}
let found = headings(output);
if found != schema.sections {
return Err(Error::Nonconforming(format!(
@@ -151,45 +151,46 @@ pub fn validate_output(
schema.name, schema.sections, found
)));
}
if !source_event_ids.is_empty() {
if schema.append_sections.is_empty() {
return Err(Error::Nonconforming(format!(
"schema {:?} has no append section for event citations",
schema.name
)));
if schema.append_sections.is_empty() {
if source_event_ids.is_empty() {
return Ok(());
}
let joined = |doc: &str| {
schema
.append_sections
.iter()
.map(|name| section(doc, name))
.collect::<Vec<_>>()
.join("\n")
};
let citation_text = joined(output);
let prior_citation_text = joined(previous_output.unwrap_or(""));
let new_citation_text = if !prior_citation_text.is_empty() {
citation_text.replacen(&prior_citation_text, "", 1)
} else {
citation_text
};
let cited = cited_event_ids(&new_citation_text);
let allowed: BTreeSet<&str> = source_event_ids.iter().map(String::as_str).collect();
let fabricated: Vec<&str> = cited
return Err(Error::Nonconforming(format!(
"schema {:?} has no append section for event citations",
schema.name
)));
}
let joined = |doc: &str| {
schema
.append_sections
.iter()
.map(String::as_str)
.filter(|id| !allowed.contains(id))
.collect();
if !fabricated.is_empty() {
return Err(Error::Nonconforming(format!(
"append sections cite unknown source event ids: {fabricated:?}"
)));
}
if cited.is_empty() {
return Err(Error::Nonconforming(
"append sections are missing a source event citation".into(),
));
}
.map(|name| section(doc, name))
.collect::<Vec<_>>()
.join("\n")
};
let citation_text = joined(output);
let prior_citation_text = joined(previous_output.unwrap_or(""));
let new_citation_text = if !prior_citation_text.is_empty() {
citation_text.replacen(&prior_citation_text, "", 1)
} else {
citation_text
};
let cited = cited_event_ids(&new_citation_text);
let allowed: BTreeSet<&str> = source_event_ids.iter().map(String::as_str).collect();
let fabricated: Vec<&str> = cited
.iter()
.map(String::as_str)
.filter(|id| !allowed.contains(id))
.collect();
if !fabricated.is_empty() {
return Err(Error::Nonconforming(format!(
"append sections cite event ids that were not shown this run: {fabricated:?}"
)));
}
if !source_event_ids.is_empty() && cited.is_empty() {
return Err(Error::Nonconforming(
"append sections are missing a source event citation".into(),
));
}
Ok(())
}
@@ -235,7 +236,7 @@ pub fn splice_append_sections(
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{CHANNEL_DIGEST_V1, FREEFORM_V1};
use crate::schema::CHANNEL_DIGEST_V1;
fn id(c: char) -> String {
std::iter::repeat_n(c, 64).collect()
@@ -269,12 +270,6 @@ mod tests {
assert!(matches!(err, Err(Error::Nonconforming(_))));
}
#[test]
fn freeform_requires_only_non_empty() {
assert!(validate_output(&FREEFORM_V1, " \n", None, &[]).is_err());
assert!(validate_output(&FREEFORM_V1, "anything", None, &[]).is_ok());
}
#[test]
fn fabricated_citation_is_refused() {
let out = digest(&format!("- made up [event:{}]", id('c')));
@@ -282,6 +277,16 @@ mod tests {
assert!(matches!(err, Err(Error::Nonconforming(_))));
}
#[test]
fn run_shown_nothing_may_cite_nothing() {
// A config-only rerun shows no signals: an empty Log passes…
assert!(validate_output(&CHANNEL_DIGEST_V1, &digest(""), None, &[]).is_ok());
// …but any citation is a fabrication and is refused.
let out = digest(&format!("- invented [event:{}]", id('c')));
let err = validate_output(&CHANNEL_DIGEST_V1, &out, None, &[]);
assert!(matches!(err, Err(Error::Nonconforming(_))));
}
#[test]
fn missing_citation_is_refused() {
let out = digest("- no citation here");
@@ -305,6 +310,23 @@ mod tests {
assert_eq!(short.into_iter().collect::<Vec<_>>(), vec!["abc123"]);
}
#[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.
let run = format!("{}abcdef", id('a'));
let cited = cited_event_ids(&format!("[event:{run}]"));
assert!(!cited.contains(&id('a')));
assert!(cited.contains(&run));
let err = validate_output(
&CHANNEL_DIGEST_V1,
&digest(&format!("- padded [event:{run}]")),
None,
&[id('a')],
);
assert!(matches!(err, Err(Error::Nonconforming(_))));
}
#[test]
fn prior_citations_do_not_satisfy_a_new_run() {
let prior = digest(&format!("- old entry [event:{}]", id('a')));

View File

@@ -41,6 +41,11 @@ use crate::validate::read_or_stdin;
/// 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)
@@ -157,29 +162,37 @@ async fn load_all_specs(client: &BuzzClient) -> Result<Vec<FoldSpec>, CliError>
});
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;
};
if let Ok(Some(spec)) = parse_spec_envelope(&plain) {
specs.push(spec);
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 a plaintext `f` tag, but generic-tag filters are not
/// pushed to storage for regular kinds, 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 payload.
/// 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<Vec<ArtifactPayload>, CliError> {
let me = client.keys().public_key().to_hex();
let filter = serde_json::json!({
@@ -189,24 +202,27 @@ async fn load_artifacts(client: &BuzzClient, fold: &str) -> Result<Vec<ArtifactP
});
let events = client.query_all_bounded(filter, MAX_ARTIFACT_CHAIN).await?;
let mut artifacts = Vec::new();
let mut skipped = 0usize;
for ev in &events {
// Cheap pre-filter on the plaintext tag; payload is authoritative.
if event_tag(ev, "f").is_some_and(|f| f != fold) {
continue;
}
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::<ArtifactPayload>(&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)
}
@@ -223,7 +239,11 @@ fn chain_state(artifacts: &[ArtifactPayload]) -> (Option<&ArtifactPayload>, BTre
/// Fetch the signals a selection matches over `[since, until_exclusive)`.
///
/// One relay query per compiled filter (multi-channel union filters are not
/// reliable); the engine dedupes and orders during materialization.
/// 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,
@@ -233,7 +253,7 @@ async fn fetch_signals(
) -> Result<Vec<Signal>, CliError> {
let mut signals = Vec::new();
for filter in selection.compile_filters(since, until_exclusive, limit as usize) {
let events = client.query_paginated(filter, limit).await?;
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 {
@@ -337,6 +357,10 @@ struct Preflight {
spec: FoldSpec,
prior: Option<ArtifactPayload>,
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.
@@ -361,7 +385,13 @@ async fn preflight(
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 })
Ok(Preflight {
spec,
prior,
plan,
since,
until_exclusive,
})
}
fn plan_json(name: &str, pre: &Preflight) -> serde_json::Value {
@@ -389,34 +419,28 @@ fn plan_json(name: &str, pre: &Preflight) -> serde_json::Value {
"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 <since> --until <until_exclusive>`
// executes the same fetch this estimate priced.
"since": pre.since,
"until_exclusive": pre.until_exclusive,
}),
}
}
// --- subcommands ----------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
async fn cmd_set(
client: &BuzzClient,
name: String,
channels: Vec<String>,
authors: Vec<String>,
kinds: Vec<u32>,
schema: String,
model: String,
instructions: Option<String>,
) -> Result<(), CliError> {
let instructions = match instructions {
Some(value) => read_or_stdin(&value)?,
None if schema == buzz_accumulator::schema::CHANNEL_DIGEST_V1.name => {
buzz_accumulator::schema::CHANNEL_DIGEST_PROMPT.to_string()
}
None => {
return Err(CliError::Usage(format!(
"--instructions is required for schema {schema:?} (only {:?} has a default prompt)",
buzz_accumulator::schema::CHANNEL_DIGEST_V1.name
)));
}
None => buzz_accumulator::schema::CHANNEL_DIGEST_PROMPT.to_string(),
};
let mut spec = FoldSpec {
name: name.clone(),
@@ -425,7 +449,7 @@ async fn cmd_set(
authors,
kinds,
},
schema,
schema: buzz_accumulator::schema::CHANNEL_DIGEST_V1.name.to_string(),
model,
instructions,
};
@@ -555,21 +579,80 @@ async fn cmd_run(
let output = runner
.run(&run.model_input, &pre.spec.model)
.map_err(|e| CliError::Other(e.to_string()))?;
// Refusal path: nonconforming output errors here and nothing persists.
let artifact = complete_run(&pre.spec, pre.prior.as_ref(), run, &output, now_secs())
.map_err(|e| CliError::Other(e.to_string()))?;
let plaintext = serde_json::to_string(&artifact)
.map_err(|e| CliError::Other(format!("artifact serialization failed: {e}")))?;
let ciphertext = encrypt_to_self(client, &plaintext)?;
let tags = [
Tag::parse(["f", &artifact.fold])
.map_err(|e| CliError::Other(format!("failed to build f tag: {e}")))?,
Tag::parse(["v", &artifact.version.to_string()])
.map_err(|e| CliError::Other(format!("failed to build v tag: {e}")))?,
];
let builder = EventBuilder::new(Kind::Custom(KIND_FOLD_ARTIFACT as u16), ciphertext).tags(tags);
let event = client.sign_event(builder)?;
let event_id = submit_checked(client, event, "artifact write was reported duplicate").await?;
// 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!({
@@ -638,25 +721,22 @@ async fn cmd_artifact(
}
/// The share taint rule: an artifact may be republished into a channel only
/// when its selection reads from exactly that one channel — then every cited
/// signal is already visible to the audience by construction. Author/kind
/// narrowing within the channel is fine; a second channel or a channel-less
/// selection is not.
fn share_allowed(selection: &Selection, channel: &str) -> bool {
selection.channels.len() == 1 && selection.channels[0] == channel
/// 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> {
let spec = load_spec(client, &name)
.await?
.ok_or_else(|| CliError::NotFound(format!("no fold named {name:?}")))?;
if !share_allowed(&spec.selection, &channel) {
return Err(CliError::Usage(format!(
"refusing to share: fold {name:?} reads {}, not exactly channel {channel} — \
sharing is only allowed into the single channel the fold reads from, so every \
cited signal is already visible to the audience",
spec.selection.describe(),
)));
// 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(|| {
@@ -664,6 +744,15 @@ async fn cmd_share(client: &BuzzClient, name: String, channel: String) -> Result
"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::<uuid::Uuid>()
.map_err(|e| CliError::Usage(format!("--channel must be a channel UUID: {e}")))?;
@@ -699,22 +788,9 @@ pub async fn dispatch(cmd: crate::FoldsCmd, client: &BuzzClient) -> Result<(), C
channel,
author,
kind,
schema,
model,
instructions,
} => {
cmd_set(
client,
name,
channel,
author,
kind,
schema,
model,
instructions,
)
.await
}
} => 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,
@@ -752,14 +828,37 @@ mod tests {
}
}
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() {
assert!(share_allowed(&selection(&["ch1"], &[]), "ch1"));
// Author-narrowing within the channel keeps taint ⊆ {channel}.
assert!(share_allowed(&selection(&["ch1"], &["pk1"]), "ch1"));
assert!(!share_allowed(&selection(&["ch1"], &[]), "ch2"));
assert!(!share_allowed(&selection(&["ch1", "ch2"], &[]), "ch1"));
assert!(!share_allowed(&selection(&[], &["pk1"]), "ch1"));
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]

View File

@@ -1843,14 +1843,11 @@ pub enum FoldsCmd {
/// Event kind to include (repeatable; default 9 = channel messages)
#[arg(long = "kind")]
kind: Vec<u32>,
/// Artifact schema: channel-digest@v1 or freeform@v1
#[arg(long, default_value = "channel-digest@v1")]
schema: String,
/// 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. Only
/// channel-digest@v1 has a built-in default prompt.
/// Fold instructions; pass `-` to read from stdin (defaults to the
/// built-in channel-digest prompt)
#[arg(long)]
instructions: Option<String>,
},
@@ -1869,7 +1866,8 @@ pub enum FoldsCmd {
/// Only consider signals created strictly before this unix timestamp
#[arg(long)]
until: Option<i64>,
/// Max signals fetched per compiled selection filter
/// 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,
},
@@ -1882,7 +1880,8 @@ pub enum FoldsCmd {
/// Only consider signals created strictly before this unix timestamp
#[arg(long)]
until: Option<i64>,
/// Max signals fetched per compiled selection filter
/// 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,
},
@@ -1902,8 +1901,9 @@ pub enum FoldsCmd {
/// 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 fold
/// reads from, so every cited signal is already visible there
/// 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,
},

View File

@@ -28,9 +28,11 @@ const MAX_ARGS: usize = 64;
const MAX_ARG_LEN: usize = 16 * 1024;
/// `folds run` invokes a model subprocess (the fold runner's own timeout is
/// 600s); every other verb is relay I/O only.
const RUN_TIMEOUT: Duration = Duration::from_secs(660);
const QUERY_TIMEOUT: Duration = Duration::from_secs(60);
/// 600s, plus relay reads and the publish); every other verb is relay I/O
/// only. Both leave generous headroom over the inner timeouts so the CLI's
/// own error reporting wins the race against this outer kill.
const RUN_TIMEOUT: Duration = Duration::from_secs(900);
const QUERY_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -75,6 +77,18 @@ fn resolve_buzz_cli() -> Result<std::path::PathBuf, String> {
.ok_or_else(|| "buzz CLI not found".to_string())
}
/// Kill the child — on unix the entire process group it leads, so the model
/// subprocess a `folds run` spawned dies with it.
fn kill_group(child: &mut std::process::Child) {
#[cfg(unix)]
// SAFETY: plain syscall; a negative pid signals the process group.
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.kill();
let _ = child.wait();
}
fn read_pipe_lossy(pipe: Option<impl Read>) -> String {
let Some(mut pipe) = pipe else {
return String::new();
@@ -101,14 +115,30 @@ fn run_folds_cli_blocking(
command.arg("folds").args(&args);
command.env("BUZZ_PRIVATE_KEY", &nsec);
command.env("BUZZ_RELAY_URL", &relay_url);
// Scrub ambient credentials the desktop process may carry: an inherited
// BUZZ_AUTH_TAG would be silently injected into every event this CLI
// signs (folds are personal, never membership-delegated).
command.env_remove("BUZZ_AUTH_TAG");
command.env_remove("BUZZ_API_TOKEN");
command.env_remove("NOSTR_PRIVATE_KEY");
// The fold runner shells out to the user's model CLI (`claude` by
// default); the augmented PATH makes that resolvable from a DMG launch.
if let Some(path) = crate::managed_agents::readiness::cli_probe::augmented_path() {
command.env("PATH", path);
}
if let Some(home) = dirs::home_dir() {
command.current_dir(home);
}
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
// Lead a fresh process group so a timeout kill reaches the model CLI the
// fold runner spawned, not just the `buzz` wrapper.
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
crate::util::configure_no_window(&mut command);
let mut child = command
@@ -128,8 +158,7 @@ fn run_folds_cli_blocking(
Ok(Some(status)) => break status,
Ok(None) => {
if started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
kill_group(&mut child);
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return Err(format!("buzz folds timed out after {}s", timeout.as_secs()));
@@ -137,8 +166,7 @@ fn run_folds_cli_blocking(
std::thread::sleep(Duration::from_millis(50));
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
kill_group(&mut child);
return Err(format!("failed to wait for buzz folds: {error}"));
}
}

View File

@@ -36,6 +36,10 @@ type FoldPlan = {
next_version?: number;
shown_signals?: number;
coverage_until?: number | null;
// Ready plans carry the exact half-open window the estimate priced, so Run
// can replay the same fetch instead of racing new arrivals.
since?: number;
until_exclusive?: number;
};
const foldsListQueryKey = ["accumulator", "folds"] as const;
@@ -85,7 +89,20 @@ function FoldRow({ fold }: { fold: FoldSummary }) {
});
const runMutation = useMutation({
mutationFn: () => runFoldsCliJson<FoldPlan>(["run", fold.fold]),
// Replay exactly the window the preflight priced — arrivals since then
// stay pending for the next preflight instead of silently joining a run
// priced without them.
mutationFn: (priced: FoldPlan) =>
runFoldsCliJson<FoldPlan>([
"run",
fold.fold,
...(priced.since !== undefined
? ["--since", String(priced.since)]
: []),
...(priced.until_exclusive !== undefined
? ["--until", String(priced.until_exclusive)]
: []),
]),
onSuccess: async (result) => {
setPlan(result);
setError(null);
@@ -96,7 +113,12 @@ function FoldRow({ fold }: { fold: FoldSummary }) {
}),
]);
},
onError: (mutationError: Error) => setError(mutationError.message),
onError: (mutationError: Error) => {
// A failed run invalidates the priced plan: Run re-locks until the
// next preflight rather than offering a stale "ready".
setPlan(null);
setError(mutationError.message);
},
});
const busy = estimateMutation.isPending || runMutation.isPending;
@@ -160,7 +182,7 @@ function FoldRow({ fold }: { fold: FoldSummary }) {
<Button
data-testid={`accumulator-run-${fold.fold}`}
disabled={!runReady}
onClick={() => runMutation.mutate()}
onClick={() => plan && runMutation.mutate(plan)}
size="sm"
>
{runMutation.isPending
@@ -233,19 +255,6 @@ export function AccumulatorSettingsCard() {
))
)}
</SettingsOptionGroup>
<SettingsOptionGroup title="Automation">
<SettingsOptionRow>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
Everything here is also available to agents and scripts as the
`buzz folds` CLI (set, list, get, delete, estimate, run, artifact,
share). Scheduled runs are a workflow concern — folds deliberately
carry no cadence of their own.
</p>
</SettingsOptionRow>
</SettingsOptionGroup>
</SettingsOptionGroupList>
</section>
);

View File

@@ -18,14 +18,24 @@ export async function runFoldsCli(args: string[]): Promise<FoldsCliOutput> {
/**
* Run a folds verb that prints JSON on success and parse it. Throws with the
* CLI's stderr (which carries the actionable message) on nonzero exit.
* CLI's stderr (which carries the actionable message) on nonzero exit. A
* failed `run` may put a structured salvage report on stdout (the paid-for
* model output that was refused or could not be published); its reason is
* folded into the thrown message so the UI surfaces why.
*/
export async function runFoldsCliJson<T>(args: string[]): Promise<T> {
const result = await runFoldsCli(args);
if (result.exitCode !== 0) {
throw new Error(
result.stderr.trim() || `buzz folds exited with code ${result.exitCode}`,
);
let detail = result.stderr.trim();
try {
const salvage = JSON.parse(result.stdout) as { reason?: string };
if (salvage.reason && !detail.includes(salvage.reason)) {
detail = detail ? `${detail} (${salvage.reason})` : salvage.reason;
}
} catch {
// stdout was not a salvage report; stderr already tells the story.
}
throw new Error(detail || `buzz folds exited with code ${result.exitCode}`);
}
return JSON.parse(result.stdout) as T;
}

View File

@@ -82,12 +82,6 @@ export const KIND_GIT_STATUS_DRAFT = 1633;
// NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey,
// h-tags = currently-hidden DM channel ids).
export const KIND_DM_VISIBILITY = 30622;
// Accumulator folds. Mirror of buzz-core's KIND_FOLD_SPEC/KIND_FOLD_ARTIFACT.
// Both author-only and NIP-44 encrypted to self; the desktop reads them via
// the bundled `buzz folds` CLI, never directly.
export const KIND_FOLD_SPEC = 30640;
export const KIND_FOLD_ARTIFACT = 4640;
// Human-visible "new content" message kinds. Used as the unread trigger set
// (sidebar badges, catch-up queries) and as the Home-feed mention query.
// Reactions, edits, diffs, deletions, and system messages are deliberately