diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 7eeb070d17b..9b5ce7e6ee2 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -16,6 +16,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz messages` | `send`, `get`, `thread`, `search` | | `buzz channels` | `list`, `get`, `create`, `join`, `members` | | `buzz canvas` | `get`, `set` | +| `buzz dispositions` | `emit`, `list` | | `buzz reactions` | `add`, `remove` | | `buzz dms` | `list`, `open` | | `buzz users` | `get`, `set-profile`, `presence` | @@ -60,6 +61,30 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat - When you **finish delegated work**, you MUST `@mention` the delegator in the message that reports the result, deliverable, or blocker. This is the #1 cause of stalled collaboration. - This applies to **completed work only.** Do not `@mention` to accept an assignment, confirm receipt, or close a loop conversationally. If you have nothing to report yet, say nothing and report when you do. +### Recording how you resolved a request + +When a message is addressed to you as a tracked request, the channel keeps a +signed record of how it ended. **You are the only party that can say a request +is done or declined** — the harness can only observe that a turn ended, never +whether the work was accomplished, so it never records completion on your +behalf. + +Two commands, both taking the triggering request's event id: + +- **Finished the work:** `buzz dispositions emit --request --disposition completed --reason ""` +- **Declined it:** `buzz dispositions emit --request --disposition refused --reason ""` + +Emit the disposition alongside your reply, not instead of it — the reply is +still how the requester learns what happened; the disposition is what makes it +a verifiable, signed record rather than chat text. + +Only emit these when they are true. `completed` is a claim that the requested +work is actually done, and a channel where it is emitted for every reply is +worth nothing. If you asked a clarifying question, made partial progress, hit +an error, or said "let me check on that" — emit nothing. Those leave the +request open, which is the correct record. Both `completed` and `refused` are +**final**: this version has no way to correct or retract one, so do not guess. + ### Threading Use the reply destination supplied in the `[Context]` block for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1352b31cad8..3c4f6095ff2 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4479,6 +4479,43 @@ mod agent_draft_prompt_tests { .contains("add them explicitly with `buzz channels add-member` only when authorized")); assert!(prompt.contains("never changes membership automatically")); } + + /// The base prompt must teach BOTH terminal dispositions with the flags + /// the CLI actually accepts. + /// + /// Completion used to be missing entirely: the prompt told agents the + /// harness "automatically records that you completed or errored", which + /// stopped being true when the harness lost the ability to emit + /// `completed` at all. So the only settling state in the protocol had no + /// instruction anywhere and no agent would ever produce one. + #[test] + fn shared_base_prompt_teaches_both_terminal_dispositions() { + let prompt = include_str!("base_prompt.md"); + // Exact command strings — a prompt teaching a flag the parser rejects + // is worse than no instruction. `--state` was documented once and + // does not exist. + assert!( + prompt.contains("buzz dispositions emit --request --disposition completed") + ); + assert!( + prompt.contains("buzz dispositions emit --request --disposition refused") + ); + assert!( + !prompt.contains("--state"), + "the CLI flag is --disposition; --state does not exist" + ); + // The harness cannot observe completion, so the agent must be told + // that recording it is its own job. + assert!(prompt.contains("You are the only party that can say a request")); + assert!(prompt.contains("never records completion on your")); + // Must be explicit that the disposition supplements the reply, never + // replaces it — a disposition that's only a signed event with no + // human-readable explanation in the channel is a silent failure by + // the base prompt's own "if it isn't published, it didn't happen" rule. + assert!(prompt.contains("not instead of it")); + // And that a terminal claim cannot be taken back in v1. + assert!(prompt.contains("**final**")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2c173646bac..a176f654b25 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1764,6 +1764,80 @@ fn send_prompt_result( /// 5. Send the actual prompt with turn timeout. /// 6. Handle all error paths, always returning the agent via `result_tx`. /// +/// Whether `event` is a NIP-AD request this specific agent must answer: +/// marked `["t","request"]` AND carrying `["agent", ]`. +/// +/// Both halves matter, for different reasons: +/// - The marker filters out ordinary chatter merged into the same batch. A +/// flush window can bundle a real request with unrelated follow-up +/// messages; without this the harness would label them all `completed`. +/// - The target check keeps this agent from publishing a disposition +/// against a request addressed to a *different* agent. Readers would +/// reject such an event anyway (see NIP-AD.md's target-agent binding), +/// but a writer that emits records it knows are unbindable pollutes the +/// ledger with permanently-invalid rows. Don't write what no one can use. +/// +/// The composer writes both tags together (`useMentionSendFlow.ts` desktop +/// side, `with_request_tag` in `buzz-cli`'s `messages` command for parity). +/// Owned storage so a `nostr::Event` can be handed to the shared verifier, +/// which borrows from the event's own data. +struct OwnedEventView { + id: String, + pubkey: String, + kind: u16, + created_at: i64, + content: String, + tags: Vec>, +} + +impl OwnedEventView { + fn from_event(event: &nostr::Event) -> Self { + Self { + id: event.id.to_hex(), + pubkey: event.pubkey.to_hex(), + kind: event.kind.as_u16(), + created_at: event.created_at.as_secs() as i64, + content: event.content.clone(), + tags: event.tags.iter().map(|t| t.as_slice().to_vec()).collect(), + } + } + + fn view(&self) -> buzz_core::disposition::EventView<'_> { + buzz_core::disposition::EventView { + id: &self.id, + pubkey: &self.pubkey, + kind: self.kind, + created_at: self.created_at, + content: &self.content, + tags: &self.tags, + } + } +} + +/// The obligation this event creates for `agent_pubkey_hex`, if any. +/// +/// Delegates entirely to `buzz_core::disposition::classify_request`. An +/// earlier version was a hand-rolled predicate here — marker plus a +/// case-insensitive `agent` match — which accepted uppercase targets, +/// multi-target requests, and targets that were never `p`-mentioned. The +/// harness would then do real work and publish dispositions for events every +/// consumer classified as invalid or unsupported. One verifier means one +/// verifier, including for the component that decides whether to act. +fn obligation_for_agent( + event: &nostr::Event, + agent_pubkey_hex: &str, +) -> Option { + let owned = OwnedEventView::from_event(event); + match buzz_core::disposition::classify_request(&owned.view()) { + buzz_core::disposition::RequestClass::Valid(ob) + if ob.target_agent_pubkey == agent_pubkey_hex => + { + Some(*ob) + } + _ => None, + } +} + /// The agent is ALWAYS returned — even on panic the `JoinSet` detects the /// abort and the caller uses `task_map` to recover the agent index. pub async fn run_prompt_task( @@ -1795,6 +1869,27 @@ pub async fn run_prompt_task( .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) .unwrap_or_default(); + // Extracted here, alongside triggering_event_ids, for the same reason: + // `batch` is moved out from under several later branches (e.g. + // `requeue_cancelled_batch`), so anything derived from it that a + // turn-completion call site needs — here, each triggering request's + // (id, author pubkey) pair for NIP-AD disposition emission — must be + // captured into an owned value before any branch can consume `batch`. + // + // Filtered through the shared verifier to the obligations *this agent* + // actually owes — see `obligation_for_agent`. A batched turn can merge + // several messages, only some marked as requests, and only some addressed + // to this agent. + let agent_pubkey_hex = ctx.agent_keys.public_key().to_hex(); + let triggering_obligations: Vec = batch + .as_ref() + .map(|b| { + b.events + .iter() + .filter_map(|be| obligation_for_agent(&be.event, &agent_pubkey_hex)) + .collect() + }) + .unwrap_or_default(); agent.acp.observe( "turn_started", serde_json::json!({ @@ -2543,6 +2638,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Cancelled), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Cancelled), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2579,6 +2681,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Error), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Error), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2642,6 +2751,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::EndTurn), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::EndTurn), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2715,6 +2831,13 @@ pub async fn run_prompt_task( Some(core_stop), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &acp_stop_to_outcome(&stop_reason), + ) + .await; send_prompt_result( &result_tx, @@ -2738,6 +2861,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Error), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Error), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2770,6 +2900,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Cancelled), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Cancelled), + ) + .await; // Timeout triggers respawn in handle_prompt_result — // session state will be discarded with the old agent. send_prompt_result( @@ -2798,6 +2935,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Error), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Error), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2823,6 +2967,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Error), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Error), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2852,6 +3003,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Error), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Error), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -2879,6 +3037,13 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::Error), ) .await; + publish_turn_dispositions( + &ctx, + observer_channel_id, + &triggering_obligations, + &core_stop_to_outcome(buzz_core::agent_turn_metric::StopReason::Error), + ) + .await; send_prompt_result( &result_tx, &turn_id, @@ -4477,6 +4642,389 @@ async fn publish_agent_turn_metric( } } +/// Map a completed turn's `CoreStop` outcome to a NIP-AD disposition state +/// and a plain-language reason. +/// +/// What the harness actually observed about how a turn ended, in NIP-AD +/// terms. +/// +/// The harness never produces `completed`. That state asserts the requested +/// work was accomplished, and nothing at this layer verifies that — a clean +/// end-of-turn equally covers an agent asking a clarifying question, +/// reporting it couldn't finish, or answering one message of a batch. +/// `Responded` is the honest counterpart: the agent answered. `completed` +/// requires an explicit per-request assertion from the agent itself. +#[derive(Debug, Clone, PartialEq, Eq)] +enum TurnOutcome { + /// Clean end of turn — the agent responded. Non-terminal. + Responded, + /// The runtime itself signalled a refusal. Terminal, and therefore only + /// attributable when the turn answered exactly one request. + Refused, + /// Technical failure, with a plain-language reason. Non-terminal. + Errored(String), +} + +/// Reason recorded when the ACP runtime itself refuses a turn. +/// +/// The runtime's `Refusal` stop reason carries no explanatory text, so this +/// is the honest maximum: a stable, machine-readable marker meaning "the +/// runtime refused and gave no reason", rather than an empty string that +/// would read as an agent declining to explain itself. +pub const ACP_RUNTIME_REFUSAL_REASON: &str = "acp-runtime-refusal"; + +impl TurnOutcome { + fn state(&self) -> buzz_core::disposition::DispositionState { + use buzz_core::disposition::DispositionState as S; + match self { + Self::Responded => S::Responded, + Self::Refused => S::Refused, + Self::Errored(_) => S::Errored, + } + } + + fn reason(&self) -> String { + match self { + Self::Responded => String::new(), + // A refusal with no stated reason defeats much of the point of + // recording it — NIP-AD's headline promise is that a reader can + // recover *why* an agent declined. The ACP runtime's `Refusal` + // stop reason carries no text at all, so an empty string here + // would publish a refusal that explains nothing while the spec + // advertised otherwise. This stable marker says exactly as much + // as the harness actually knows: the runtime refused, and it did + // not say why. + Self::Refused => ACP_RUNTIME_REFUSAL_REASON.to_string(), + Self::Errored(reason) => reason.clone(), + } + } +} + +/// Map a raw ACP stop reason to a turn outcome. +/// +/// This is the path that preserves `Refusal`. The previous implementation +/// collapsed it into a generic non-`EndTurn` bucket and then reported +/// `errored`, so a runtime that explicitly refused was recorded as a +/// technical failure — losing one of the protocol's three headline states on +/// the very path most likely to produce it. +fn acp_stop_to_outcome(stop_reason: &StopReason) -> TurnOutcome { + match stop_reason { + StopReason::EndTurn => TurnOutcome::Responded, + StopReason::Refusal => TurnOutcome::Refused, + StopReason::Cancelled => TurnOutcome::Errored("turn was cancelled".to_string()), + StopReason::MaxTokens => { + TurnOutcome::Errored("turn stopped after reaching the max-tokens limit".to_string()) + } + StopReason::MaxTurnRequests => TurnOutcome::Errored( + "turn stopped after reaching the max-turn-requests limit".to_string(), + ), + } +} + +/// Map an already-collapsed `CoreStop` to a turn outcome. +/// +/// Used only at sites that hardcode an outcome for something that never had +/// an ACP stop reason at all — an idle timeout, a hard timeout, a +/// cancellation. `Refusal` is unreachable there by construction, so nothing +/// is lost by mapping through this narrower function: every site that *can* +/// see a refusal routes through [`acp_stop_to_outcome`] instead. That is +/// what makes the mapping uniform rather than partial. +fn core_stop_to_outcome(stop_reason: buzz_core::agent_turn_metric::StopReason) -> TurnOutcome { + use buzz_core::agent_turn_metric::StopReason as CoreStop; + match stop_reason { + CoreStop::EndTurn => TurnOutcome::Responded, + CoreStop::Cancelled => TurnOutcome::Errored("turn was cancelled".to_string()), + CoreStop::MaxTokens => { + TurnOutcome::Errored("turn stopped after reaching the max-tokens limit".to_string()) + } + CoreStop::Error => TurnOutcome::Errored("turn ended with an error".to_string()), + CoreStop::Unknown => { + TurnOutcome::Errored("turn ended for an unrecognized reason".to_string()) + } + } +} + +/// Which of `request_ids` this agent has already **terminally settled** — +/// carries a `completed` or `refused` disposition signed by its own key. +/// +/// This is what makes the harness a deferential writer rather than one +/// racing the agent. An agent that explicitly asserts an outcome (via +/// `buzz dispositions emit`) has made a claim the harness cannot improve on: +/// the harness only ever observes *that a turn ended*, never what the agent +/// decided. So when a terminal state already exists, the harness stays +/// silent instead of appending its own weaker observation on top — which +/// would land as a `post_terminal_write` anomaly on a perfectly normal +/// exchange. +/// +/// That is the practical resolution of the dual-writer problem for v1 +/// without new IPC: the agent's explicit assertion wins, and the harness +/// fills in only where the agent asserted nothing. +/// +/// **The residual race is real, and an earlier version of this comment had +/// it backwards.** It claimed that a refusal landing after this check but +/// before the harness publishes yields `responded` then `refused`, "a clean +/// progression". It does not: this check runs *before* the harness builds and +/// signs its event, so in that window the refusal carries the **earlier** +/// `created_at` and the harness's observation sorts after it. The real +/// ordering is `refused` → `responded` — precisely the terminal-then-weaker +/// case. A pre-check cannot serialize two writers, and no amount of narrowing +/// makes it able to. +/// +/// What actually contains the race is the lifecycle itself: terminal claims +/// are **absorbing**, so a later non-terminal observation records an +/// `ordered_after_terminal` warning and leaves the settled outcome intact. +/// This check is therefore an optimization that keeps the record tidy, not a +/// correctness mechanism — which is why it may fail open without endangering +/// anything. +/// +/// **Bound, not tag-matched.** An earlier version filtered on signer plus a +/// terminal `disposition` tag and nothing else — no channel, no requester, no +/// validity. A stored-but-unbound event signed by this agent (say, one +/// carrying the wrong `p`) would suppress the real disposition, turning a +/// genuinely answered obligation into a permanent ledger gap. Candidates now +/// go through the same `bind_disposition` every consumer uses. +async fn already_settled_by_self( + ctx: &PromptContext, + obligations: &[buzz_core::disposition::Obligation], +) -> std::collections::HashSet { + if obligations.is_empty() { + return std::collections::HashSet::new(); + } + let agent_pubkey_hex = ctx.agent_keys.public_key().to_hex(); + const CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_AGENT_DISPOSITION as u16, + )) + .custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::E), + obligations.iter().map(|o| o.request_id.clone()), + ); + let result = match tokio::time::timeout(CHECK_TIMEOUT, ctx.rest_client.query(&[filter])).await { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + tracing::debug!(target: "pool::disposition", "NIP-AD: refusal pre-check failed: {e}"); + return std::collections::HashSet::new(); + } + Err(_) => { + tracing::debug!(target: "pool::disposition", "NIP-AD: refusal pre-check timed out"); + return std::collections::HashSet::new(); + } + }; + let Some(events) = result.as_array() else { + return std::collections::HashSet::new(); + }; + + // Adapt once, then bind each candidate against the obligation it claims. + let owned: Vec = events.iter().filter_map(OwnedJsonEvent::parse).collect(); + let mut settled = std::collections::HashSet::new(); + for obligation in obligations { + // A disposition signed by anyone else cannot settle this agent's + // obligation, and `bind_disposition` already enforces that — the + // explicit signer check here is belt-and-braces on the one thing + // whose absence would be silent. + if obligation.target_agent_pubkey != agent_pubkey_hex { + continue; + } + let candidates: Vec> = + owned.iter().map(OwnedJsonEvent::view).collect(); + let derived = buzz_core::disposition::derive_obligation(obligation, &candidates); + if derived.is_resolved() || derived.is_disputed() { + settled.insert(obligation.request_id.clone()); + } + } + settled +} + +/// Owned view over a relay JSON event, so the shared verifier can borrow it. +struct OwnedJsonEvent { + id: String, + pubkey: String, + kind: u16, + created_at: i64, + content: String, + tags: Vec>, +} + +impl OwnedJsonEvent { + fn parse(value: &serde_json::Value) -> Option { + Some(Self { + id: value.get("id")?.as_str()?.to_string(), + pubkey: value.get("pubkey")?.as_str()?.to_string(), + kind: value.get("kind").and_then(serde_json::Value::as_u64)? as u16, + created_at: value + .get("created_at") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0), + content: value + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + tags: value + .get("tags") + .and_then(serde_json::Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + Some( + row.as_array()? + .iter() + .filter_map(|c| c.as_str().map(String::from)) + .collect(), + ) + }) + .collect() + }) + .unwrap_or_default(), + }) + } + + fn view(&self) -> buzz_core::disposition::EventView<'_> { + buzz_core::disposition::EventView { + id: &self.id, + pubkey: &self.pubkey, + kind: self.kind, + created_at: self.created_at, + content: &self.content, + tags: &self.tags, + } + } +} + +/// Publish this turn's NIP-AD dispositions — the single place the harness +/// writes kind:44300. +/// +/// Best-effort in the same sense as [`publish_agent_turn_metric`]: failures +/// are logged and swallowed, never surfaced, because a disposition-publish +/// failure must not break the conversation turn it describes. +/// +/// Three rules govern what gets written: +/// +/// 1. **Never `completed`.** [`TurnOutcome`] cannot express it. The harness +/// observes that a turn ended, not that the work was done. +/// 2. **One obligation, or nothing.** A turn observation is about the *turn*; +/// a batched turn carries several obligations and cannot say which one any +/// statement applies to. So a multi-obligation turn emits nothing at all. +/// +/// An earlier version made an exception for `errored`, reasoning that a +/// failed turn means no obligation in the batch received an answer. That +/// is only true if output is atomic per turn — if the agent fully answers +/// A, then B's tool call fails, `errored` on A is exactly the overclaim +/// already removed from `responded`, one state further down. The ACP +/// runtime makes no such atomicity guarantee that this code establishes, +/// and asserting an unverified premise is how the `responded` projection +/// got here in the first place. +/// +/// Withholding leaves those obligations `unanswered`, which is the honest +/// record: nothing here knows what happened to them. The agent settles +/// them explicitly (`buzz dispositions emit`), being the only party that +/// knows which request it addressed. +/// 3. **Defer to the agent's own assertion.** Obligations this agent has +/// already terminally settled are skipped ([`already_settled_by_self`]). +/// +/// Skipped wholesale when `channel_id` is `None` (heartbeat/DM turn — v1 is +/// channel-scoped) or when the turn carried no obligations for this agent. +async fn publish_turn_dispositions( + ctx: &PromptContext, + channel_id: Option, + triggering_obligations: &[buzz_core::disposition::Obligation], + outcome: &TurnOutcome, +) { + let Some(channel_id) = channel_id else { + return; + }; + if triggering_obligations.is_empty() { + return; + } + + // Rule 2: a turn outcome is attributable only when the turn carried + // exactly one obligation. + if triggering_obligations.len() != 1 { + tracing::debug!( + target: "pool::disposition", + obligations = triggering_obligations.len(), + outcome = outcome.state().as_str(), + "NIP-AD: withholding turn disposition — a batched turn cannot say \ + which obligation any outcome applies to, and claiming all of them \ + would be a false record. The agent may settle each explicitly." + ); + return; + } + let disposition = outcome.state().as_str(); + let reason = outcome.reason(); + + // Rule 3: never write over the agent's own explicit terminal claim. + let settled_ids = already_settled_by_self(ctx, triggering_obligations).await; + + const DISPOSITION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + for obligation in triggering_obligations { + let request_id_hex = &obligation.request_id; + let requester_hex = &obligation.requester_pubkey; + if settled_ids.contains(request_id_hex) { + tracing::debug!( + target: "pool::disposition", + request_id_hex, + "NIP-AD: skipping — this agent already terminally settled this request" + ); + continue; + } + let request_eid = match nostr::EventId::parse(request_id_hex) { + Ok(id) => id, + Err(e) => { + tracing::warn!( + target: "pool::disposition", + request_id_hex, + "NIP-AD: invalid request event id: {e}" + ); + continue; + } + }; + let builder = match buzz_sdk::build_agent_disposition( + channel_id, + request_eid, + requester_hex, + disposition, + &reason, + ) { + Ok(b) => b, + Err(e) => { + tracing::warn!( + target: "pool::disposition", + request_id_hex, + "NIP-AD: build failed: {e}" + ); + continue; + } + }; + let event = match builder.sign_with_keys(&ctx.agent_keys) { + Ok(e) => e, + Err(e) => { + tracing::warn!( + target: "pool::disposition", + request_id_hex, + "NIP-AD: sign failed: {e}" + ); + continue; + } + }; + match tokio::time::timeout(DISPOSITION_TIMEOUT, ctx.rest_client.submit_event(&event)).await + { + Ok(Ok(_)) => {} + Ok(Err(e)) => tracing::warn!( + target: "pool::disposition", + request_id_hex, + "NIP-AD: publish failed: {e}" + ), + Err(_) => tracing::warn!( + target: "pool::disposition", + request_id_hex, + "NIP-AD: publish timed out" + ), + } + } +} + const REACTION_SEEN: &str = "👀"; const REACTION_WORKING: &str = "💬"; @@ -7701,6 +8249,405 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .await; } + /// The harness must NEVER be able to produce `completed`. That state + /// asserts the requested work was accomplished; the harness only ever + /// observes that a turn ended. This is the guard against regressing to + /// the old `EndTurn -> completed` mapping, which made a clarifying + /// question indistinguishable from finished work. + #[test] + fn no_harness_outcome_can_ever_report_completed() { + use buzz_core::agent_turn_metric::StopReason as CoreStop; + use buzz_core::disposition::DispositionState; + + let mut produced = Vec::new(); + for raw in [ + StopReason::EndTurn, + StopReason::Refusal, + StopReason::Cancelled, + StopReason::MaxTokens, + StopReason::MaxTurnRequests, + ] { + produced.push(acp_stop_to_outcome(&raw).state()); + } + for core in [ + CoreStop::EndTurn, + CoreStop::Cancelled, + CoreStop::MaxTokens, + CoreStop::Error, + CoreStop::Unknown, + ] { + produced.push(core_stop_to_outcome(core).state()); + } + assert!( + !produced.contains(&DispositionState::Completed), + "the harness cannot assert completion — only an explicit \ + per-request signal from the agent can" + ); + } + + /// A clean end of turn is `responded`, and a native runtime refusal + /// survives as `refused` rather than collapsing into a generic error. + #[test] + fn acp_stop_to_outcome_preserves_refusal_and_never_overclaims() { + use buzz_core::disposition::DispositionState; + + assert_eq!( + acp_stop_to_outcome(&StopReason::EndTurn).state(), + DispositionState::Responded, + "a bare end-of-turn means the agent answered, not that it finished the work" + ); + assert_eq!( + acp_stop_to_outcome(&StopReason::Refusal).state(), + DispositionState::Refused, + "a native refusal must not be recorded as a technical failure" + ); + for failure in [ + StopReason::Cancelled, + StopReason::MaxTokens, + StopReason::MaxTurnRequests, + ] { + let outcome = acp_stop_to_outcome(&failure); + assert_eq!(outcome.state(), DispositionState::Errored); + assert!( + !outcome.reason().is_empty(), + "{failure:?} must carry a plain-language reason" + ); + } + } + + /// Each technical failure keeps its own reason — a single generic + /// "something went wrong" would defeat the point of recording one. + #[test] + fn core_stop_to_outcome_gives_each_failure_a_distinct_reason() { + use buzz_core::agent_turn_metric::StopReason as CoreStop; + use buzz_core::disposition::DispositionState; + + assert_eq!( + core_stop_to_outcome(CoreStop::EndTurn).state(), + DispositionState::Responded + ); + let reasons: std::collections::HashSet = [ + CoreStop::Cancelled, + CoreStop::MaxTokens, + CoreStop::Error, + CoreStop::Unknown, + ] + .into_iter() + .map(|v| { + let outcome = core_stop_to_outcome(v); + assert_eq!(outcome.state(), DispositionState::Errored); + outcome.reason() + }) + .collect(); + assert_eq!(reasons.len(), 4, "each failure needs its own reason"); + } + + const TEST_CHANNEL: &str = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + + /// A canonical v1 request: marked, one target, target `p`-mentioned. + fn request_event(keys: &Keys, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(9), "@agent do the thing") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + /// `obligation_for_agent` decides which triggering batch events this agent + /// owes an answer for. It delegates wholly to the shared classifier, so + /// the harness cannot act on something every reader calls invalid. + #[test] + fn test_obligation_for_agent_matches_the_shared_classifier() { + let keys = Keys::generate(); + let me = "a".repeat(64); + let other = "b".repeat(64); + + let for_me = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &me]).unwrap(), + Tag::parse(["p", &me]).unwrap(), + ], + ); + let ob = obligation_for_agent(&for_me, &me).expect("canonical request is ours"); + assert_eq!(ob.target_agent_pubkey, me); + assert_eq!(ob.channel_id, TEST_CHANNEL); + assert_eq!(ob.requester_pubkey, keys.public_key().to_hex()); + + // Marked, but addressed to a different agent — not ours to answer. + let for_other = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &other]).unwrap(), + Tag::parse(["p", &other]).unwrap(), + ], + ); + assert!(obligation_for_agent(&for_other, &me).is_none()); + + // Addressed to us but never marked as a request (ordinary mention). + let unmarked = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["agent", &me]).unwrap(), + Tag::parse(["p", &me]).unwrap(), + ], + ); + assert!(obligation_for_agent(&unmarked, &me).is_none()); + + // Plain chatter with neither tag. + let chatter = EventBuilder::new(Kind::Custom(9), "lunch?") + .sign_with_keys(&keys) + .unwrap(); + assert!(obligation_for_agent(&chatter, &me).is_none()); + + // A `t` tag with a different value is not a request marker. + let other_t_tag = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["t", "announcement"]).unwrap(), + Tag::parse(["agent", &me]).unwrap(), + Tag::parse(["p", &me]).unwrap(), + ], + ); + assert!(obligation_for_agent(&other_t_tag, &me).is_none()); + } + + /// The behavior change the shared classifier forces, called out on its + /// own because the old harness predicate did the opposite: a multi-target + /// request is unsupported in v1, so the harness must NOT treat it as its + /// own. Acting on it would produce dispositions no consumer can bind and + /// work attributed to an obligation that does not exist. + #[test] + fn test_multi_target_request_is_not_this_agents_obligation() { + let keys = Keys::generate(); + let me = "a".repeat(64); + let other = "b".repeat(64); + let multi = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &other]).unwrap(), + Tag::parse(["agent", &me]).unwrap(), + Tag::parse(["p", &other]).unwrap(), + Tag::parse(["p", &me]).unwrap(), + ], + ); + assert!( + obligation_for_agent(&multi, &me).is_none(), + "the previous predicate answered yes here, which is how the harness \ + ended up acting on requests every consumer classified as unsupported" + ); + } + + /// The other divergence the old predicate carried: it compared the + /// `agent` target case-insensitively while every reader compares exactly, + /// so an uppercase target made the harness emit dispositions nobody binds. + #[test] + fn test_uppercase_target_is_not_this_agents_obligation() { + let keys = Keys::generate(); + let me = "a".repeat(64); + let upper = me.to_ascii_uppercase(); + let event = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &upper]).unwrap(), + Tag::parse(["p", &upper]).unwrap(), + ], + ); + assert!(obligation_for_agent(&event, &me).is_none()); + } + + /// A target that is never `p`-mentioned is not routed to anyone, so it is + /// not an obligation the harness may act on either. + #[test] + fn test_target_without_p_mention_is_not_an_obligation() { + let keys = Keys::generate(); + let me = "a".repeat(64); + let event = request_event( + &keys, + vec![ + Tag::parse(["h", TEST_CHANNEL]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &me]).unwrap(), + ], + ); + assert!(obligation_for_agent(&event, &me).is_none()); + } + + /// `publish_agent_dispositions` is a no-op when `channel_id` is `None` + /// (heartbeat/DM turn) — v1 scopes dispositions to channel-scoped + /// requests only. Must not panic even with non-empty triggering data. + #[tokio::test] + async fn test_publish_turn_dispositions_noop_on_no_channel() { + let ctx = make_prompt_context_no_owner(); + let triggering = vec![test_obligation("a")]; + publish_turn_dispositions(&ctx, None, &triggering, &TurnOutcome::Responded).await; + } + + fn test_obligation(seed: &str) -> buzz_core::disposition::Obligation { + buzz_core::disposition::Obligation { + request_id: seed.repeat(64), + channel_id: TEST_CHANNEL.to_string(), + requester_pubkey: "d".repeat(64), + target_agent_pubkey: "a".repeat(64), + } + } + + /// `publish_agent_dispositions` is a no-op when there are no triggering + /// requests (e.g. a resumed/merged batch with nothing new this turn). + #[tokio::test] + async fn test_publish_turn_dispositions_noop_on_empty_triggering_requesters() { + let ctx = make_prompt_context_no_owner(); + publish_turn_dispositions( + &ctx, + Some(uuid::Uuid::new_v4()), + &[], + &TurnOutcome::Responded, + ) + .await; + } + + /// With a channel and triggering requests present, `publish_agent_dispositions` + /// runs the full build/sign/publish path per request without panicking — + /// mirrors `test_publish_agent_turn_metric_encrypts_with_owner`'s bar (no + /// real relay is reachable in tests; HTTP will fail, that's expected). + /// Covers both the `completed` path (which also runs the refusal + /// pre-check query) and the `errored` path (which skips it). + #[tokio::test] + async fn test_publish_turn_dispositions_executes_without_panic() { + let ctx = make_prompt_context_no_owner(); + let triggering = vec![test_obligation("a")]; + publish_turn_dispositions( + &ctx, + Some(uuid::Uuid::new_v4()), + &triggering, + &TurnOutcome::Responded, + ) + .await; + publish_turn_dispositions( + &ctx, + Some(uuid::Uuid::new_v4()), + &triggering, + &TurnOutcome::Errored("turn ended with an error".to_string()), + ) + .await; + } + + /// `publish_turn_dispositions` skips a malformed request id (invalid hex) + /// rather than panicking — defensive parsing, since ids ultimately come + /// from `nostr::Event::id.to_hex()` and should always be well-formed, but + /// the function must not trust that blindly. + #[tokio::test] + async fn test_publish_turn_dispositions_skips_malformed_request_id() { + let ctx = make_prompt_context_no_owner(); + let mut ob = test_obligation("a"); + ob.request_id = "not-a-valid-event-id".to_string(); + publish_turn_dispositions( + &ctx, + Some(uuid::Uuid::new_v4()), + &[ob], + &TurnOutcome::Responded, + ) + .await; + } + + /// A native ACP refusal must carry a non-empty reason. + /// + /// NIP-AD's headline promise is that a reader can recover why an agent + /// declined. The runtime's `Refusal` stop reason carries no text, so an + /// empty reason here would leave that promise unmet on the one path most + /// likely to produce a refusal. + #[test] + fn test_refusal_carries_a_stable_reason() { + let outcome = acp_stop_to_outcome(&StopReason::Refusal); + assert!(matches!(outcome, TurnOutcome::Refused)); + assert_eq!(outcome.reason(), ACP_RUNTIME_REFUSAL_REASON); + assert!( + !outcome.reason().is_empty(), + "a refusal that explains nothing defeats the point of recording it" + ); + // `responded` genuinely has nothing to say, and inventing text for it + // would be the opposite mistake. + assert_eq!(TurnOutcome::Responded.reason(), ""); + } + + /// The attribution rule, which is what a turn can honestly say about each + /// obligation it carried. + /// + /// `errored` is a fact about every obligation in a batch — the turn + /// failed, so none of them got an answer. `responded` is not: it says an + /// answer was produced without saying *which* request it answered, and a + /// turn that addressed one of three messages would otherwise mark all + /// three answered. An earlier version asserted in a comment that all + /// non-terminal outcomes were true of every request, which is right for + /// one of them and wrong for the other. + #[test] + fn test_batched_turn_attribution_rule() { + let one = [test_obligation("a")]; + let many = [test_obligation("a"), test_obligation("c")]; + + let attributable = |obs: &[buzz_core::disposition::Obligation], o: &TurnOutcome| { + obs.len() == 1 || matches!(o, TurnOutcome::Errored(_)) + }; + + // A single obligation can carry any outcome. + for outcome in [ + TurnOutcome::Responded, + TurnOutcome::Refused, + TurnOutcome::Errored("boom".into()), + ] { + assert!( + attributable(&one, &outcome), + "single obligation: {outcome:?}" + ); + } + + // A batch can only carry `errored`. + assert!( + attributable(&many, &TurnOutcome::Errored("boom".into())), + "a failed turn answered none of them — true of all" + ); + assert!( + !attributable(&many, &TurnOutcome::Responded), + "`responded` for every request in a batch is a claim the turn cannot support" + ); + assert!( + !attributable(&many, &TurnOutcome::Refused), + "`refused` cannot say which request was declined" + ); + } + + /// `already_settled_by_self` returns an empty set immediately for an empty + /// input slice, without attempting any query. + #[tokio::test] + async fn test_already_settled_empty_input_short_circuits() { + let ctx = make_prompt_context_no_owner(); + let result = already_settled_by_self(&ctx, &[]).await; + assert!(result.is_empty()); + } + + /// `already_settled_by_self` is best-effort: when the relay is unreachable (as + /// in tests — `base_url` points at a closed port), it must return an + /// empty set rather than panicking or propagating the error. A failed + /// pre-check must never block the primary disposition publish it guards. + #[tokio::test] + async fn test_already_settled_returns_empty_on_query_failure() { + let ctx = make_prompt_context_no_owner(); + let result = already_settled_by_self(&ctx, &[test_obligation("a")]).await; + assert!( + result.is_empty(), + "a failed pre-check must fail open (empty), not panic or block" + ); + } + /// `build_turn_metric_counts` maps exact turn and cumulative totals from /// `TurnUsage` to the corresponding `TokenCounts.total_tokens` fields. /// Reverting the production fields at the call site to `None` would break diff --git a/crates/buzz-cli/src/commands/dispositions.rs b/crates/buzz-cli/src/commands/dispositions.rs new file mode 100644 index 00000000000..eca184b7413 --- /dev/null +++ b/crates/buzz-cli/src/commands/dispositions.rs @@ -0,0 +1,657 @@ +//! NIP-AD agent dispositions (kind:44300) — emit and audit how an agent +//! resolved a human→agent request. See `docs/nips/NIP-AD.md`. +//! +//! All verification and lifecycle logic lives in +//! [`buzz_core::disposition`], not here. This module only fetches events, +//! adapts them into that verifier's input shape, and formats its output. +//! An earlier version derived state locally, and it drifted from the +//! desktop's independent derivation — different candidate sets, different +//! binding rules — which is exactly the failure a shared verifier prevents. + +use buzz_core::disposition::{ + account, classify_request, derive_obligation, Accounting, Coverage, EffectiveOutcome, + EventView, HistoryWarning, InvalidRequest, RequestClass, UnsupportedRequest, REQUEST_KINDS, +}; +use buzz_core::kind::KIND_AGENT_DISPOSITION; +use nostr::JsonUtil; + +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::{parse_event_id, parse_uuid, validate_hex64}; + +/// Owned event fields, so [`EventView`]s can borrow from a stable buffer. +/// The verifier is deliberately borrow-based (it runs in the relay, the +/// CLI, and an auditor without imposing an event type), so callers own the +/// storage. +struct OwnedEvent { + id: String, + pubkey: String, + kind: u16, + created_at: i64, + content: String, + tags: Vec>, +} + +impl OwnedEvent { + fn from_json(value: &serde_json::Value) -> Option { + Some(Self { + id: value.get("id")?.as_str()?.to_string(), + pubkey: value.get("pubkey")?.as_str()?.to_string(), + kind: value.get("kind").and_then(|v| v.as_u64()).unwrap_or(0) as u16, + created_at: value + .get("created_at") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + content: value + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + tags: value + .get("tags") + .and_then(|v| v.as_array()) + .map(|rows| { + rows.iter() + .filter_map(|row| { + Some( + row.as_array()? + .iter() + .filter_map(|c| c.as_str().map(String::from)) + .collect(), + ) + }) + .collect() + }) + .unwrap_or_default(), + }) + } + + fn view(&self) -> EventView<'_> { + EventView { + id: &self.id, + pubkey: &self.pubkey, + kind: self.kind, + created_at: self.created_at, + content: &self.content, + tags: &self.tags, + } + } +} + +fn parse_events(raw: &str, what: &str) -> Result, CliError> { + Ok(parse_verified_events(raw, what)?.0) +} + +/// Parse relay JSON into owned events, **verifying each event's id and +/// signature** and dropping any that fail. Returns the survivors and the +/// number rejected. +/// +/// Without this, `buzz dispositions list` was a semantic verifier that +/// trusted the relay: it applied every NIP-AD binding rule perfectly to +/// events it had not checked were authentic. The relay does verify at ingest, +/// so this changes nothing against an honest relay — which is precisely why +/// it was easy to leave out, and precisely why it matters. An auditor whose +/// guarantees dissolve if the relay is lying is not an independent auditor, +/// and this tool's whole purpose is to be one. +fn parse_verified_events(raw: &str, what: &str) -> Result<(Vec, usize), CliError> { + let values: Vec = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("failed to parse {what}: {e}")))?; + + let mut events = Vec::with_capacity(values.len()); + let mut rejected = 0usize; + for value in &values { + // Reads are sig-stripped on some CLI paths; an event with no `sig` + // cannot be verified and must not be silently trusted either. + let verified = serde_json::to_string(value) + .ok() + .and_then(|json| nostr::Event::from_json(&json).ok()) + .is_some_and(|event| event.verify().is_ok()); + if !verified { + rejected += 1; + continue; + } + if let Some(event) = OwnedEvent::from_json(value) { + events.push(event); + } else { + rejected += 1; + } + } + Ok((events, rejected)) +} + +/// Emit a disposition for a request this agent handled. +/// +/// Only `request` and `disposition` are required — the requester's pubkey +/// (`p` tag) and the channel (`h` tag) are both derived by fetching the +/// request event itself, rather than asking the caller to repeat data it +/// would otherwise have to get right independently. This removes a whole +/// class of mistake (a mistyped requester pubkey, or a channel that doesn't +/// actually match the request) rather than merely validating against it. +/// +/// The request is checked against the shared verifier first, so the CLI +/// refuses to answer a request that no reader could ever bind the answer +/// to — publishing an unbindable disposition would look like a successful +/// write while leaving the request permanently unanswered. +/// +/// **This is the path a managed agent uses to settle its own work.** The +/// harness structurally cannot emit `completed` (it observes that a turn +/// ended, never that the task was done), so `completed` reaches the ledger +/// only from here, signed by the agent that was actually asked. +pub async fn cmd_emit( + client: &BuzzClient, + request_event_id: &str, + disposition: &str, + reason: &str, +) -> Result<(), CliError> { + validate_hex64(request_event_id)?; + let request_eid = parse_event_id(request_event_id)?; + + let filter = serde_json::json!({ "ids": [request_event_id] }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw, "request lookup")?; + let request = events + .first() + .ok_or_else(|| CliError::NotFound(format!("request event {request_event_id} not found")))?; + let view = request.view(); + + let obligation = match classify_request(&view) { + RequestClass::Valid(obligation) => obligation, + RequestClass::NotRequest => { + return Err(CliError::Usage(format!( + "event {request_event_id} is not a NIP-AD request (no [\"t\",\"request\"] marker) — \ + a disposition against it would never bind" + ))) + } + RequestClass::Invalid(reason) => { + return Err(CliError::Usage(format!( + "request {request_event_id} is not a valid NIP-AD v1 obligation ({}) — \ + a disposition against it would never bind. See docs/nips/NIP-AD.md.", + describe_invalid(reason) + ))) + } + RequestClass::Unsupported(reason) => { + return Err(CliError::Usage(format!( + "request {request_event_id} is not representable in NIP-AD v1 ({}) — \ + a disposition against it would never bind. See docs/nips/NIP-AD.md.", + describe_unsupported(reason) + ))) + } + }; + + // Only the obligation's target agent can discharge it. Without this the + // CLI happily signed and published a disposition from any identity and + // reported success, while every consumer ignored it — a write that looks + // like it worked and changes nothing is worse than a rejection. + let signer = client.keys().public_key().to_hex(); + if signer != obligation.target_agent_pubkey { + return Err(CliError::Usage(format!( + "this identity ({signer}) is not the agent request {request_event_id} was \ + addressed to ({}) — only the target agent can discharge an obligation, so \ + a disposition signed here would be stored and then ignored by every reader", + obligation.target_agent_pubkey + ))); + } + + let channel_uuid = parse_uuid(&obligation.channel_id)?; + let builder = buzz_sdk::build_agent_disposition( + channel_uuid, + request_eid, + &obligation.requester_pubkey, + disposition, + reason, + ) + .map_err(|e| CliError::Other(format!("build_agent_disposition failed: {e}")))?; + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +fn describe_invalid(reason: InvalidRequest) -> &'static str { + match reason { + InvalidRequest::MissingAgentTarget => { + "it names no agent target, so nothing was asked of anyone" + } + InvalidRequest::MalformedAgentTarget => { + "its agent target is not canonical 64-char lowercase hex" + } + InvalidRequest::MissingChannel => "it has no channel (h tag)", + InvalidRequest::MultipleChannels => { + "it carries more than one channel (h tag), so its scope is ambiguous" + } + InvalidRequest::TargetNotMentioned => { + "its agent target is not also p-mentioned, so the request never reached it" + } + InvalidRequest::DuplicateAgentTarget => { + "it repeats the same agent target, which is not a canonical v1 request" + } + InvalidRequest::UnsupportedKind => "its event kind is not one v1 accepts requests on", + } +} + +fn describe_unsupported(reason: UnsupportedRequest) -> &'static str { + match reason { + UnsupportedRequest::MultipleAgentTargets => { + "it names several agent targets, and v1 cannot say whether either target \ + may answer or both must — a future revision may add per-agent obligations" + } + } +} + +fn invalid_reason_slug(reason: InvalidRequest) -> &'static str { + match reason { + InvalidRequest::MissingAgentTarget => "missing_agent_target", + InvalidRequest::MalformedAgentTarget => "malformed_agent_target", + InvalidRequest::MissingChannel => "missing_channel", + InvalidRequest::MultipleChannels => "multiple_channels", + InvalidRequest::TargetNotMentioned => "target_not_mentioned", + InvalidRequest::DuplicateAgentTarget => "duplicate_agent_target", + InvalidRequest::UnsupportedKind => "unsupported_kind", + } +} + +fn unsupported_reason_slug(reason: UnsupportedRequest) -> &'static str { + match reason { + UnsupportedRequest::MultipleAgentTargets => "multiple_agent_targets", + } +} + +fn warning_slug(warning: HistoryWarning) -> &'static str { + match warning { + HistoryWarning::DuplicateTerminal => "duplicate_terminal", + HistoryWarning::OrderedAfterTerminal => "ordered_after_terminal", + } +} + +/// List a channel's dispositions with obligation accounting. +/// +/// `state`, when given, narrows the returned rows — but as a CLIENT-side +/// filter applied after fetching by `#h`, never as a relay query key. +/// `#disposition` is not a valid NIP-01 single-letter tag filter, and the +/// underlying nostr crate's `Filter.generic_tags` silently drops an +/// unrecognized multi-char key rather than erroring, so sending it would +/// return every state while looking like a filtered query. See NIP-AD.md's +/// "Not a query filter". +/// +/// The `state` filter never changes the accounting totals — those always +/// come from the full unfiltered set. +pub async fn cmd_list( + client: &BuzzClient, + channel_id: &str, + state: Option<&str>, +) -> Result<(), CliError> { + let channel_uuid = parse_uuid(channel_id)?; + let channel_str = channel_uuid.to_string(); + + let raw = client + .query(&serde_json::json!({ + "kinds": [KIND_AGENT_DISPOSITION], + "#h": [channel_str], + })) + .await?; + let (dispositions, rejected_dispositions) = parse_verified_events(&raw, "dispositions query")?; + + // The request universe comes from the shared `REQUEST_KINDS`, not a + // locally chosen kind list — two consumers querying different kind sets + // produce different accounting for one channel and both look correct. + let raw = client + .query(&serde_json::json!({ + "kinds": REQUEST_KINDS, + "#h": [channel_str], + "#t": ["request"], + })) + .await?; + let (requests, rejected_requests) = parse_verified_events(&raw, "marked-requests query")?; + + let request_views: Vec> = requests.iter().map(OwnedEvent::view).collect(); + let disposition_views: Vec> = dispositions.iter().map(OwnedEvent::view).collect(); + + // Both sides of coverage are false: this is a single unpaginated query + // pair with no completeness token, so it describes what the relay + // returned, not provably the channel's whole history. Requests alone + // would not be enough anyway — an unfetched later `refused` on page two + // turns a "settled" obligation into a disputed one. + let acc = account(&request_views, &disposition_views, Coverage::partial()); + + // Per-request rows, filtered only for display. + let mut rows = Vec::new(); + for request in &request_views { + let RequestClass::Valid(obligation) = classify_request(request) else { + continue; + }; + let derived = derive_obligation(&obligation, &disposition_views); + let (outcome_kind, current) = match derived.outcome { + EffectiveOutcome::Unanswered => continue, + EffectiveOutcome::Open(s) => ("open", Some(s)), + EffectiveOutcome::Settled(s) => ("settled", Some(s)), + EffectiveOutcome::Disputed => ("disputed", None), + }; + if let Some(want) = state { + if current.is_none_or(|c| c.as_str() != want) { + continue; + } + } + rows.push(serde_json::json!({ + "request_id": obligation.request_id, + "target_agent": obligation.target_agent_pubkey, + "outcome": outcome_kind, + "disposition": current.map(|c| c.as_str()), + // What arrived last, which is a different question from what is + // true — a terminal claim absorbs later weaker observations. + "latest_observation": derived.latest_observation.map(|s| s.as_str()), + "reason": derived.reason, + "warnings": derived.warnings.iter().map(|w| warning_slug(*w)).collect::>(), + "resolved": derived.is_resolved(), + })); + } + + println!( + "{}", + serde_json::to_string(&render( + &acc, + rows, + &channel_str, + rejected_requests + rejected_dispositions, + )) + .unwrap_or_default() + ); + Ok(()) +} + +fn render( + acc: &Accounting, + rows: Vec, + channel: &str, + unverifiable_events: usize, +) -> serde_json::Value { + serde_json::json!({ + "channel": channel, + // Events the relay returned that failed id/signature verification and + // were excluded before any accounting. Non-zero means the relay + // served something it should not have. + "unverifiable_events": unverifiable_events, + "marked_requests": acc.total(), + // Settled: a terminal claim, absorbing. + "settled": acc.settled, + // Answered but not settled — `responded` or `errored`. + "open": acc.open, + // Nothing bound at all: a real gap. + "unanswered": acc.unanswered, + // Both terminal states claimed. No settled answer exists. + "disputed": acc.disputed, + // Malformed marked events. Client faults, NOT agent failures — + // reporting them as unanswered would blame an agent for a gap no + // agent could ever clear. + "invalid_requests": acc.invalid_requests + .iter() + .map(|(id, reason)| serde_json::json!({ + "request_id": id, + "reason": invalid_reason_slug(*reason), + })) + .collect::>(), + // Well-formed but not representable in v1. Nobody's fault; still + // blocks a clean claim. + "unsupported_requests": acc.unsupported_requests + .iter() + .map(|(id, reason)| serde_json::json!({ + "request_id": id, + "reason": unsupported_reason_slug(*reason), + })) + .collect::>(), + // Stored dispositions that named an obligation but did not bind — + // spoof attempts and misdirected writes, visible without being + // allowed to affect any outcome. + "rejected_claims": acc.rejected_claims + .iter() + .map(|c| serde_json::json!({ + "event_id": c.event_id, + "referenced_request": c.referenced_request, + "signer": c.signer, + "failure": format!("{:?}", c.failure), + })) + .collect::>(), + // Whether the above covers the channel's whole history, on BOTH + // sides. Always false here: one unpaginated query pair, no + // completeness token. + "coverage": { + "requests_complete": acc.coverage.requests_complete, + "dispositions_complete": acc.coverage.dispositions_complete, + }, + // Only true with complete coverage AND nothing open, disputed, + // invalid, or unsupported. The one field safe to render as "all good". + "all_resolved": acc.all_resolved(), + "dispositions": rows, + }) +} + +pub async fn dispatch(cmd: crate::DispositionsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::DispositionsCmd; + match cmd { + DispositionsCmd::Emit { + request, + disposition, + reason, + } => { + cmd_emit( + client, + &request, + &disposition, + reason.as_deref().unwrap_or(""), + ) + .await + } + DispositionsCmd::List { channel, state } => { + cmd_list(client, &channel, state.as_deref()).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HUMAN: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const REQUESTER: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const CHANNEL: &str = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + const REQ_A: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const REQ_B: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + fn request_json(id: &str, agent: Option<&str>) -> serde_json::Value { + let mut tags = vec![ + serde_json::json!(["h", CHANNEL]), + serde_json::json!(["t", "request"]), + serde_json::json!(["p", HUMAN]), + ]; + if let Some(agent) = agent { + tags.push(serde_json::json!(["agent", agent])); + tags.push(serde_json::json!(["p", agent])); + } + serde_json::json!({ + "id": id, + "pubkey": REQUESTER, + "kind": REQUEST_KINDS[0], + "created_at": 100, + "content": "@agent do the thing", + "tags": tags, + }) + } + + fn disposition_json(id: &str, request: &str, signer: &str, state: &str) -> serde_json::Value { + disposition_json_at(id, request, signer, state, 200) + } + + fn disposition_json_at( + id: &str, + request: &str, + signer: &str, + state: &str, + created_at: i64, + ) -> serde_json::Value { + serde_json::json!({ + "id": id, + "pubkey": signer, + "kind": KIND_AGENT_DISPOSITION, + "created_at": created_at, + "content": serde_json::json!({"disposition": state, "reason": ""}).to_string(), + "tags": [ + ["e", request], + ["h", CHANNEL], + ["p", REQUESTER], + ["disposition", state], + ], + }) + } + + fn owned(values: &[serde_json::Value]) -> Vec { + values.iter().filter_map(OwnedEvent::from_json).collect() + } + + #[test] + fn an_unsigned_or_forged_event_is_dropped_before_any_accounting() { + // The auditor's independence rests on this: every rule below is + // applied only to events whose id and signature check out. An event + // the relay served but nobody validly signed must not reach the + // verifier at all. + let forged = serde_json::json!([{ + "id": REQ_A, + "pubkey": AGENT, + "kind": REQUEST_KINDS[0], + "created_at": 100, + "content": "@agent do the thing", + "tags": [["h", CHANNEL], ["t", "request"]], + "sig": "00".repeat(64), + }]); + let (events, rejected) = + parse_verified_events(&forged.to_string(), "test").expect("valid JSON"); + assert!( + events.is_empty(), + "a bad signature must not survive parsing" + ); + assert_eq!(rejected, 1); + } + + #[test] + fn an_invalid_request_is_reported_as_invalid_not_unanswered() { + // A targetless marked request can never be answered by anyone. + // Counting it as a gap would blame an agent for a composer fault and + // leave a gap that never clears. + let reqs = owned(&[request_json(REQ_A, None)]); + let views: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let acc = account(&views, &[], Coverage::complete()); + assert!(acc.unanswered.is_empty()); + assert_eq!(acc.invalid_requests.len(), 1); + assert_eq!( + invalid_reason_slug(acc.invalid_requests[0].1), + "missing_agent_target" + ); + assert!(!acc.all_resolved()); + } + + #[test] + fn a_settled_obligation_is_not_reopened_by_a_later_error() { + // Terminal absorption, through the CLI's own adapter: the agent + // asserted `completed`, then a stray `errored` sorted after it. The + // obligation stays settled and the stray write is a warning. + let reqs = owned(&[request_json(REQ_A, Some(AGENT))]); + let disps = owned(&[ + disposition_json_at("d1", REQ_A, AGENT, "completed", 200), + disposition_json_at("d2", REQ_A, AGENT, "errored", 300), + ]); + let rv: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let dv: Vec> = disps.iter().map(OwnedEvent::view).collect(); + let acc = account(&rv, &dv, Coverage::complete()); + assert_eq!(acc.settled, vec![REQ_A.to_string()]); + assert!(acc.open.is_empty()); + assert!(acc.all_resolved()); + } + + #[test] + fn a_multi_target_request_is_unsupported_not_an_agent_gap() { + let mut req = request_json(REQ_A, Some(AGENT)); + req["tags"] + .as_array_mut() + .unwrap() + .push(serde_json::json!(["agent", HUMAN])); + req["tags"] + .as_array_mut() + .unwrap() + .push(serde_json::json!(["p", HUMAN])); + let reqs = owned(&[req]); + let views: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let acc = account(&views, &[], Coverage::complete()); + assert!(acc.unanswered.is_empty(), "never an agent gap"); + assert!(acc.invalid_requests.is_empty(), "not a malformed event"); + assert_eq!(acc.unsupported_requests.len(), 1); + assert_eq!( + unsupported_reason_slug(acc.unsupported_requests[0].1), + "multiple_agent_targets" + ); + assert!(!acc.all_resolved()); + } + + #[test] + fn a_responded_request_is_open_not_resolved() { + // The state that keeps the harness honest: the agent answered, but + // nothing asserted the work was done. + let reqs = owned(&[request_json(REQ_A, Some(AGENT))]); + let disps = owned(&[disposition_json("d1", REQ_A, AGENT, "responded")]); + let rv: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let dv: Vec> = disps.iter().map(OwnedEvent::view).collect(); + let acc = account(&rv, &dv, Coverage::complete()); + assert_eq!(acc.open, vec![REQ_A.to_string()]); + assert!(acc.settled.is_empty()); + assert!(!acc.all_resolved()); + } + + #[test] + fn a_disposition_from_a_merely_mentioned_human_leaves_the_request_unanswered() { + let reqs = owned(&[request_json(REQ_A, Some(AGENT))]); + let disps = owned(&[disposition_json("d1", REQ_A, HUMAN, "completed")]); + let rv: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let dv: Vec> = disps.iter().map(OwnedEvent::view).collect(); + let acc = account(&rv, &dv, Coverage::complete()); + assert_eq!(acc.unanswered, vec![REQ_A.to_string()]); + assert!(acc.settled.is_empty()); + } + + #[test] + fn accounting_is_never_claimed_complete_from_one_unpaginated_query() { + // `cmd_list` passes scope_complete=false. Even a perfectly clean + // channel must not report `all_resolved`, because the query pair + // carries no completeness guarantee. + let reqs = owned(&[request_json(REQ_A, Some(AGENT))]); + let disps = owned(&[disposition_json("d1", REQ_A, AGENT, "completed")]); + let rv: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let dv: Vec> = disps.iter().map(OwnedEvent::view).collect(); + + let partial = account(&rv, &dv, Coverage::partial()); + assert_eq!(partial.settled, vec![REQ_A.to_string()]); + assert!( + !partial.all_resolved(), + "an unpaginated read must never claim channel-wide resolution" + ); + assert!(account(&rv, &dv, Coverage::complete()).all_resolved()); + } + + #[test] + fn rendered_output_separates_every_category() { + let reqs = owned(&[request_json(REQ_A, Some(AGENT)), request_json(REQ_B, None)]); + let disps = owned(&[disposition_json("d1", REQ_A, AGENT, "completed")]); + let rv: Vec> = reqs.iter().map(OwnedEvent::view).collect(); + let dv: Vec> = disps.iter().map(OwnedEvent::view).collect(); + let acc = account(&rv, &dv, Coverage::complete()); + let out = render(&acc, vec![], CHANNEL, 0); + + assert_eq!(out["marked_requests"], 2); + assert_eq!(out["settled"][0], REQ_A); + assert_eq!(out["invalid_requests"][0]["request_id"], REQ_B); + assert_eq!(out["invalid_requests"][0]["reason"], "missing_agent_target"); + assert_eq!(out["all_resolved"], false); + // The legacy `all_answered` field is gone: it conflated "has any + // disposition" with "settled", so a channel of nothing but failures + // reported true. + assert!(out.get("all_answered").is_none()); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..1bdd2e304e4 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -569,6 +569,62 @@ pub struct SendMessageParams { pub broadcast: bool, pub files: Vec, pub mentions: Vec, + /// Target agent (hex or npub). `Some` makes this a NIP-AD request — see + /// NIP-AD.md's target-agent binding. Exactly one target in v1. + pub request_agent: Option, +} + +/// Canonicalize a `--request-agent` value to lowercase hex. +/// +/// Case matters: readers compare `agent` tags exactly, so an uppercase +/// target would produce a request no disposition could ever bind to. +fn canonical_request_agent(raw: &str) -> Result { + PublicKey::parse(raw.trim()) + .map_err(|_| CliError::Usage(format!("invalid --request-agent pubkey: {raw}"))) + .map(|pk| pk.to_hex()) +} + +/// Add the NIP-AD request marker and target-agent tag to `builder`. +/// +/// `Some(target)` adds `["t","request"]` (so gap detection finds the +/// request) plus exactly one `["agent", ]` (so a reader can verify +/// the disposition's signer was actually asked). Both derive from the same +/// value, so a marked request can never lack a target — a marked request +/// with no named target is unresolvable by anyone and would sit as a +/// permanent false gap. +/// +/// **Exactly one target in v1.** A request naming two agents has no single +/// well-defined outcome: either agent answering would discharge the other's +/// obligation, or both answering would look like a contradiction. The +/// verifier classifies multi-target markers as unsupported, so emitting one +/// would produce a request nobody can answer. +/// +/// The caller is responsible for also `p`-mentioning the target — see +/// [`cmd_send_message`], which adds it automatically, since `p` is what +/// routes the message to the agent at all. An `agent` tag without the +/// matching `p` names a target that never receives the request. +/// +/// Applies uniformly regardless of message kind — `buzz dispositions list`'s +/// gap detection currently only reads kind:9 stream messages, so the marker +/// is meaningful there today, but the CLI doesn't restrict which kind a +/// caller marks. +fn with_request_tag( + builder: nostr::EventBuilder, + request_agent: Option<&str>, +) -> Result { + let Some(agent) = request_agent else { + return Ok(builder); + }; + let hex = canonical_request_agent(agent)?; + Ok(builder + .tag( + nostr::Tag::parse(["t", "request"]) + .map_err(|e| CliError::Other(format!("invalid request tag: {e}")))?, + ) + .tag( + nostr::Tag::parse(["agent", &hex]) + .map_err(|e| CliError::Other(format!("invalid agent tag: {e}")))?, + )) } pub async fn cmd_send_message( @@ -586,6 +642,24 @@ pub async fn cmd_send_message( } let channel_uuid = parse_uuid(&p.channel_id)?; + // A `--request-agent` target is by definition someone this message is + // addressed to, so it mentions itself. `p` is what actually routes the + // message to the agent — an `agent` tag without it would name a target + // that never receives the request, producing a marked request nobody + // can answer and a gap that never clears. Requiring a separate + // `--mention` for the same pubkey would make that failure a matter of + // remembering a second flag. + if let Some(ref agent) = p.request_agent { + let hex = canonical_request_agent(agent)?; + if !p + .mentions + .iter() + .any(|m| PublicKey::parse(m.trim()).is_ok_and(|pk| pk.to_hex() == hex)) + { + p.mentions.push(hex); + } + } + let explicit_mentions = normalize_explicit_mentions(&p.mentions)?; let stripped = strip_code_regions(&p.content); let uri_pubkeys = extract_nostr_uris(&stripped); @@ -676,6 +750,7 @@ pub async fn cmd_send_message( ))) } }; + let builder = with_request_tag(builder, p.request_agent.as_deref())?; let event = client.sign_event(builder)?; let emitted_mentions = event_mention_pubkeys(&event); @@ -880,6 +955,7 @@ pub async fn dispatch( broadcast, files, mentions, + request_agent, } => { cmd_send_message( client, @@ -891,6 +967,7 @@ pub async fn dispatch( broadcast, files, mentions, + request_agent, }, ) .await @@ -995,7 +1072,7 @@ mod tests { use super::{ event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + resolve_names_to_pubkeys, with_request_tag, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1372,4 +1449,62 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + fn tagged(request_agent: Option<&str>) -> nostr::Event { + with_request_tag( + nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello"), + request_agent, + ) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign") + } + + fn tag_values<'a>(event: &'a nostr::Event, key: &str) -> Vec<&'a str> { + event + .tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + (s.first().map(String::as_str) == Some(key)).then(|| s[1].as_str()) + }) + .collect() + } + + #[test] + fn with_request_tag_adds_the_marker_and_exactly_one_agent_target() { + let event = tagged(Some(PK_VALID_A)); + assert!(event.tags.iter().any(|t| t.as_slice() == ["t", "request"])); + assert_eq!(tag_values(&event, "agent"), vec![PK_VALID_A]); + } + + #[test] + fn with_request_tag_leaves_message_unmarked_when_no_target() { + // Marker and target derive from one value, so a marked request can + // never lack a target — and an unmarked message never gains one. + let event = tagged(None); + assert!(tag_values(&event, "t").is_empty()); + assert!(tag_values(&event, "agent").is_empty()); + } + + #[test] + fn with_request_tag_canonicalizes_an_uppercase_target_to_lowercase_hex() { + // Readers compare `agent` values exactly, and the verifier rejects + // non-canonical hex outright — an uppercase target would otherwise + // produce a request no disposition could ever bind to. + let event = tagged(Some(&PK_VALID_A.to_ascii_uppercase())); + assert_eq!(tag_values(&event, "agent"), vec![PK_VALID_A]); + } + + #[test] + fn with_request_tag_rejects_a_malformed_target() { + let err = with_request_tag( + nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello"), + Some("not-a-pubkey"), + ); + assert!( + err.is_err(), + "a malformed target must not be silently dropped" + ); + } } diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index ad2c36e200c..6e6995a5cc0 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod agents; pub mod channel_templates; pub mod channels; +pub mod dispositions; pub mod dms; pub mod emoji; pub mod feed; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2b041da57b5..3039e696855 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -186,6 +186,9 @@ enum Cmd { /// Get and set channel canvas documents #[command(subcommand)] Canvas(CanvasCmd), + /// Emit and audit NIP-AD agent dispositions (kind:44300) + #[command(subcommand)] + Dispositions(DispositionsCmd), /// Add, remove, and list emoji reactions #[command(subcommand)] Reactions(ReactionsCmd), @@ -395,6 +398,20 @@ pub enum MessagesCmd { /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. #[arg(long = "mention")] mentions: Vec, + /// Mark this message as a NIP-AD request addressed to this agent + /// (hex or npub). Adds `["t","request"]`, one `["agent", ]`, + /// and the matching `p` mention automatically — `p` is what routes + /// the message to the agent, so an `agent` tag without it would name + /// a target that never receives the request. `buzz dispositions + /// list` can then detect an unanswered request AND verify that + /// whoever signs the disposition was actually asked; a disposition + /// from anyone else, including a merely `--mention`ed human, does + /// not resolve it. Exactly one target: v1 has no well-defined + /// outcome for a request naming several agents. Set automatically by + /// the desktop composer when a message @mentions an agent; pass + /// explicitly here for CLI parity. + #[arg(long = "request-agent")] + request_agent: Option, }, /// Send a code diff / patch to a channel SendDiff { @@ -726,6 +743,40 @@ pub enum CanvasCmd { }, } +#[derive(Subcommand)] +pub enum DispositionsCmd { + /// Record how you resolved a request (completed, refused, responded, or errored) + /// + /// The requester (`p` tag) and channel (`h` tag) are both derived by + /// looking up the request event — you only need its id. + Emit { + /// Event ID (64-char hex) of the request this disposition answers + #[arg(long)] + request: String, + /// One of: completed | refused | responded | errored + #[arg(long)] + disposition: String, + /// Why (required for refused; optional — defaults to empty — for + /// completed/errored) + #[arg(long)] + reason: Option, + }, + /// List a channel's dispositions with request/answer accounting + /// + /// Pairs kind:44300 dispositions to `["t","request"]`-marked messages by + /// their `e` tag and reports which marked requests remain unanswered. + /// `--state` filters CLIENT-side after the fetch (`#disposition` is not + /// a valid relay query filter — see NIP-AD.md). + List { + /// Channel UUID + #[arg(long)] + channel: String, + /// Narrow to one state: completed | refused | errored + #[arg(long)] + state: Option, + }, +} + #[derive(Subcommand)] pub enum ReactionsCmd { /// Add an emoji reaction to a message @@ -2042,6 +2093,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, + Cmd::Dispositions(sub) => commands::dispositions::dispatch(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, @@ -2149,6 +2201,7 @@ mod tests { "agents", "canvas", "channels", + "dispositions", "dms", "emoji", "feed", diff --git a/crates/buzz-core/src/agent_turn_metric.rs b/crates/buzz-core/src/agent_turn_metric.rs index cbf2dd24342..8b6268ac922 100644 --- a/crates/buzz-core/src/agent_turn_metric.rs +++ b/crates/buzz-core/src/agent_turn_metric.rs @@ -48,7 +48,7 @@ pub struct TokenCounts { /// NIP-AM: consumers MUST treat unrecognized `stopReason` values as `Unknown` /// and keep the token counts valid. Custom deserialization maps any unrecognized /// string to `Unknown` instead of failing the whole payload. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum StopReason { /// Model reached a natural end-of-turn. diff --git a/crates/buzz-core/src/disposition.rs b/crates/buzz-core/src/disposition.rs new file mode 100644 index 00000000000..fd2da944341 --- /dev/null +++ b/crates/buzz-core/src/disposition.rs @@ -0,0 +1,1582 @@ +//! NIP-AD: Agent Disposition — the shared verifier and lifecycle derivation. +//! +//! This module is the single authority for two questions every consumer asks: +//! +//! 1. **Is this a valid v1 request, and if so whose obligation is it?** +//! ([`classify_request`]) +//! 2. **Given a request and some candidate dispositions, what is its state?** +//! ([`derive_obligation`]) +//! +//! It exists because the previous implementation answered both questions +//! independently in `buzz-cli` and in the desktop timeline, and the two +//! answers drifted — different candidate sets, different binding rules, +//! different conflict semantics. A protocol whose whole purpose is +//! verifiable accountability cannot have two first-party consumers that +//! disagree about what was verified. The TypeScript mirror lives at +//! `desktop/src/shared/lib/disposition.ts`, and both are pinned to the same +//! JSON corpus (`docs/nips/nip-ad-conformance.json`) so they cannot drift +//! again silently. +//! +//! Zero I/O: everything here is a pure function over already-fetched events, +//! so it can run in the relay, the CLI, a test, or an external auditor. +//! +//! See `docs/nips/NIP-AD.md`. + +use serde::{Deserialize, Serialize}; + +/// The one obligation a marked request creates in v1. +/// +/// v1 deliberately requires **exactly one** agent target, which is what makes +/// this key unambiguous. A multi-target request has no single well-defined +/// outcome — two agents legitimately answering would either look like a +/// conflict or let one agent's answer discharge another's obligation — so v1 +/// classifies those as unsupported rather than guessing. When per-agent +/// splitting arrives, this becomes one obligation per target and the rest of +/// the module is unchanged. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Obligation { + /// Event id of the request message. + pub request_id: String, + /// Channel the request lives in (its `h` tag). + pub channel_id: String, + /// Who asked (the request's author). + pub requester_pubkey: String, + /// The single agent expected to answer. + pub target_agent_pubkey: String, +} + +/// Why a `["t","request"]`-marked event is malformed. +/// +/// These are NOT agent failures and MUST NOT be reported as unanswered +/// requests. An event nobody can validly answer is a client bug, and counting +/// it as a gap would blame an agent for it — a permanent false gap that never +/// clears. +/// +/// Distinct from [`UnsupportedRequest`]: these events are *wrong*, whereas an +/// unsupported one expresses a coherent intent v1 cannot yet represent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvalidRequest { + /// Marked as a request but names no agent — nothing was asked of anyone. + MissingAgentTarget, + /// An `agent` tag that isn't canonical 64-char lowercase hex. + MalformedAgentTarget, + /// No `h` tag, so the request isn't scoped to a channel. + MissingChannel, + /// More than one `h` tag. One channel would govern authorization while + /// another still rode along for tag matching — the same ambiguity kind + /// 44300 rejects on the disposition side, rejected here too. + MultipleChannels, + /// The named agent isn't also `p`-mentioned, so it was never addressed + /// in the way that actually routes a message to it. + TargetNotMentioned, + /// The same `agent` target repeated. Canonical events keep independent + /// implementations and audit output simple, so a duplicate is rejected + /// rather than quietly folded into one target. + DuplicateAgentTarget, + /// The event's kind is not one v1 accepts requests on. Without this the + /// CLI and the desktop can fetch different candidate universes and + /// legitimately disagree about the same channel. + UnsupportedKind, +} + +/// A well-formed request whose intent v1 cannot represent. +/// +/// Kept separate from [`InvalidRequest`] because the remedy differs: an +/// invalid request is a bug to fix, an unsupported one is a feature to build. +/// Both block a clean channel claim; only one implies anybody did anything +/// wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnsupportedRequest { + /// Names two or more distinct agents. The event cannot say whether either + /// target may discharge one shared obligation, both owe independent + /// answers, or one owns it and the other is consulted — so v1 refuses to + /// guess. See [`Obligation`]. + MultipleAgentTargets, +} + +/// Result of classifying any event. Total: every event lands in exactly one +/// arm, so no caller needs a marker pre-check of its own. +/// +/// Totality is the point. An earlier version required callers to check the +/// marker first and classify second, and the ACP harness grew its own +/// looser predicate instead — it accepted uppercase targets, multi-target +/// requests, and targets that were never `p`-mentioned, so it would do real +/// work and publish dispositions for events every consumer called invalid. +/// One total function with no precondition removes the temptation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequestClass { + /// Not marked `["t","request"]` — ordinary traffic, no obligation. + NotRequest, + /// Marked but malformed — see [`InvalidRequest`]. + Invalid(InvalidRequest), + /// Marked and well-formed, but not representable in v1. + Unsupported(UnsupportedRequest), + /// A usable v1 obligation. + Valid(Box), +} + +impl RequestClass { + /// The obligation, when this is a valid request. + pub fn obligation(&self) -> Option<&Obligation> { + match self { + Self::Valid(o) => Some(o), + _ => None, + } + } +} + +/// A disposition state. Terminal states settle an obligation; non-terminal +/// ones leave it open. +/// +/// `Responded` is the honest counterpart to a bare runtime end-of-turn: the +/// agent produced a response, which is all the harness can observe. It is +/// deliberately NOT `Completed` — a clean turn covers asking a clarifying +/// question, reporting it couldn't finish, or answering one message of a +/// batch, none of which accomplished the request. `Completed` requires an +/// explicit per-request assertion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DispositionState { + /// Explicitly asserted done. Terminal. + Completed, + /// Explicitly declined, with a reason. Terminal. + Refused, + /// The agent responded; no completion was asserted. Non-terminal. + Responded, + /// Technical failure — the turn did not end cleanly. Non-terminal. + Errored, +} + +impl DispositionState { + /// The wire value carried in the `disposition` tag and `content`. + pub fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Refused => "refused", + Self::Responded => "responded", + Self::Errored => "errored", + } + } + + /// Parse a wire value. Unknown values are rejected rather than coerced — + /// a disposition whose state we don't understand must not be silently + /// treated as some other state. + pub fn parse(value: &str) -> Option { + match value { + "completed" => Some(Self::Completed), + "refused" => Some(Self::Refused), + "responded" => Some(Self::Responded), + "errored" => Some(Self::Errored), + _ => None, + } + } + + /// Whether this state settles the obligation. + /// + /// `Errored` and `Responded` are non-terminal: both mean "something + /// happened, the request may still reach a settled outcome." This is + /// what makes `errored → completed` and `responded → completed` legal + /// repairs rather than contradictions. + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Refused) + } + + /// Every valid wire value, for validators and tests. + pub const ALL: [Self; 4] = [ + Self::Completed, + Self::Refused, + Self::Responded, + Self::Errored, + ]; +} + +/// Something worth surfacing about a history that does not change its +/// outcome. +/// +/// Warnings are strictly diagnostic. An earlier version made every one of +/// these force the obligation out of its settled state and render as +/// "disputed", which defeated the point of categorizing them: a duplicate +/// delivery and a genuine contradiction produced identical, equally +/// destructive results. Only [`EffectiveOutcome::Disputed`] — two *opposing* +/// terminal claims — is a real dispute. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HistoryWarning { + /// The same terminal state recorded more than once by distinct events. + /// Redundant, not contradictory — usually a retry that re-published. + DuplicateTerminal, + /// A **non-terminal** disposition sorts after a terminal one under + /// `(created_at, id)` — a stale or late weak observation. + /// + /// Deliberately **not** named for a causal claim. The ordering is + /// deterministic but publisher-supplied, so this cannot establish that + /// anything was actually *written after* settlement — only that it sorts + /// later. Naming it `PostTerminalWrite` (as an earlier version did) told + /// users something the algorithm cannot know; a real causal claim needs + /// a trusted receive sequence or an attempt ordinal. + /// + /// Scoped to non-terminal followers so the warnings never overlap: a + /// terminal after a terminal is already fully described by + /// [`Self::DuplicateTerminal`] (same state) or by + /// [`EffectiveOutcome::Disputed`] (opposing states). Two warnings for one + /// event is noise, and noise is what made the previous single `conflict` + /// boolean useless. + OrderedAfterTerminal, +} + +/// What actually became of an obligation. +/// +/// Separate from the raw latest observation on purpose. Deriving state as +/// "latest event wins" made `terminal` a lie: a late `errored` silently +/// reopened a settled obligation. Here terminal claims are **absorbing** — +/// once something settles, a later weaker observation is recorded as a +/// warning and cannot reopen it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "state")] +pub enum EffectiveOutcome { + /// Nothing bound at all — a real gap. + Unanswered, + /// Answered, not settled. Carries `Responded` or `Errored`. + Open(DispositionState), + /// Settled. Carries `Completed` or `Refused`. + Settled(DispositionState), + /// Both terminal states claimed. Genuinely contradictory; no settled + /// answer exists. + Disputed, +} + +impl EffectiveOutcome { + /// The obligation is done. Only [`Self::Settled`] qualifies — a dispute + /// is not a resolution, and neither is an open state. + pub fn is_settled(self) -> bool { + matches!(self, Self::Settled(_)) + } + + /// Something bound but the obligation is not settled. + pub fn is_open(self) -> bool { + matches!(self, Self::Open(_)) + } +} + +/// An obligation's derived history. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedObligation { + /// The obligation this was derived for. + pub obligation: Obligation, + /// What became of it, under terminal-absorbing semantics. This is what + /// every accounting and display surface must use. + pub outcome: EffectiveOutcome, + /// The last bound state by `(created_at, id)`, regardless of absorption. + /// Retained for audit and debugging — it answers "what arrived last", + /// which is a different question from "what is true". Never use it to + /// decide whether an obligation is done. + pub latest_observation: Option, + /// Reason from the disposition that determined [`Self::outcome`] — the + /// settling event when settled, otherwise the latest bound one. A + /// refusal's stated reason must survive a later stray write. + pub reason: String, + /// Diagnostics that do not change the outcome, deduplicated and ordered. + pub warnings: Vec, +} + +impl DerivedObligation { + /// Settled. The only condition under which a surface may claim done. + pub fn is_resolved(&self) -> bool { + self.outcome.is_settled() + } + + /// Answered but not settled. + pub fn is_open(&self) -> bool { + self.outcome.is_open() + } + + /// Nothing bound at all: a real gap. + pub fn is_unanswered(&self) -> bool { + matches!(self.outcome, EffectiveOutcome::Unanswered) + } + + /// Contradictory terminal claims. + pub fn is_disputed(&self) -> bool { + matches!(self.outcome, EffectiveOutcome::Disputed) + } +} + +/// Minimal view of a Nostr event this module needs. Callers adapt from +/// `nostr::Event`, `serde_json::Value`, or a test fixture — keeping this +/// module free of any particular event representation is what lets the relay, +/// the CLI, and an external auditor share one verifier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventView<'a> { + /// Event id (64 lowercase hex). + pub id: &'a str, + /// Signing pubkey (64 lowercase hex). + pub pubkey: &'a str, + /// Event kind. Required so request classification can reject kinds v1 + /// does not accept requests on — without it, two consumers querying + /// different kind sets produce different accounting for one channel and + /// both look correct. + pub kind: u16, + /// Whole-second Nostr timestamp. + pub created_at: i64, + /// Raw event content. + pub content: &'a str, + /// Tag rows, each `[key, value, ...]`. + pub tags: &'a [Vec], +} + +impl<'a> EventView<'a> { + /// Values borrow from the event's own data (`'a`), not from `&self`, so + /// callers can hold them past the borrow of the view. + fn tag_values<'s>(&'s self, key: &'s str) -> impl Iterator + 's { + self.tags + .iter() + .filter(move |t| t.len() >= 2 && t[0] == key) + .map(|t| t[1].as_str()) + } + + fn first_tag(&self, key: &str) -> Option<&'a str> { + self.tag_values(key).next() + } + + fn has_tag_pair(&self, key: &str, value: &str) -> bool { + self.tag_values(key).any(|v| v == value) + } +} + +/// Canonical 64-character lowercase hex. +/// +/// Case is part of the contract, not a formatting preference: the harness and +/// the readers compare these values, and a case-insensitive writer paired +/// with a case-sensitive reader silently produces dispositions that no one +/// binds. One canonical form, compared exactly, everywhere. +pub fn is_canonical_hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Event kinds v1 accepts `["t","request"]` markers on. +/// +/// Centralized so every query builder and every classifier derives its +/// candidate universe from one list. The CLI previously queried only +/// `KIND_STREAM_MESSAGE` while the desktop timeline carried several message +/// and job kinds, so even identical derivation logic could produce different +/// accounting for the same channel. +pub const REQUEST_KINDS: [u16; 1] = [crate::kind::KIND_STREAM_MESSAGE as u16]; + +/// Whether v1 accepts a request marker on this kind. +pub fn is_request_kind(kind: u16) -> bool { + REQUEST_KINDS.contains(&kind) +} + +/// Whether an event carries the NIP-AD request marker. +/// +/// A marker check alone says nothing about validity — prefer +/// [`classify_request`], which is total. +pub fn is_marked_request(event: &EventView<'_>) -> bool { + event.has_tag_pair("t", "request") +} + +/// Classify any event. Total — no precondition, no caller-side marker check. +/// +/// The order of checks is deliberate: kind and channel first (an event on the +/// wrong kind or with an ambiguous channel is unusable regardless of who it +/// names), then targeting. +pub fn classify_request(event: &EventView<'_>) -> RequestClass { + if !is_marked_request(event) { + return RequestClass::NotRequest; + } + if !is_request_kind(event.kind) { + return RequestClass::Invalid(InvalidRequest::UnsupportedKind); + } + + let channels: Vec<&str> = event.tag_values("h").collect(); + let channel_id = match channels.as_slice() { + [] => return RequestClass::Invalid(InvalidRequest::MissingChannel), + [single] => *single, + _ => return RequestClass::Invalid(InvalidRequest::MultipleChannels), + }; + + let agents: Vec<&str> = event.tag_values("agent").collect(); + if agents.is_empty() { + return RequestClass::Invalid(InvalidRequest::MissingAgentTarget); + } + + // **Malformed beats unsupported.** Every target is validated before + // cardinality is judged, so a request naming one real agent and one + // garbage value is a malformed event — not "a feature v1 cannot + // represent". Returning Unsupported first (as an earlier version did) + // filed client bugs under "not anyone's fault" and told the sender their + // perfectly reasonable request needed a future protocol version. + // + // `p`-mention is checked in the same pass: `p` is what actually routes a + // message to a principal, so an `agent` tag without it names a target + // that will never be asked — an obligation nobody could ever discharge. + for target in &agents { + if !is_canonical_hex64(target) { + return RequestClass::Invalid(InvalidRequest::MalformedAgentTarget); + } + if !event.has_tag_pair("p", target) { + return RequestClass::Invalid(InvalidRequest::TargetNotMentioned); + } + } + + let mut unique: Vec<&str> = agents.clone(); + unique.sort_unstable(); + unique.dedup(); + let target = match (agents.len(), unique.len()) { + (1, _) => agents[0], + // Distinct, well-formed, reachable targets: a coherent intent v1 + // cannot represent. + (_, u) if u > 1 => { + return RequestClass::Unsupported(UnsupportedRequest::MultipleAgentTargets) + } + // One target, written twice. Rejected rather than folded: this + // document promises exactly one `agent` tag, and canonical events + // keep independent implementations and audit output simple. + _ => return RequestClass::Invalid(InvalidRequest::DuplicateAgentTarget), + }; + + RequestClass::Valid(Box::new(Obligation { + request_id: event.id.to_string(), + channel_id: channel_id.to_string(), + requester_pubkey: event.pubkey.to_string(), + target_agent_pubkey: target.to_string(), + })) +} + +/// Why an event is not a structurally valid kind:44300 disposition. +/// +/// Every variant corresponds to a rule in NIP-AD's "Event" and "Content" +/// sections: if the NIP states a structural requirement, it is enforced in +/// [`validate_disposition_event`] and named here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvalidDisposition { + /// Not kind 44300 at all. + WrongKind, + /// Not exactly one `e` tag. + RequestIdCardinality, + /// Not exactly one `h` tag. + ChannelCardinality, + /// Not exactly one `p` tag. + RequesterCardinality, + /// Not exactly one `disposition` tag. + StateCardinality, + /// The `e` value is not canonical 64-char lowercase hex. + MalformedRequestId, + /// The `p` value is not canonical 64-char lowercase hex. + MalformedRequester, + /// The `disposition` tag value is not a recognized state. + UnknownState, + /// `content` is not parseable JSON, or is not an object. + ContentNotObject, + /// `content.disposition` is missing or disagrees with the tag. + ContentStateMismatch, + /// `content.reason` is absent. + MissingReason, + /// `content.reason` is present but not a string. + ReasonNotString, + /// `content.request_id` is present but not a string. + RequestIdNotString, + /// `content.request_id` disagrees with the `e` tag. + ContentRequestIdMismatch, +} + +/// A structurally valid kind:44300 event, with its fields extracted once. +/// +/// Holding this value is proof that the full NIP-AD envelope and content +/// contract was checked. Nothing may contribute to an obligation's state +/// without one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalDisposition<'a> { + /// The disposition event's own id. + pub event_id: &'a str, + /// Who signed it. + pub signer: &'a str, + /// Whole-second Nostr timestamp. + pub created_at: i64, + /// The request it answers (`e`). + pub request_id: &'a str, + /// The channel it is scoped to (`h`). + pub channel_id: &'a str, + /// The requesting principal it names (`p`). + pub requester_pubkey: &'a str, + /// The state, agreed between tag and content. + pub state: DispositionState, + /// `content.reason` — required to be present, permitted to be empty. + pub reason: String, +} + +/// Validate an event against the **complete** NIP-AD structural contract. +/// +/// This is the single validity boundary. The relay calls it at ingest, and +/// every consumer calls it (via [`bind_disposition`]) before an event may +/// affect any obligation. That sharing is the point, not an optimization. +/// +/// It exists because the two sides had drifted apart in the worst possible +/// direction. The relay enforced kind, exact `e`/`h`/`p`/`disposition` +/// cardinality, and a required string `reason`; the consumer-side binder +/// checked none of them — it read the *first* matching tag and ignored the +/// rest. An event carrying two `e` tags, or no `reason`, or **not even of +/// kind 44300**, was rejected by the relay and simultaneously accepted as a +/// settling disposition by the verifier this module advertises for auditing +/// imported history where no relay stood in the way. Signature verification +/// does not help: a valid signature can cover a perfectly well-signed +/// non-disposition. +pub fn validate_disposition_event<'a>( + event: &EventView<'a>, +) -> Result, InvalidDisposition> { + if event.kind != crate::kind::KIND_AGENT_DISPOSITION as u16 { + return Err(InvalidDisposition::WrongKind); + } + + // Exactly one of each. `first_tag` semantics are deliberately not used + // here: silently taking the first of several is precisely how the two + // validators came to disagree. + let exactly_one = |key: &str, err: InvalidDisposition| -> Result<&'a str, InvalidDisposition> { + let mut values = event.tag_values(key); + let first = values.next().ok_or(err)?; + if values.next().is_some() { + return Err(err); + } + Ok(first) + }; + + let request_id = exactly_one("e", InvalidDisposition::RequestIdCardinality)?; + let channel_id = exactly_one("h", InvalidDisposition::ChannelCardinality)?; + let requester_pubkey = exactly_one("p", InvalidDisposition::RequesterCardinality)?; + let state_value = exactly_one("disposition", InvalidDisposition::StateCardinality)?; + + if !is_canonical_hex64(request_id) { + return Err(InvalidDisposition::MalformedRequestId); + } + if !is_canonical_hex64(requester_pubkey) { + return Err(InvalidDisposition::MalformedRequester); + } + let state = DispositionState::parse(state_value).ok_or(InvalidDisposition::UnknownState)?; + + let body: serde_json::Value = + serde_json::from_str(event.content).map_err(|_| InvalidDisposition::ContentNotObject)?; + let body = body + .as_object() + .ok_or(InvalidDisposition::ContentNotObject)?; + + if body.get("disposition").and_then(|v| v.as_str()) != Some(state.as_str()) { + return Err(InvalidDisposition::ContentStateMismatch); + } + let reason = match body.get("reason") { + Some(v) => v + .as_str() + .ok_or(InvalidDisposition::ReasonNotString)? + .to_string(), + None => return Err(InvalidDisposition::MissingReason), + }; + if let Some(rid) = body.get("request_id") { + let rid = rid.as_str().ok_or(InvalidDisposition::RequestIdNotString)?; + if rid != request_id { + return Err(InvalidDisposition::ContentRequestIdMismatch); + } + } + + Ok(CanonicalDisposition { + event_id: event.id, + signer: event.pubkey, + created_at: event.created_at, + request_id, + channel_id, + requester_pubkey, + state, + reason, + }) +} + +/// Why a candidate disposition does not bind to an obligation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindFailure { + /// The event is not a structurally valid disposition at all. + Invalid(InvalidDisposition), + /// `e` tag missing or not this obligation's request. + RequestMismatch, + /// `h` tag missing or a different channel than the request's. + ChannelMismatch, + /// `p` tag missing or not the request's actual author. + RequesterMismatch, + /// Signed by someone other than the obligation's target agent. This is + /// the cross-principal spoof check. + NotTargetAgent, +} + +/// Whether `disposition` validly answers `obligation`. +/// +/// Two stages, in this order: +/// +/// 1. **Structural validity** — [`validate_disposition_event`]. An event that +/// is not a canonical kind:44300 disposition can never contribute to an +/// obligation, whatever it claims. This stage used to be missing entirely +/// here while the relay enforced it, so the two sides disagreed about what +/// a disposition even *is*. +/// 2. **Binding** — does this specific valid disposition answer this specific +/// obligation? Request, channel, requester, and target-agent signer all +/// have to match. The target-agent clause is the cross-principal check: +/// without it any channel member, including a merely `p`-mentioned human, +/// can close an agent's obligation. +/// +/// The relay performs stage 1 and deliberately not stage 2 — binding would +/// require fetching a second event mid-validation. So every consumer must do +/// stage 2, identically, which is why this function exists rather than three +/// near-copies. +pub fn bind_disposition( + obligation: &Obligation, + disposition: &EventView<'_>, +) -> Result { + let canonical = validate_disposition_event(disposition).map_err(BindFailure::Invalid)?; + bind_canonical(obligation, &canonical) +} + +/// Stage 2 alone, for callers that already validated. +pub fn bind_canonical( + obligation: &Obligation, + disposition: &CanonicalDisposition<'_>, +) -> Result { + if disposition.request_id != obligation.request_id { + return Err(BindFailure::RequestMismatch); + } + if disposition.channel_id != obligation.channel_id { + return Err(BindFailure::ChannelMismatch); + } + if disposition.requester_pubkey != obligation.requester_pubkey { + return Err(BindFailure::RequesterMismatch); + } + if disposition.signer != obligation.target_agent_pubkey { + return Err(BindFailure::NotTargetAgent); + } + Ok(disposition.state) +} + +fn reason_of(disposition: &EventView<'_>) -> String { + serde_json::from_str::(disposition.content) + .ok() + .and_then(|v| { + v.get("reason") + .and_then(|r| r.as_str()) + .map(std::string::ToString::to_string) + }) + .unwrap_or_default() +} + +/// Derive an obligation's state from candidate dispositions. +/// +/// Candidates are filtered by [`bind_disposition`], deduplicated by event id +/// (a relay result merged from several queries can legitimately contain the +/// same event twice — without this, one duplicate delivery reads as a +/// disputed outcome), then ordered by `(created_at, id)`. +/// +/// **That ordering is deterministic, not causal.** `created_at` is +/// whole-second and publisher-supplied, so a later publication can carry an +/// earlier timestamp. The anomalies below therefore detect *timestamp-order* +/// contradictions, which is a strictly weaker claim than detecting a real +/// post-terminal write. Under v1's single authoritative writer per +/// obligation, the two coincide in practice; a system with genuinely +/// concurrent writers would need an explicit attempt ordinal instead. +pub fn derive_obligation( + obligation: &Obligation, + candidates: &[EventView<'_>], +) -> DerivedObligation { + let mut bound: Vec<(&EventView<'_>, DispositionState)> = Vec::new(); + let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for candidate in candidates { + let Ok(state) = bind_disposition(obligation, candidate) else { + continue; + }; + if !seen_ids.insert(candidate.id) { + continue; + } + bound.push((candidate, state)); + } + + bound.sort_by(|a, b| (a.0.created_at, a.0.id).cmp(&(b.0.created_at, b.0.id))); + + let latest_observation = bound.last().map(|(_, s)| *s); + let has_completed = bound.iter().any(|(_, s)| *s == DispositionState::Completed); + let has_refused = bound.iter().any(|(_, s)| *s == DispositionState::Refused); + + // Terminal-absorbing. The first terminal claim decides the outcome; a + // later non-terminal observation is a warning, never a reopening. + let first_terminal = bound.iter().position(|(_, s)| s.is_terminal()); + let outcome = match (has_completed, has_refused, first_terminal) { + (true, true, _) => EffectiveOutcome::Disputed, + (_, _, Some(idx)) => EffectiveOutcome::Settled(bound[idx].1), + (_, _, None) => match latest_observation { + Some(state) => EffectiveOutcome::Open(state), + None => EffectiveOutcome::Unanswered, + }, + }; + + let mut warnings = Vec::new(); + // "Duplicate" means the SAME terminal state twice. Counting any two + // terminals would fire on `completed` + `refused`, which is a + // contradiction (already `Disputed`), not a duplicate. + let duplicated = [DispositionState::Completed, DispositionState::Refused] + .iter() + .any(|state| bound.iter().filter(|(_, s)| s == state).count() > 1); + if duplicated { + warnings.push(HistoryWarning::DuplicateTerminal); + } + if let Some(idx) = first_terminal { + if bound[idx + 1..].iter().any(|(_, s)| !s.is_terminal()) { + warnings.push(HistoryWarning::OrderedAfterTerminal); + } + } + + // The settling event's reason, not the latest event's — a stray write + // after a refusal must not blank out why the agent refused. + let reason = match first_terminal { + Some(idx) if !matches!(outcome, EffectiveOutcome::Disputed) => reason_of(bound[idx].0), + _ => bound.last().map(|(e, _)| reason_of(e)).unwrap_or_default(), + }; + + DerivedObligation { + obligation: obligation.clone(), + outcome, + latest_observation, + reason, + warnings, + } +} + +/// How much of the record a caller actually examined. +/// +/// Both sides are required, which an earlier single `scope_complete` flag +/// got wrong: a caller could paginate every request, fetch one page of +/// dispositions, see a `completed`, miss the later `refused` on page two, +/// and truthfully set the old flag while reporting a disputed obligation as +/// resolved. Completeness of the request set alone proves nothing about the +/// completeness of any obligation's history. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Coverage { + /// Every marked request in scope was fetched. + pub requests_complete: bool, + /// Every disposition for those requests was fetched. + pub dispositions_complete: bool, +} + +impl Coverage { + /// Both sides complete. Only then may a channel-wide claim be made. + pub fn is_complete(self) -> bool { + self.requests_complete && self.dispositions_complete + } + + /// Coverage a partial reader (one page, a loaded UI window) must use. + pub fn partial() -> Self { + Self::default() + } + + /// Coverage a reader that paginated both sides to exhaustion may use. + pub fn complete() -> Self { + Self { + requests_complete: true, + dispositions_complete: true, + } + } +} + +/// A stored disposition that named an obligation but did not bind to it. +/// +/// Excluded from state — that is the whole point of binding — but reported +/// separately so an auditor can see spoof attempts rather than having them +/// silently vanish. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RejectedClaim { + /// The unbound disposition's event id. + pub event_id: String, + /// The request it referenced via `e`. + pub referenced_request: String, + /// Who signed it. + pub signer: String, + /// Why it did not bind. + pub failure: BindFailure, +} + +/// Accounting over a set of requests, with its coverage stated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Accounting { + /// Settled obligations. + pub settled: Vec, + /// Answered, not settled (`responded` or `errored`). + pub open: Vec, + /// Nothing bound at all — a real gap. + pub unanswered: Vec, + /// Contradictory terminal claims. + pub disputed: Vec, + /// Malformed marked events, with why. Client faults, never agent + /// failures. + pub invalid_requests: Vec<(String, InvalidRequest)>, + /// Well-formed marked events v1 cannot represent, with why. Not anyone's + /// fault; still blocks a clean claim. + pub unsupported_requests: Vec<(String, UnsupportedRequest)>, + /// Unbound dispositions, for diagnostics only. + pub rejected_claims: Vec, + /// What the caller actually examined. + pub coverage: Coverage, +} + +impl Accounting { + /// Every request examined is settled, over provably complete coverage. + /// The only condition under which a surface may claim the channel is + /// clean. + /// + /// `invalid_requests` and `unsupported_requests` block this too, which is + /// easy to miss: neither is an agent failure, but both are marked events + /// nobody can ever answer. Reporting such a channel as clean hides the + /// problem behind a green check. + pub fn all_resolved(&self) -> bool { + self.coverage.is_complete() + && self.open.is_empty() + && self.unanswered.is_empty() + && self.disputed.is_empty() + && self.invalid_requests.is_empty() + && self.unsupported_requests.is_empty() + } + + /// Requests examined, across every bucket. + pub fn total(&self) -> usize { + self.settled.len() + + self.open.len() + + self.unanswered.len() + + self.disputed.len() + + self.invalid_requests.len() + + self.unsupported_requests.len() + } +} + +/// Build accounting from candidate requests and dispositions. +/// +/// `requests` may contain any events — classification is total, and +/// non-requests are ignored. Dispositions are grouped by `e` tag once, so +/// this is O(requests + dispositions) rather than re-scanning every +/// disposition for every request: any channel member can store structurally +/// valid but unbound dispositions, so the quadratic version was an avoidable +/// denial-of-service surface. +pub fn account( + requests: &[EventView<'_>], + dispositions: &[EventView<'_>], + coverage: Coverage, +) -> Accounting { + let mut acc = Accounting { + settled: Vec::new(), + open: Vec::new(), + unanswered: Vec::new(), + disputed: Vec::new(), + invalid_requests: Vec::new(), + unsupported_requests: Vec::new(), + rejected_claims: Vec::new(), + coverage, + }; + + let mut by_request: std::collections::HashMap<&str, Vec>> = + std::collections::HashMap::new(); + for d in dispositions { + if let Some(e) = d.first_tag("e") { + by_request.entry(e).or_default().push(d.clone()); + } + } + let empty: Vec> = Vec::new(); + + for request in requests { + match classify_request(request) { + RequestClass::NotRequest => {} + RequestClass::Invalid(reason) => { + acc.invalid_requests.push((request.id.to_string(), reason)); + } + RequestClass::Unsupported(reason) => { + acc.unsupported_requests + .push((request.id.to_string(), reason)); + } + RequestClass::Valid(obligation) => { + let candidates = by_request.get(request.id).unwrap_or(&empty); + let derived = derive_obligation(&obligation, candidates); + let id = obligation.request_id.clone(); + match derived.outcome { + EffectiveOutcome::Settled(_) => acc.settled.push(id), + EffectiveOutcome::Open(_) => acc.open.push(id), + EffectiveOutcome::Unanswered => acc.unanswered.push(id), + EffectiveOutcome::Disputed => acc.disputed.push(id), + } + for candidate in candidates { + if let Err(failure) = bind_disposition(&obligation, candidate) { + acc.rejected_claims.push(RejectedClaim { + event_id: candidate.id.to_string(), + referenced_request: obligation.request_id.clone(), + signer: candidate.pubkey.to_string(), + failure, + }); + } + } + } + } + } + + acc +} + +#[cfg(test)] +mod tests { + use super::*; + + const AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_AGENT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const HUMAN: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const REQUESTER: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const CHANNEL: &str = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + const REQ_ID: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + + fn tags(pairs: &[(&str, &str)]) -> Vec> { + pairs + .iter() + .map(|(k, v)| vec![(*k).to_string(), (*v).to_string()]) + .collect() + } + + fn request_tags() -> Vec> { + tags(&[ + ("h", CHANNEL), + ("t", "request"), + ("agent", AGENT), + ("p", AGENT), + ("p", HUMAN), + ]) + } + + fn request<'a>(tags: &'a [Vec]) -> EventView<'a> { + EventView { + id: REQ_ID, + pubkey: REQUESTER, + kind: REQUEST_KINDS[0], + created_at: 100, + content: "@agent do the thing", + tags, + } + } + + fn disposition_tags(state: &str) -> Vec> { + tags(&[ + ("e", REQ_ID), + ("h", CHANNEL), + ("p", REQUESTER), + ("disposition", state), + ]) + } + + fn disposition<'a>( + id: &'a str, + signer: &'a str, + created_at: i64, + tags: &'a [Vec], + content: &'a str, + ) -> EventView<'a> { + EventView { + id, + pubkey: signer, + kind: crate::kind::KIND_AGENT_DISPOSITION as u16, + created_at, + content, + tags, + } + } + + fn valid_obligation() -> Obligation { + Obligation { + request_id: REQ_ID.to_string(), + channel_id: CHANNEL.to_string(), + requester_pubkey: REQUESTER.to_string(), + target_agent_pubkey: AGENT.to_string(), + } + } + + #[test] + fn a_canonical_request_classifies_to_one_obligation() { + let t = request_tags(); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Valid(Box::new(valid_obligation())) + ); + } + + #[test] + fn a_request_naming_no_agent_is_invalid_not_an_obligation() { + // Must never be counted as an unanswered agent failure: nothing was + // asked of anyone, so no agent can ever clear it. + let t = tags(&[("h", CHANNEL), ("t", "request"), ("p", HUMAN)]); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Invalid(InvalidRequest::MissingAgentTarget) + ); + } + + #[test] + fn a_multi_target_request_is_unsupported_in_v1() { + // Two agents legitimately answering would either read as a conflict + // or let one discharge the other's obligation. v1 refuses to guess. + let t = tags(&[ + ("h", CHANNEL), + ("t", "request"), + ("agent", AGENT), + ("agent", OTHER_AGENT), + ("p", AGENT), + ("p", OTHER_AGENT), + ]); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Unsupported(UnsupportedRequest::MultipleAgentTargets) + ); + } + + #[test] + fn a_repeated_identical_target_is_rejected_as_non_canonical() { + // Previously folded into one target. The prose promises exactly one + // `agent` tag; accepting a duplicate made the prose false and left + // independent implementations to guess. + let t = tags(&[ + ("h", CHANNEL), + ("t", "request"), + ("agent", AGENT), + ("agent", AGENT), + ("p", AGENT), + ]); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Invalid(InvalidRequest::DuplicateAgentTarget) + ); + } + + #[test] + fn an_unmarked_event_is_not_a_request_at_all() { + // Totality: no caller needs its own marker pre-check, which is how + // the harness ended up with a looser predicate of its own. + let t = tags(&[("h", CHANNEL), ("agent", AGENT), ("p", AGENT)]); + assert_eq!(classify_request(&request(&t)), RequestClass::NotRequest); + } + + #[test] + fn a_marker_on_an_unsupported_kind_is_invalid() { + let t = request_tags(); + let mut view = request(&t); + view.kind = crate::kind::KIND_AGENT_DISPOSITION as u16; + assert_eq!( + classify_request(&view), + RequestClass::Invalid(InvalidRequest::UnsupportedKind) + ); + } + + #[test] + fn two_channel_tags_are_invalid_not_first_wins() { + // The same ambiguity kind 44300 rejects on the disposition side: one + // channel would govern authorization while the other still rode + // along for tag matching. + let t = tags(&[ + ("h", CHANNEL), + ("h", "99999999-0e2d-4cfe-bd6e-567eb169db9f"), + ("t", "request"), + ("agent", AGENT), + ("p", AGENT), + ]); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Invalid(InvalidRequest::MultipleChannels) + ); + } + + #[test] + fn an_uppercase_target_is_malformed_not_silently_accepted() { + // The case that previously made the harness emit events no reader + // would bind: harness compared case-insensitively, readers exactly. + let upper = AGENT.to_ascii_uppercase(); + let t = tags(&[ + ("h", CHANNEL), + ("t", "request"), + ("agent", &upper), + ("p", &upper), + ]); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Invalid(InvalidRequest::MalformedAgentTarget) + ); + } + + #[test] + fn a_target_that_is_not_p_mentioned_is_invalid() { + // `p` is what routes the message. An `agent` tag without it names a + // target that never receives the request. + let t = tags(&[ + ("h", CHANNEL), + ("t", "request"), + ("agent", AGENT), + ("p", HUMAN), + ]); + assert_eq!( + classify_request(&request(&t)), + RequestClass::Invalid(InvalidRequest::TargetNotMentioned) + ); + } + + #[test] + fn the_target_agent_binds_and_a_mentioned_human_does_not() { + let ob = valid_obligation(); + let dt = disposition_tags("completed"); + let body = r#"{"disposition":"completed","reason":""}"#; + + assert_eq!( + bind_disposition(&ob, &disposition("d1", AGENT, 200, &dt, body)), + Ok(DispositionState::Completed) + ); + // The headline spoof: a `p`-mentioned human signing a completion. + assert_eq!( + bind_disposition(&ob, &disposition("d2", HUMAN, 200, &dt, body)), + Err(BindFailure::NotTargetAgent) + ); + assert_eq!( + bind_disposition(&ob, &disposition("d3", OTHER_AGENT, 200, &dt, body)), + Err(BindFailure::NotTargetAgent) + ); + } + + #[test] + fn a_disposition_from_another_channel_or_requester_does_not_bind() { + let ob = valid_obligation(); + let body = r#"{"disposition":"completed","reason":""}"#; + + let wrong_channel = tags(&[ + ("e", REQ_ID), + ("h", "99999999-0000-0000-0000-000000000000"), + ("p", REQUESTER), + ("disposition", "completed"), + ]); + assert_eq!( + bind_disposition(&ob, &disposition("d1", AGENT, 200, &wrong_channel, body)), + Err(BindFailure::ChannelMismatch) + ); + + let wrong_requester = tags(&[ + ("e", REQ_ID), + ("h", CHANNEL), + ("p", HUMAN), + ("disposition", "completed"), + ]); + assert_eq!( + bind_disposition(&ob, &disposition("d2", AGENT, 200, &wrong_requester, body)), + Err(BindFailure::RequesterMismatch) + ); + } + + #[test] + fn content_disagreeing_with_its_tags_does_not_bind() { + let ob = valid_obligation(); + let dt = disposition_tags("completed"); + assert_eq!( + bind_disposition( + &ob, + // Recognizable state, but the event contradicts itself — + // distinct from an unparseable one. + &disposition( + "d1", + AGENT, + 200, + &dt, + r#"{"disposition":"refused","reason":""}"# + ) + ), + Err(BindFailure::Invalid( + InvalidDisposition::ContentStateMismatch + )) + ); + assert_eq!( + bind_disposition( + &ob, + &disposition( + "d2", + AGENT, + 200, + &dt, + r#"{"disposition":"completed","reason":"","request_id":"ffff"}"# + ) + ), + Err(BindFailure::Invalid( + InvalidDisposition::ContentRequestIdMismatch + )) + ); + } + + #[test] + fn a_duplicate_event_id_is_not_a_disputed_outcome() { + // The same event delivered twice (merged query results) must not + // read as two finalizations. + let ob = valid_obligation(); + let dt = disposition_tags("completed"); + let body = r#"{"disposition":"completed","reason":"done"}"#; + let d = disposition("dup", AGENT, 200, &dt, body); + let derived = derive_obligation(&ob, &[d.clone(), d]); + assert_eq!( + derived.outcome, + EffectiveOutcome::Settled(DispositionState::Completed) + ); + assert!(derived.warnings.is_empty(), "{:?}", derived.warnings); + assert!(derived.is_resolved()); + } + + #[test] + fn a_repeated_terminal_warns_but_stays_settled() { + // The finding that motivated splitting warnings from outcomes: this + // is described as harmless and redundant, yet the previous model + // made it unresolved and rendered it as "disputed" — identical + // treatment to a genuine contradiction. + let ob = valid_obligation(); + let dt = disposition_tags("completed"); + let body = r#"{"disposition":"completed","reason":""}"#; + let derived = derive_obligation( + &ob, + &[ + disposition("d1", AGENT, 200, &dt, body), + disposition("d2", AGENT, 300, &dt, body), + ], + ); + assert_eq!(derived.warnings, vec![HistoryWarning::DuplicateTerminal]); + assert!( + derived.is_resolved(), + "a duplicate delivery is not a dispute" + ); + } + + #[test] + fn completed_plus_refused_is_disputed_and_never_settled() { + let ob = valid_obligation(); + let ct = disposition_tags("completed"); + let rt = disposition_tags("refused"); + let derived = derive_obligation( + &ob, + &[ + disposition( + "d1", + AGENT, + 200, + &ct, + r#"{"disposition":"completed","reason":""}"#, + ), + disposition( + "d2", + AGENT, + 300, + &rt, + r#"{"disposition":"refused","reason":""}"#, + ), + ], + ); + assert_eq!(derived.outcome, EffectiveOutcome::Disputed); + assert!(!derived.is_resolved()); + assert!(derived.is_disputed()); + } + + #[test] + fn a_late_non_terminal_cannot_reopen_a_settled_obligation() { + // Terminal-absorbing. Previously the later `errored` won outright, + // so a state the spec called terminal silently reopened. + let ob = valid_obligation(); + let ct = disposition_tags("completed"); + let et = disposition_tags("errored"); + let derived = derive_obligation( + &ob, + &[ + disposition( + "d1", + AGENT, + 200, + &ct, + r#"{"disposition":"completed","reason":""}"#, + ), + disposition( + "d2", + AGENT, + 300, + &et, + r#"{"disposition":"errored","reason":""}"#, + ), + ], + ); + assert_eq!( + derived.outcome, + EffectiveOutcome::Settled(DispositionState::Completed), + "terminal claims absorb later weaker observations" + ); + assert_eq!( + derived.latest_observation, + Some(DispositionState::Errored), + "the raw latest observation is still available for audit" + ); + assert_eq!(derived.warnings, vec![HistoryWarning::OrderedAfterTerminal]); + assert!(derived.is_resolved()); + } + + #[test] + fn a_refusal_reason_survives_a_later_stray_write() { + let ob = valid_obligation(); + let rt = disposition_tags("refused"); + let et = disposition_tags("errored"); + let derived = derive_obligation( + &ob, + &[ + disposition( + "d1", + AGENT, + 200, + &rt, + r#"{"disposition":"refused","reason":"outside my delegation"}"#, + ), + disposition( + "d2", + AGENT, + 300, + &et, + r#"{"disposition":"errored","reason":"transport blip"}"#, + ), + ], + ); + assert_eq!(derived.reason, "outside my delegation"); + } + + #[test] + fn non_terminal_progress_then_completion_is_a_clean_repair() { + // errored -> responded -> completed is the normal repair path and + // must never be flagged. Guards against an over-broad rule. + let ob = valid_obligation(); + let et = disposition_tags("errored"); + let st = disposition_tags("responded"); + let ct = disposition_tags("completed"); + let derived = derive_obligation( + &ob, + &[ + disposition( + "d1", + AGENT, + 100, + &et, + r#"{"disposition":"errored","reason":""}"#, + ), + disposition( + "d2", + AGENT, + 200, + &st, + r#"{"disposition":"responded","reason":""}"#, + ), + disposition( + "d3", + AGENT, + 300, + &ct, + r#"{"disposition":"completed","reason":""}"#, + ), + ], + ); + assert!(derived.warnings.is_empty(), "{:?}", derived.warnings); + assert!(derived.is_resolved()); + } + + #[test] + fn responded_alone_is_open_not_resolved() { + // The whole point of the state: the agent answered, but nothing + // asserted the request was accomplished. + let ob = valid_obligation(); + let st = disposition_tags("responded"); + let derived = derive_obligation( + &ob, + &[disposition( + "d1", + AGENT, + 200, + &st, + r#"{"disposition":"responded","reason":""}"#, + )], + ); + assert_eq!( + derived.outcome, + EffectiveOutcome::Open(DispositionState::Responded) + ); + assert!(!derived.is_resolved()); + assert!(derived.is_open()); + } + + #[test] + fn same_second_ties_break_by_id_deterministically() { + let ob = valid_obligation(); + let st = disposition_tags("responded"); + let ct = disposition_tags("completed"); + let a = disposition( + "aaa", + AGENT, + 200, + &ct, + r#"{"disposition":"completed","reason":""}"#, + ); + let z = disposition( + "zzz", + AGENT, + 200, + &st, + r#"{"disposition":"responded","reason":""}"#, + ); + let forward = derive_obligation(&ob, &[a.clone(), z.clone()]); + let reversed = derive_obligation(&ob, &[z, a]); + assert_eq!(forward.outcome, reversed.outcome); + assert_eq!(forward.latest_observation, reversed.latest_observation); + // `completed` sorts first by id, so it settles; the tie order is what + // must be stable, not which state happens to win. + assert_eq!( + forward.outcome, + EffectiveOutcome::Settled(DispositionState::Completed) + ); + } + + #[test] + fn content_that_is_not_a_json_object_never_binds() { + // Confirmed divergence: Rust rejected `null`/`42`/`"hi"` while + // TypeScript bound them, because both skipped body validation for a + // non-object body — in opposite directions. + let ob = valid_obligation(); + let ct = disposition_tags("completed"); + for body in ["null", "42", "\"hi\"", "{not json", "[]"] { + assert_eq!( + bind_disposition(&ob, &disposition("d1", AGENT, 200, &ct, body)), + Err(BindFailure::Invalid(InvalidDisposition::ContentNotObject)), + "content {body} must not bind" + ); + } + } + + #[test] + fn accounting_separates_invalid_requests_from_real_gaps() { + let good = request_tags(); + let targetless = tags(&[("h", CHANNEL), ("t", "request"), ("p", HUMAN)]); + let good_req = request(&good); + let bad_req = EventView { + id: "2222222222222222222222222222222222222222222222222222222222222222", + ..request(&targetless) + }; + let acc = account(&[good_req, bad_req], &[], Coverage::complete()); + assert_eq!(acc.unanswered, vec![REQ_ID.to_string()]); + assert_eq!( + acc.invalid_requests, + vec![( + "2222222222222222222222222222222222222222222222222222222222222222".to_string(), + InvalidRequest::MissingAgentTarget + )] + ); + assert!(!acc.all_resolved()); + } + + #[test] + fn unsupported_requests_are_their_own_bucket_and_block_a_clean_claim() { + let t = tags(&[ + ("h", CHANNEL), + ("t", "request"), + ("agent", AGENT), + ("agent", OTHER_AGENT), + ("p", AGENT), + ("p", OTHER_AGENT), + ]); + let acc = account(&[request(&t)], &[], Coverage::complete()); + assert!(acc.invalid_requests.is_empty(), "not a malformed event"); + assert_eq!( + acc.unsupported_requests, + vec![(REQ_ID.to_string(), UnsupportedRequest::MultipleAgentTargets)] + ); + assert!(acc.unanswered.is_empty(), "never an agent gap"); + assert!(!acc.all_resolved()); + } + + #[test] + fn an_unbound_claim_is_excluded_from_state_but_reported() { + // Spoof attempts must not vanish silently — an auditor needs to see + // them, they just must not affect the outcome. + let t = request_tags(); + let ct = disposition_tags("completed"); + let acc = account( + &[request(&t)], + &[disposition( + "d1", + HUMAN, + 200, + &ct, + r#"{"disposition":"completed","reason":""}"#, + )], + Coverage::complete(), + ); + assert_eq!(acc.unanswered, vec![REQ_ID.to_string()]); + assert_eq!(acc.rejected_claims.len(), 1); + assert_eq!(acc.rejected_claims[0].failure, BindFailure::NotTargetAgent); + assert_eq!(acc.rejected_claims[0].signer, HUMAN); + } + + #[test] + fn all_resolved_requires_both_sides_of_coverage() { + let t = request_tags(); + let ct = disposition_tags("completed"); + let settled = [disposition( + "d1", + AGENT, + 200, + &ct, + r#"{"disposition":"completed","reason":""}"#, + )]; + // Exhausting only the request side is not enough: an unfetched later + // `refused` would make this obligation disputed, not settled. + let one_sided = Coverage { + requests_complete: true, + dispositions_complete: false, + }; + let acc = account(&[request(&t)], &settled, one_sided); + assert_eq!(acc.settled.len(), 1); + assert!( + !acc.all_resolved(), + "complete requests with partial dispositions cannot claim a clean channel" + ); + + let acc = account(&[request(&t)], &settled, Coverage::complete()); + assert!(acc.all_resolved()); + } + + #[test] + fn all_resolved_requires_complete_scope() { + let t = request_tags(); + let ct = disposition_tags("completed"); + let acc = account( + &[request(&t)], + &[disposition( + "d1", + AGENT, + 200, + &ct, + r#"{"disposition":"completed","reason":""}"#, + )], + Coverage::partial(), + ); + assert_eq!(acc.settled.len(), 1); + assert!( + !acc.all_resolved(), + "partial coverage must never claim channel-wide resolution" + ); + } + + #[test] + fn disposition_state_roundtrips_and_rejects_unknown() { + for state in DispositionState::ALL { + assert_eq!(DispositionState::parse(state.as_str()), Some(state)); + } + assert_eq!(DispositionState::parse("maybe"), None); + assert!(DispositionState::Completed.is_terminal()); + assert!(DispositionState::Refused.is_terminal()); + assert!(!DispositionState::Responded.is_terminal()); + assert!(!DispositionState::Errored.is_terminal()); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..ed03c37ea50 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -544,6 +544,35 @@ pub const KIND_MEMBER_REMOVED_NOTIFICATION: u32 = 44101; /// See `docs/nips/NIP-AM.md`. pub const KIND_AGENT_TURN_METRIC: u32 = 44200; +/// NIP-AD: Agent Disposition — signed, append-only record of how an agent +/// resolved one human→agent request (`completed` | `refused` | `errored`). +/// +/// Regular stored event (never replaced) — the deliberate inverse of +/// [`KIND_AGENT_TURN_METRIC`]: plaintext and readable by any authorized +/// reader of its channel, rather than encrypted and owner-gated, because a +/// disposition's whole purpose is verifiability by whoever can see the +/// conversation. Not specially gated — MUST NOT be added to +/// [`RESULT_GATED_KINDS`] or [`AUTHOR_ONLY_KINDS`] — but reads still inherit +/// ordinary channel access; this is not public-internet readable. +/// +/// Tags: exactly one `e` (the request event id), one `h` (channel UUID — +/// required at ingest, and exactly-one enforced by the kind's own envelope +/// validator so two `h` tags can't split storage scope from tag matching), +/// one `p` (the requesting principal), and one `disposition` (`completed` | +/// `refused` | `errored`), readable directly from the tag without parsing +/// `content`. NOT a server-side query filter: `#disposition` is not a valid +/// NIP-01 single-letter tag filter (the `nostr` crate's `Filter.generic_tags` +/// only accepts `#` keys and silently drops longer ones rather than +/// erroring) — consumers filter by state client-side over an `#h`/`#e`-scoped +/// read. `content` is a plaintext JSON object mirroring the tag. +/// +/// **Binding.** A disposition only resolves a request when its `pubkey` is +/// one of that request's `["agent", ]` targets — not merely a `p` +/// mention, which would let any CC'd human close an agent's obligation. The +/// relay does not enforce this (it would need to fetch a second event during +/// validation); every consumer must. See `docs/nips/NIP-AD.md`. +pub const KIND_AGENT_DISPOSITION: u32 = 44300; + // Forum / social (45000–45999) // V1 used addressable range (30001–30003) — wrong. /// A forum post (thread root). @@ -725,6 +754,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_AGENT_TURN_METRIC, + KIND_AGENT_DISPOSITION, KIND_WORKFLOW_DEF, KIND_LONG_FORM, KIND_USER_STATUS, @@ -885,6 +915,12 @@ const _: () = assert!(!is_ephemeral(KIND_AGENT_TURN_METRIC)); const _: () = assert!(!is_replaceable(KIND_AGENT_TURN_METRIC)); const _: () = assert!(!is_parameterized_replaceable(KIND_AGENT_TURN_METRIC)); const _: () = assert!(KIND_AGENT_TURN_METRIC <= u16::MAX as u32); +// Compile-time: KIND_AGENT_DISPOSITION is a regular stored kind (not ephemeral, not replaceable) — +// append-only is load-bearing: a disposition ledger that could be overwritten isn't a ledger. +const _: () = assert!(!is_ephemeral(KIND_AGENT_DISPOSITION)); +const _: () = assert!(!is_replaceable(KIND_AGENT_DISPOSITION)); +const _: () = assert!(!is_parameterized_replaceable(KIND_AGENT_DISPOSITION)); +const _: () = assert!(KIND_AGENT_DISPOSITION <= u16::MAX as u32); // Moderation kinds fit u16 and are neither replaceable nor ephemeral: // 1984 is a regular event (persisted to the queue, never fanned out); // 9040–9044 are direct commands (executed, never stored). @@ -907,6 +943,29 @@ mod tests { } } + #[test] + fn agent_disposition_is_world_readable_not_gated() { + // A disposition's whole purpose is third-party verifiability — the + // opposite of KIND_AGENT_TURN_METRIC's owner-only encrypted payload. + // Neither gating list may ever claim it. + assert!( + !RESULT_GATED_KINDS.contains(&KIND_AGENT_DISPOSITION), + "KIND_AGENT_DISPOSITION must stay channel-readable, not result-gated" + ); + assert!( + !AUTHOR_ONLY_KINDS.contains(&KIND_AGENT_DISPOSITION), + "KIND_AGENT_DISPOSITION must stay channel-readable, not author-only" + ); + assert!( + !P_GATED_KINDS.contains(&KIND_AGENT_DISPOSITION), + "KIND_AGENT_DISPOSITION must stay channel-readable, not p-gated" + ); + assert!( + ALL_KINDS.contains(&KIND_AGENT_DISPOSITION), + "KIND_AGENT_DISPOSITION must be registered in ALL_KINDS" + ); + } + #[test] fn nip43_membership_snapshot_is_relay_only() { assert!(is_relay_only_kind(KIND_NIP43_MEMBERSHIP_LIST)); diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83e..92de64917dd 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,10 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// NIP-AD Agent Dispositions — the shared request/disposition verifier and +/// lifecycle derivation. Every first-party consumer uses this so they cannot +/// disagree about what was verified. +pub mod disposition; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-core/tests/nip_ad_conformance.rs b/crates/buzz-core/tests/nip_ad_conformance.rs new file mode 100644 index 00000000000..64f9d1b575c --- /dev/null +++ b/crates/buzz-core/tests/nip_ad_conformance.rs @@ -0,0 +1,541 @@ +//! Runs the shared NIP-AD conformance corpora against the Rust verifier. +//! +//! The TypeScript mirror (`desktop/src/shared/lib/disposition.test.mjs`) runs +//! the same files. That is the whole point: the two implementations previously +//! drifted because each tested itself with its own fixtures, so the +//! divergence only surfaced in review. A case added here obliges both. +//! +//! Two corpora, doing different jobs: +//! +//! - `docs/nips/nip-ad-conformance.json` — hand-written, expressive cases. +//! Documents intent, and pins the two implementations to each other. +//! - `docs/nips/nip-ad-lifecycle-exhaustive.json` — every bound history up to +//! length 4, with expectations computed from declarative rules in +//! `scripts/gen-nip-ad-corpus.mjs` rather than from either implementation. +//! This is what makes the corpus more than a mutual-agreement pin: the +//! generator is a third, independent statement of the lifecycle, and the +//! spec's transition table is generated from it too. + +use std::collections::HashMap; + +use buzz_core::disposition::{ + account, bind_disposition, classify_request, derive_obligation, is_marked_request, BindFailure, + Coverage, DispositionState, EffectiveOutcome, EventView, HistoryWarning, InvalidDisposition, + InvalidRequest, RequestClass, UnsupportedRequest, REQUEST_KINDS, +}; +use serde_json::Value; + +fn read_corpus(name: &str) -> Value { + let path = format!("{}/../../docs/nips/{name}", env!("CARGO_MANIFEST_DIR")); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read conformance corpus at {path}: {e}")); + serde_json::from_str(&raw).expect("corpus is valid JSON") +} + +fn corpus() -> Value { + read_corpus("nip-ad-conformance.json") +} + +/// Resolve `$name` placeholders against the corpus constants. `$AGENT_UPPER` +/// is a derived value so the corpus can express the uppercase-target case +/// without duplicating a 64-char literal. +fn constants(corpus: &Value) -> HashMap { + let mut map = HashMap::new(); + for (key, value) in corpus["constants"].as_object().expect("constants object") { + map.insert( + format!("${key}"), + value.as_str().expect("constant is a string").to_string(), + ); + } + let agent = map["$agent"].clone(); + map.insert("$AGENT_UPPER".to_string(), agent.to_ascii_uppercase()); + map +} + +fn resolve(raw: &str, consts: &HashMap) -> String { + consts.get(raw).cloned().unwrap_or_else(|| { + // Placeholders can also appear embedded in JSON content strings. + let mut out = raw.to_string(); + for (placeholder, value) in consts { + out = out.replace(placeholder, value); + } + out + }) +} + +fn resolve_tags(raw: &Value, consts: &HashMap) -> Vec> { + raw.as_array() + .expect("tags array") + .iter() + .map(|row| { + row.as_array() + .expect("tag row") + .iter() + .map(|cell| resolve(cell.as_str().expect("tag cell"), consts)) + .collect() + }) + .collect() +} + +fn invalid_reason_str(reason: InvalidRequest) -> &'static str { + match reason { + InvalidRequest::MissingAgentTarget => "missing_agent_target", + InvalidRequest::MalformedAgentTarget => "malformed_agent_target", + InvalidRequest::MissingChannel => "missing_channel", + InvalidRequest::MultipleChannels => "multiple_channels", + InvalidRequest::TargetNotMentioned => "target_not_mentioned", + InvalidRequest::DuplicateAgentTarget => "duplicate_agent_target", + InvalidRequest::UnsupportedKind => "unsupported_kind", + } +} + +fn unsupported_reason_str(reason: UnsupportedRequest) -> &'static str { + match reason { + UnsupportedRequest::MultipleAgentTargets => "multiple_agent_targets", + } +} + +fn bind_failure_str(failure: BindFailure) -> &'static str { + match failure { + // Structural invalidity is flattened to its precise reason: the + // corpus names the rule that was broken, not merely "invalid". + BindFailure::Invalid(reason) => invalid_disposition_str(reason), + BindFailure::RequestMismatch => "request_mismatch", + BindFailure::ChannelMismatch => "channel_mismatch", + BindFailure::RequesterMismatch => "requester_mismatch", + BindFailure::NotTargetAgent => "not_target_agent", + } +} + +fn invalid_disposition_str(reason: InvalidDisposition) -> &'static str { + match reason { + InvalidDisposition::WrongKind => "wrong_kind", + InvalidDisposition::RequestIdCardinality => "request_id_cardinality", + InvalidDisposition::ChannelCardinality => "channel_cardinality", + InvalidDisposition::RequesterCardinality => "requester_cardinality", + InvalidDisposition::StateCardinality => "state_cardinality", + InvalidDisposition::MalformedRequestId => "malformed_request_id", + InvalidDisposition::MalformedRequester => "malformed_requester", + InvalidDisposition::UnknownState => "unknown_state", + InvalidDisposition::ContentNotObject => "content_not_object", + InvalidDisposition::ContentStateMismatch => "content_state_mismatch", + InvalidDisposition::MissingReason => "missing_reason", + InvalidDisposition::ReasonNotString => "reason_not_string", + InvalidDisposition::RequestIdNotString => "request_id_not_string", + InvalidDisposition::ContentRequestIdMismatch => "content_request_id_mismatch", + } +} + +fn warning_str(warning: HistoryWarning) -> &'static str { + match warning { + HistoryWarning::DuplicateTerminal => "duplicate_terminal", + HistoryWarning::OrderedAfterTerminal => "ordered_after_terminal", + } +} + +/// The corpus encodes outcomes as `{kind, state?}` to match the tagged +/// serialization on both sides. +fn assert_outcome(name: &str, got: EffectiveOutcome, want: &Value) { + let want_kind = want["kind"].as_str().expect("outcome kind"); + let (got_kind, got_state) = match got { + EffectiveOutcome::Unanswered => ("unanswered", None), + EffectiveOutcome::Open(s) => ("open", Some(s.as_str())), + EffectiveOutcome::Settled(s) => ("settled", Some(s.as_str())), + EffectiveOutcome::Disputed => ("disputed", None), + }; + assert_eq!(got_kind, want_kind, "{name}: wrong outcome kind"); + assert_eq!( + got_state, + want["state"].as_str(), + "{name}: wrong outcome state" + ); +} + +fn request_view<'a>( + id: &'a str, + pubkey: &'a str, + tags: &'a [Vec], + kind: u16, +) -> EventView<'a> { + EventView { + id, + pubkey, + kind, + created_at: 100, + content: "@agent do the thing", + tags, + } +} + +/// The corpus names the request-kind set, and each implementation must match +/// it. Without this, both runners defaulted the event kind to their own +/// `REQUEST_KINDS[0]`, so Rust accepting kind 10 while TypeScript accepted +/// kind 9 would pass both suites while the CLI and desktop disagreed about +/// which events are even candidates. +#[test] +fn request_kinds_match_the_corpus() { + let corpus = corpus(); + let want: Vec = corpus["requestKinds"] + .as_array() + .expect("corpus must pin requestKinds") + .iter() + .map(|k| k.as_u64().expect("kind") as u16) + .collect(); + assert_eq!( + REQUEST_KINDS.to_vec(), + want, + "this implementation's request-kind set disagrees with the corpus" + ); +} + +#[test] +fn request_classification_matches_the_corpus() { + let corpus = corpus(); + let consts = constants(&corpus); + let request_id = consts["$requestId"].clone(); + let requester = consts["$requester"].clone(); + + for case in corpus["requestClassification"].as_array().expect("cases") { + let name = case["name"].as_str().expect("case name"); + let tags = resolve_tags(&case["tags"], &consts); + // Cases may pin a kind to exercise the unsupported-kind rule. + let corpus_kind = corpus["requestKinds"][0].as_u64().expect("kind") as u16; + let kind = case["kind"].as_u64().map_or(corpus_kind, |k| k as u16); + let event = request_view(&request_id, &requester, &tags, kind); + let want = &case["expect"]; + let want_kind = want["kind"].as_str().expect("expect kind"); + + match classify_request(&event) { + RequestClass::NotRequest => { + assert_eq!(want_kind, "not_request", "{name}: unexpected not_request"); + assert!(!is_marked_request(&event), "{name}: marker disagreement"); + } + RequestClass::Valid(ob) => { + assert_eq!( + want_kind, "valid", + "{name}: expected {want_kind}, got valid" + ); + let want_target = + resolve(want["targetAgent"].as_str().expect("targetAgent"), &consts); + assert_eq!(ob.target_agent_pubkey, want_target, "{name}: wrong target"); + // Pin the whole obligation, not just the target: a classifier + // that got the channel or requester wrong would still pass a + // target-only assertion. + assert_eq!(ob.request_id, request_id, "{name}: wrong request id"); + assert_eq!(ob.requester_pubkey, requester, "{name}: wrong requester"); + assert_eq!( + ob.channel_id, + resolve("$channel", &consts), + "{name}: wrong channel" + ); + } + RequestClass::Invalid(reason) => { + assert_eq!( + want_kind, "invalid", + "{name}: expected {want_kind}, got invalid {reason:?}" + ); + assert_eq!( + invalid_reason_str(reason), + want["reason"].as_str().expect("reason"), + "{name}: wrong invalid reason" + ); + } + RequestClass::Unsupported(reason) => { + assert_eq!( + want_kind, "unsupported", + "{name}: expected {want_kind}, got unsupported {reason:?}" + ); + assert_eq!( + unsupported_reason_str(reason), + want["reason"].as_str().expect("reason"), + "{name}: wrong unsupported reason" + ); + } + } + } +} + +/// The corpus's first classification case is the canonical valid request every +/// binding and lifecycle case derives its obligation from. +fn canonical_obligation( + corpus: &Value, + consts: &HashMap, + request_id: &str, + requester: &str, +) -> buzz_core::disposition::Obligation { + let tags = resolve_tags(&corpus["requestClassification"][0]["tags"], consts); + let request = request_view(request_id, requester, &tags, REQUEST_KINDS[0]); + match classify_request(&request) { + RequestClass::Valid(ob) => *ob, + other => panic!("corpus case 0 must be a valid request, got {other:?}"), + } +} + +#[test] +fn binding_matches_the_corpus() { + let corpus = corpus(); + let consts = constants(&corpus); + let request_id = consts["$requestId"].clone(); + let requester = consts["$requester"].clone(); + let obligation = canonical_obligation(&corpus, &consts, &request_id, &requester); + + for case in corpus["binding"].as_array().expect("binding cases") { + let name = case["name"].as_str().expect("case name"); + let signer = resolve(case["signer"].as_str().expect("signer"), &consts); + let tags = resolve_tags(&case["tags"], &consts); + let content = resolve(case["content"].as_str().expect("content"), &consts); + let disposition = EventView { + id: "d0", + pubkey: &signer, + kind: case["kind"] + .as_u64() + .map_or(buzz_core::kind::KIND_AGENT_DISPOSITION as u16, |k| k as u16), + created_at: 200, + content: &content, + tags: &tags, + }; + + match (bind_disposition(&obligation, &disposition), &case["expect"]) { + (Ok(state), expect) => { + assert!( + expect["bound"].as_bool() == Some(true), + "{name}: expected unbound, bound as {state:?}" + ); + assert_eq!( + state.as_str(), + expect["state"].as_str().expect("state"), + "{name}: wrong state" + ); + } + (Err(failure), expect) => { + assert!( + expect["bound"].as_bool() == Some(false), + "{name}: expected bound, got {failure:?}" + ); + assert_eq!( + bind_failure_str(failure), + expect["reason"].as_str().expect("reason"), + "{name}: wrong bind failure" + ); + } + } + } +} + +/// Build disposition views for a history of `(id, created_at, state)` triples. +/// Tags and content are owned separately so the views can borrow them. +fn history_events<'a>( + specs: &'a [(String, i64, String)], + owned: &'a [(Vec>, String)], + agent: &'a str, +) -> Vec> { + specs + .iter() + .zip(owned.iter()) + .map(|((id, created_at, _), (tags, content))| EventView { + id, + pubkey: agent, + kind: buzz_core::kind::KIND_AGENT_DISPOSITION as u16, + created_at: *created_at, + content, + tags, + }) + .collect() +} + +fn owned_history( + specs: &[(String, i64, String)], + request_id: &str, + channel: &str, + requester: &str, +) -> Vec<(Vec>, String)> { + specs + .iter() + .map(|(_, _, state)| { + ( + vec![ + vec!["e".to_string(), request_id.to_string()], + vec!["h".to_string(), channel.to_string()], + vec!["p".to_string(), requester.to_string()], + vec!["disposition".to_string(), state.clone()], + ], + format!(r#"{{"disposition":"{state}","reason":"r-{state}"}}"#), + ) + }) + .collect() +} + +#[test] +fn lifecycle_matches_the_corpus() { + let corpus = corpus(); + let consts = constants(&corpus); + let request_id = consts["$requestId"].clone(); + let requester = consts["$requester"].clone(); + let agent = consts["$agent"].clone(); + let channel = consts["$channel"].clone(); + let obligation = canonical_obligation(&corpus, &consts, &request_id, &requester); + + for case in corpus["lifecycle"].as_array().expect("lifecycle cases") { + let name = case["name"].as_str().expect("case name"); + let specs: Vec<(String, i64, String)> = case["events"] + .as_array() + .expect("events") + .iter() + .map(|e| { + let row = e.as_array().expect("event triple"); + ( + row[0].as_str().expect("id").to_string(), + row[1].as_i64().expect("created_at"), + row[2].as_str().expect("state").to_string(), + ) + }) + .collect(); + let owned = owned_history(&specs, &request_id, &channel, &requester); + let events = history_events(&specs, &owned, &agent); + + let derived = derive_obligation(&obligation, &events); + let expect = &case["expect"]; + + assert_outcome(name, derived.outcome, &expect["outcome"]); + assert_eq!( + derived.latest_observation.map(DispositionState::as_str), + expect["latestObservation"].as_str(), + "{name}: wrong latest observation" + ); + let want_warnings: Vec<&str> = expect["warnings"] + .as_array() + .expect("warnings") + .iter() + .map(|w| w.as_str().expect("warning")) + .collect(); + let got_warnings: Vec<&str> = derived.warnings.iter().map(|w| warning_str(*w)).collect(); + assert_eq!(got_warnings, want_warnings, "{name}: wrong warnings"); + assert_eq!( + derived.is_resolved(), + expect["resolved"].as_bool().expect("resolved"), + "{name}: wrong resolved" + ); + } +} + +#[test] +fn exhaustive_lifecycle_matches_the_generated_corpus() { + // Every history up to length 4, with expectations derived from the + // generator's declarative rules rather than from this implementation. + let exhaustive = read_corpus("nip-ad-lifecycle-exhaustive.json"); + let corpus = corpus(); + let consts = constants(&corpus); + let request_id = consts["$requestId"].clone(); + let requester = consts["$requester"].clone(); + let agent = consts["$agent"].clone(); + let channel = consts["$channel"].clone(); + let obligation = canonical_obligation(&corpus, &consts, &request_id, &requester); + + let cases = exhaustive["cases"].as_array().expect("cases"); + assert_eq!( + cases.len() as u64, + exhaustive["caseCount"].as_u64().expect("caseCount"), + "corpus caseCount disagrees with its own case list" + ); + + for case in cases { + let history: Vec = case["history"] + .as_array() + .expect("history") + .iter() + .map(|s| s.as_str().expect("state").to_string()) + .collect(); + let name = format!("[{}]", history.join(" -> ")); + + // Index order is sort order: increasing created_at, distinct ids. + let specs: Vec<(String, i64, String)> = history + .iter() + .enumerate() + .map(|(i, state)| (format!("d{i:04}"), 100 + i as i64, state.clone())) + .collect(); + let owned = owned_history(&specs, &request_id, &channel, &requester); + let events = history_events(&specs, &owned, &agent); + + let derived = derive_obligation(&obligation, &events); + let expect = &case["expect"]; + + assert_outcome(&name, derived.outcome, &expect["outcome"]); + assert_eq!( + derived.latest_observation.map(DispositionState::as_str), + expect["latestObservation"].as_str(), + "{name}: wrong latest observation" + ); + let want_warnings: Vec<&str> = expect["warnings"] + .as_array() + .expect("warnings") + .iter() + .map(|w| w.as_str().expect("warning")) + .collect(); + let got_warnings: Vec<&str> = derived.warnings.iter().map(|w| warning_str(*w)).collect(); + assert_eq!(got_warnings, want_warnings, "{name}: wrong warnings"); + assert_eq!( + derived.is_resolved(), + expect["resolved"].as_bool().expect("resolved"), + "{name}: wrong resolved" + ); + } +} + +#[test] +fn accounting_never_reports_a_malformed_request_as_an_agent_gap() { + // The corpus's invalid and unsupported cases must land in their own + // buckets, not in `unanswered` — blaming an agent for a client fault + // produces a gap that can never be cleared by anyone. + let corpus = corpus(); + let consts = constants(&corpus); + let requester = consts["$requester"].clone(); + + let cases: Vec<(String, Vec>, String)> = corpus["requestClassification"] + .as_array() + .expect("cases") + .iter() + .enumerate() + .filter_map(|(i, c)| { + let kind = c["expect"]["kind"].as_str()?; + if kind != "invalid" && kind != "unsupported" { + return None; + } + // The unsupported-kind case is about the event's kind, not its + // tags, and is covered by the classification test. + if c["kind"].as_u64().is_some() { + return None; + } + Some(( + format!("{i:064}"), + resolve_tags(&c["tags"], &consts), + kind.to_string(), + )) + }) + .collect(); + + let requests: Vec> = cases + .iter() + .map(|(id, tags, _)| EventView { + id, + pubkey: &requester, + kind: REQUEST_KINDS[0], + created_at: 100, + content: "marked but unusable", + tags, + }) + .collect(); + + let acc = account(&requests, &[], Coverage::complete()); + let want_invalid = cases.iter().filter(|(_, _, k)| k == "invalid").count(); + let want_unsupported = cases.iter().filter(|(_, _, k)| k == "unsupported").count(); + assert_eq!(acc.invalid_requests.len(), want_invalid); + assert_eq!(acc.unsupported_requests.len(), want_unsupported); + assert!( + acc.unanswered.is_empty(), + "malformed requests must never be counted as unanswered obligations" + ); + assert!( + !acc.all_resolved(), + "unanswerable requests block a clean claim" + ); +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91e..87f66a59b51 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,29 +12,29 @@ use uuid::Uuid; use buzz_auth::Scope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - 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_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, + is_relay_admin_kind, KIND_AGENT_DISPOSITION, 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_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; @@ -353,6 +353,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), + // NIP-AD: agent dispositions are agent-authored, channel-scoped, world-readable. + KIND_AGENT_DISPOSITION => Ok(Scope::MessagesWrite), // NIP-56 reports are ordinary member writes into the mod-only queue. // Ingest persists them to `moderation_reports` and suppresses public // storage/fanout; reports are signals, never enforcement triggers. @@ -637,6 +639,12 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_HUDDLE_PARTICIPANT_LEFT | KIND_HUDDLE_ENDED | KIND_HUDDLE_GUIDELINES + // NIP-AD: v1 scopes dispositions to channel-scoped requests only — + // same treatment as the stream-message kinds above. DMs already use + // a distinct kind family (KIND_DM_OPEN and friends) rather than an + // optional `h` tag on a shared kind, so there is no existing + // "conditionally channel-scoped" precedent to extend instead. + | KIND_AGENT_DISPOSITION ) } @@ -1727,6 +1735,68 @@ fn validate_agent_turn_metric_envelope(event: &nostr::Event) -> Result<(), Strin Ok(()) } +/// Validate a NIP-AD `kind:44300` disposition event before it is stored. +/// +/// **Delegates to `buzz_core::disposition::validate_disposition_event`** — it +/// does not reimplement the rules, and deliberately not a mirror of them +/// either. There is one validity boundary and this calls it. +/// +/// That matters more than it looks. This function used to be an independent +/// implementation of the same contract, and the consumer-side verifier +/// enforced a strictly weaker one: it read the first matching tag and checked +/// neither kind, nor tag cardinality, nor the required `reason`. So an event +/// with two `e` tags, or no `reason`, or not even of kind 44300, was rejected +/// here and simultaneously accepted as a settling disposition by the auditor. +/// Two implementations of one contract will drift; one implementation with +/// two callers cannot. +fn validate_agent_disposition_envelope(event: &Event) -> Result<(), String> { + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + let id = event.id.to_hex(); + let pubkey = event.pubkey.to_hex(); + let view = buzz_core::disposition::EventView { + id: &id, + pubkey: &pubkey, + kind: event.kind.as_u16(), + created_at: event.created_at.as_secs() as i64, + content: &event.content, + tags: &tags, + }; + + buzz_core::disposition::validate_disposition_event(&view) + .map(|_| ()) + .map_err(describe_invalid_disposition) +} + +/// Operator-facing text for a structural rejection. Only the wording lives +/// here; every rule lives in `buzz-core`. +fn describe_invalid_disposition(reason: buzz_core::disposition::InvalidDisposition) -> String { + use buzz_core::disposition::InvalidDisposition as I; + let detail = match reason { + I::WrongKind => "event is not kind 44300", + I::RequestIdCardinality => "must have exactly one `e` tag referencing the request", + I::ChannelCardinality => "must have exactly one `h` tag naming its channel", + I::RequesterCardinality => "must have exactly one `p` tag naming the requesting principal", + I::StateCardinality => "must have exactly one `disposition` tag", + I::MalformedRequestId => "`e` tag must be 64 lowercase hex characters", + I::MalformedRequester => "`p` tag must be 64 lowercase hex characters", + I::UnknownState => "`disposition` tag must be one of", + I::ContentNotObject => "content must be a JSON object", + I::ContentStateMismatch => "content `disposition` must match the tag", + I::MissingReason => "content must have a `reason` field", + I::ReasonNotString => "content `reason` must be a string", + I::RequestIdNotString => "content `request_id` must be a string", + I::ContentRequestIdMismatch => "content `request_id` must equal the `e` tag", + }; + let valid: Vec<&str> = buzz_core::disposition::DispositionState::ALL + .iter() + .map(|s| s.as_str()) + .collect(); + if matches!(reason, I::UnknownState) { + return format!("agent-disposition {detail}: {}", valid.join(", ")); + } + format!("agent-disposition {detail}") +} + /// Parse a NIP-ER `not_before` tag value into a Unix timestamp. /// /// The value MUST be a decimal integer string containing only ASCII digits, with @@ -2551,6 +2621,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_AGENT_DISPOSITION { + validate_agent_disposition_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_PERSONA { validate_persona_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3623,6 +3698,7 @@ mod tests { KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_TURN_METRIC, + KIND_AGENT_DISPOSITION, ]; for kind in migrated { assert!( @@ -3669,6 +3745,70 @@ mod tests { ); } + #[test] + fn agent_disposition_requires_h_channel_scope_and_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert!( + !is_global_only_kind(KIND_AGENT_DISPOSITION), + "kind:44300 must not be global-only — v1 scopes it to channel-scoped requests" + ); + assert!( + requires_h_channel_scope(KIND_AGENT_DISPOSITION), + "kind:44300 must require an h-tag (channel-scoped requests only in v1)" + ); + assert_eq!( + required_scope_for_kind(KIND_AGENT_DISPOSITION, &dummy).unwrap(), + Scope::MessagesWrite, + "kind:44300 requires MessagesWrite scope" + ); + } + + #[test] + fn agent_disposition_rejects_two_h_tags() { + // Two distinct `h` tags are cross-channel ambiguity: the shared + // channel-scope resolver would silently take the first for storage + // and authorization while the second still rides along on the + // stored event. NIP-AD states exactly-one-`h` as a MUST, so the + // validator enforces it rather than leaving the spec and the code + // disagreeing (an external design review caught that gap). + let agent = nostr::Keys::generate(); + let ev = make_agent_disposition( + &agent, + &[ + &["e", &"a".repeat(64)], + &["h", &uuid::Uuid::new_v4().to_string()], + &["h", &uuid::Uuid::new_v4().to_string()], + &["p", &"b".repeat(64)], + &["disposition", "completed"], + ], + r#"{"disposition":"completed","reason":""}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!( + err.contains("exactly one `h` tag"), + "unexpected error: {err}" + ); + } + + #[test] + fn agent_disposition_rejects_missing_h_tag() { + let agent = nostr::Keys::generate(); + let ev = make_agent_disposition( + &agent, + &[ + &["e", &"a".repeat(64)], + &["p", &"b".repeat(64)], + &["disposition", "completed"], + ], + r#"{"disposition":"completed","reason":""}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!( + err.contains("exactly one `h` tag"), + "unexpected error: {err}" + ); + } + #[test] fn nip51_and_nip65_lists_are_global_only() { for kind in [ @@ -5196,6 +5336,211 @@ mod tests { assert!(err.contains("agent-turn-metric"), "got: {err}"); } + fn make_agent_disposition( + agent_keys: &nostr::Keys, + tags: &[&[&str]], + content: &str, + ) -> nostr::Event { + let nostr_tags: Vec = tags + .iter() + .map(|t| nostr::Tag::parse(t.iter().copied()).unwrap()) + .collect(); + nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_DISPOSITION as u16), + content, + ) + .tags(nostr_tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + #[test] + fn agent_disposition_envelope_accepts_canonical() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["e", &request_id], + &["h", "some-channel-uuid"], + &["p", &requester_hex], + &["disposition", "refused"], + ], + r#"{"disposition":"refused","reason":"outside my delegation"}"#, + ); + assert!(validate_agent_disposition_envelope(&ev).is_ok()); + } + + #[test] + fn agent_disposition_envelope_accepts_matching_request_id_in_content() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + &["disposition", "completed"], + ], + &format!(r#"{{"disposition":"completed","reason":"","request_id":"{request_id}"}}"#), + ); + assert!(validate_agent_disposition_envelope(&ev).is_ok()); + } + + #[test] + fn agent_disposition_envelope_rejects_missing_e() { + let agent = nostr::Keys::generate(); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["p", &requester_hex], + &["disposition", "completed"], + ], + r#"{"disposition":"completed","reason":""}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("`e` tag"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_missing_p() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["disposition", "completed"], + ], + r#"{"disposition":"completed","reason":""}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_missing_disposition_tag() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + ], + r#"{"disposition":"completed","reason":""}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("`disposition` tag"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_invalid_disposition_value() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + &["disposition", "maybe-later"], + ], + r#"{"disposition":"maybe-later","reason":""}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + // The valid-state list is enumerated from `DispositionState::ALL`, + // so a new state can never leave this message stale. + assert!(err.contains("must be one of"), "got: {err}"); + assert!(err.contains("responded"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_content_tag_mismatch() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + &["disposition", "completed"], + ], + r#"{"disposition":"refused","reason":"changed my mind"}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("must match the tag"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_malformed_json() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + &["disposition", "completed"], + ], + "not json", + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("JSON object"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_missing_reason_field() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + &["disposition", "completed"], + ], + r#"{"disposition":"completed"}"#, + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("`reason` field"), "got: {err}"); + } + + #[test] + fn agent_disposition_envelope_rejects_request_id_mismatch() { + let agent = nostr::Keys::generate(); + let request_id = "a".repeat(64); + let other_id = "f".repeat(64); + let requester_hex = "b".repeat(64); + let ev = make_agent_disposition( + &agent, + &[ + &["h", "some-channel-uuid"], + &["e", &request_id], + &["p", &requester_hex], + &["disposition", "completed"], + ], + &format!(r#"{{"disposition":"completed","reason":"","request_id":"{other_id}"}}"#), + ); + let err = validate_agent_disposition_envelope(&ev).unwrap_err(); + assert!(err.contains("must equal the `e` tag"), "got: {err}"); + } + /// The HTTP bridge's `submit_event` 400 arm and the WS `EVENT` handler's /// reject path must land on the same counter, distinguished only by the /// `transport` label — this is what lets a dashboard tell "server got diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73db..91e22f95f36 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -470,6 +470,58 @@ pub fn build_vote( Ok(EventBuilder::new(Kind::Custom(45002), content).tags(tags)) } +/// Build a NIP-AD agent disposition event (kind 44300). +/// +/// Records how an agent resolved one human→agent request. The valid states +/// come from [`buzz_core::disposition::DispositionState`] — the same enum the +/// relay validator, the harness, and both readers use, so a state cannot +/// exist in one layer and not another. +/// +/// Note what the states mean: `completed` asserts the work was done and +/// requires an explicit per-request signal; `responded` means only that the +/// agent answered, which is all a harness can observe from a clean +/// end-of-turn. Emitting `completed` for a bare turn end would be a claim +/// nothing verified. +/// +/// Plaintext and readable by any authorized reader of the channel — +/// verifiability is the point, unlike the encrypted, owner-gated kind 44200 +/// turn metric. `channel_id` is required: v1 scopes dispositions to +/// channel-scoped requests only (see `docs/nips/NIP-AD.md`). `reason` MAY be +/// an empty string but is always present in `content`, never omitted. +pub fn build_agent_disposition( + channel_id: Uuid, + request_event_id: nostr::EventId, + requester_pubkey: &str, + disposition: &str, + reason: &str, +) -> Result { + if buzz_core::disposition::DispositionState::parse(disposition).is_none() { + let valid: Vec<&str> = buzz_core::disposition::DispositionState::ALL + .iter() + .map(|s| s.as_str()) + .collect(); + return Err(SdkError::InvalidInput(format!( + "disposition must be one of {valid:?} (got {disposition:?})" + ))); + } + let requester_hex = check_pubkey_hex(requester_pubkey, "requester_pubkey")?; + let content = serde_json::json!({ "disposition": disposition, "reason": reason }).to_string(); + let tags = vec![ + tag(&["e", &request_event_id.to_hex()])?, + tag(&["h", &channel_id.to_string()])?, + tag(&["p", &requester_hex])?, + tag(&["disposition", disposition])?, + ]; + // nostr 0.44 silently strips a `p` tag matching the signer unless opted + // in (see build_message's `#4906` note). The `p` tag here is REQUIRED — + // its absence fails ingest's tag-count check with a confusing "missing + // p tag" error — so this must survive even the rare case of an agent + // dispositioning a request it authored itself. + Ok(EventBuilder::new(Kind::Custom(44300), content) + .tags(tags) + .allow_self_tagging()) +} + /// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars. pub fn build_reaction( target_event_id: nostr::EventId, @@ -2433,6 +2485,73 @@ mod tests { ); } + #[test] + fn agent_disposition_happy_path() { + let cid = uuid(); + let req = event_id(); + let requester = keys(); + let requester_hex = requester.public_key().to_hex(); + let ev = sign( + build_agent_disposition(cid, req, &requester_hex, "refused", "outside my delegation") + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 44300); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "e", &req.to_hex())); + assert!(has_tag(&ev, "p", &requester_hex)); + assert!(has_tag(&ev, "disposition", "refused")); + let content: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); + assert_eq!(content["disposition"], "refused"); + assert_eq!(content["reason"], "outside my delegation"); + } + + #[test] + fn agent_disposition_rejects_invalid_state() { + let cid = uuid(); + let req = event_id(); + let requester_hex = keys().public_key().to_hex(); + let err = build_agent_disposition(cid, req, &requester_hex, "maybe-later", "").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn agent_disposition_rejects_malformed_pubkey() { + let cid = uuid(); + let req = event_id(); + let err = build_agent_disposition(cid, req, "not-a-pubkey", "completed", "").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn agent_disposition_reason_may_be_empty_for_completed() { + let cid = uuid(); + let req = event_id(); + let requester_hex = keys().public_key().to_hex(); + let ev = sign(build_agent_disposition(cid, req, &requester_hex, "completed", "").unwrap()); + let content: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); + // The field must still be present (an empty string), never omitted — + // see NIP-AD.md's content contract. + assert_eq!(content["reason"], ""); + } + + #[test] + fn agent_disposition_preserves_self_p_tag_when_agent_disposits_own_request() { + // Mirrors message_preserves_self_mention_p_tag: nostr 0.44 strips a + // `p` tag matching the signer unless allow_self_tagging() is set. + // Here the `p` tag is REQUIRED (not an optional mention), so losing + // it would silently produce a malformed event. + let cid = uuid(); + let req = event_id(); + let agent = keys(); + let agent_hex = agent.public_key().to_hex(); + let builder = build_agent_disposition(cid, req, &agent_hex, "completed", "").unwrap(); + let ev = builder.sign_with_keys(&agent).expect("sign"); + assert!( + has_tag(&ev, "p", &agent_hex), + "p tag must survive signing even when it equals the signer's own pubkey" + ); + } + #[test] fn agent_observer_frame_happy_path() { let sender = keys(); diff --git a/crates/buzz-test-client/tests/e2e_agent_disposition.rs b/crates/buzz-test-client/tests/e2e_agent_disposition.rs new file mode 100644 index 00000000000..7f7f022c15e --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_agent_disposition.rs @@ -0,0 +1,1240 @@ +//! End-to-end integration tests for NIP-AD (Agent Disposition, kind:44300). +//! +//! These tests verify: +//! - Write-path validation: `e`/`p`/`disposition` tag shape, content/tag +//! agreement, and the mandatory `h` tag (rejected at ingest when absent — +//! see design.md Decision 9) +//! - World-readability: a disposition is readable by a third identity that is +//! neither the publishing agent nor the request's author +//! - Append-only storage: multiple dispositions for the same agent (and for +//! the same request, across its lifecycle) are never collapsed or replaced +//! - Server-side filtering: `#h` and `#e` narrow a query; `#disposition` does +//! NOT (see design.md Decision 10 and NIP-AD.md "Not a query filter") — a +//! consumer isolating one state filters client-side over the tag instead +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! cargo test -p buzz-test-client --test e2e_agent_disposition -- --ignored +//! ``` +//! +//! Override the relay URL with the `RELAY_URL` environment variable. + +use std::time::Duration; + +use nostr::{EventBuilder, Filter, Keys, Kind, Tag}; +use reqwest::Client; +use serde_json::Value; + +const KIND_AGENT_DISPOSITION: u16 = 44300; +const KIND_STREAM_MESSAGE: u16 = 9; +const KIND_CREATE_GROUP: u16 = 9007; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +/// Submit a signed event via `POST /events`. Returns `(accepted, message)`, +/// matching the sibling e2e suites' convention: rejections may come back as +/// HTTP 200 with `accepted:false` or as a non-200 `{"error": ...}` body. +async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).unwrap()) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + if status == 200 { + let accepted = body["accepted"].as_bool().unwrap_or(false); + let message = body["message"].as_str().unwrap_or("").to_string(); + (accepted, message) + } else { + let message = body["error"].as_str().unwrap_or("").to_string(); + (false, message) + } +} + +/// Query events via the HTTP bridge. Returns the JSON array of events. +async fn query_events_http(client: &Client, pubkey_hex: &str, filters: Vec) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") +} + +/// Read the value of an event's first tag matching `key`. +fn tag_value<'a>(event: &'a Value, key: &str) -> Option<&'a str> { + event["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 + } + }) +} + +/// Create an open-visibility channel so any generated identity (creator, +/// agent, or a wholly unrelated third-party auditor) can read and write +/// without a separate membership step. Returns the channel UUID string. +async fn create_test_channel(client: &Client, creator: &Keys) -> String { + let channel_uuid = uuid::Uuid::new_v4(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_GROUP), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &format!("nip-ad-e2e-{channel_uuid}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(creator) + .unwrap(); + let (accepted, message) = submit_event_http(client, creator, &event).await; + assert!(accepted, "channel creation rejected: {message}"); + channel_uuid.to_string() +} + +/// Publish a request message (an ordinary kind:9 channel message) and return +/// its event id hex — the id a disposition's `e` tag will reference. +async fn publish_request( + client: &Client, + requester: &Keys, + channel_id: &str, + text: &str, +) -> String { + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), text) + .tags(vec![Tag::parse(["h", channel_id]).unwrap()]) + .sign_with_keys(requester) + .unwrap(); + let (accepted, message) = submit_event_http(client, requester, &event).await; + assert!(accepted, "request message rejected: {message}"); + event.id.to_hex() +} + +/// Build a well-formed kind:44300 disposition event. +fn build_disposition( + agent: &Keys, + channel_id: &str, + request_id: &str, + requester_hex: &str, + disposition: &str, + reason: &str, +) -> nostr::Event { + build_disposition_at( + agent, + channel_id, + request_id, + requester_hex, + disposition, + reason, + None, + ) +} + +/// Same as [`build_disposition`], with an optional explicit `created_at` — +/// needed to deterministically order two dispositions for the same request. +/// Nostr timestamps are whole-second precision (see NIP-AD.md's "Same-second +/// ties"), so two events signed back-to-back in a test can otherwise land in +/// the same second and make "latest wins" assertions flaky by construction. +fn build_disposition_at( + agent: &Keys, + channel_id: &str, + request_id: &str, + requester_hex: &str, + disposition: &str, + reason: &str, + created_at: Option, +) -> nostr::Event { + let content = serde_json::json!({ "disposition": disposition, "reason": reason }).to_string(); + let mut builder = EventBuilder::new(Kind::Custom(KIND_AGENT_DISPOSITION), content).tags(vec![ + Tag::parse(["e", request_id]).unwrap(), + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["p", requester_hex]).unwrap(), + Tag::parse(["disposition", disposition]).unwrap(), + ]); + if let Some(ts) = created_at { + builder = builder.custom_created_at(ts); + } + builder.sign_with_keys(agent).unwrap() +} + +#[tokio::test] +#[ignore] +async fn disposition_accepted_and_readable_by_unrelated_third_party() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let auditor = Keys::generate(); // neither requester nor agent — pure third party + + let channel_id = create_test_channel(&client, &requester).await; + let request_id = + publish_request(&client, &requester, &channel_id, "@agent please summarize").await; + + let disposition_event = build_disposition( + &agent, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "refused", + "outside my delegation", + ); + let (accepted, message) = submit_event_http(&client, &agent, &disposition_event).await; + assert!(accepted, "well-formed disposition rejected: {message}"); + + // The auditor never touched requester's or agent's keys, and was never + // added as a channel member — only the channel's open visibility and its + // own signature authenticate this read. This is the "any third party can + // verify" property NIP-AD exists to provide. + let results = query_events_http( + &client, + &auditor.public_key().to_hex(), + vec![Filter::new() + .kind(Kind::Custom(KIND_AGENT_DISPOSITION)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + channel_id.clone(), + )], + ) + .await; + + let found = results + .iter() + .find(|e| e["id"].as_str() == Some(&disposition_event.id.to_hex())) + .unwrap_or_else(|| panic!("disposition not found in third-party read: {results:?}")); + assert_eq!(tag_value(found, "disposition"), Some("refused")); + assert_eq!(tag_value(found, "e"), Some(request_id.as_str())); + // Plaintext content readable with no decryption — the whole point. + let content: Value = serde_json::from_str(found["content"].as_str().unwrap()).unwrap(); + assert_eq!(content["reason"], "outside my delegation"); +} + +#[tokio::test] +#[ignore] +async fn dispositions_are_append_only_across_lifecycle() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + let request_id = publish_request(&client, &requester, &channel_id, "@agent do a thing").await; + let requester_hex = requester.public_key().to_hex(); + + // errored, then completed for the SAME request — both must persist; + // neither is a NIP-33-style replacement of the other (kind 44300 is a + // regular stored kind, never replaceable). Backdate `errored` by 2s so + // the two events can't tie on created_at's whole-second precision — + // this test asserts the ordinary "later created_at wins" path; the + // same-second tiebreaker (event id, lexicographically greatest) is a + // separate rule documented in NIP-AD.md, exercised where the actual + // state-derivation helper is implemented (CLI `dispositions list` / + // desktop accountability store), not here at the protocol layer. + let backdated = nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 2); + let errored = build_disposition_at( + &agent, + &channel_id, + &request_id, + &requester_hex, + "errored", + "tool call failed: connection reset", + Some(backdated), + ); + let (accepted, msg) = submit_event_http(&client, &agent, &errored).await; + assert!(accepted, "errored disposition rejected: {msg}"); + + let completed = build_disposition( + &agent, + &channel_id, + &request_id, + &requester_hex, + "completed", + "", + ); + let (accepted, msg) = submit_event_http(&client, &agent, &completed).await; + assert!(accepted, "completed disposition rejected: {msg}"); + + let results = query_events_http( + &client, + &requester_hex, + vec![Filter::new() + .kind(Kind::Custom(KIND_AGENT_DISPOSITION)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::E), + request_id.clone(), + )], + ) + .await; + + let ids: Vec<&str> = results.iter().filter_map(|e| e["id"].as_str()).collect(); + assert!( + ids.contains(&errored.id.to_hex().as_str()) + && ids.contains(&completed.id.to_hex().as_str()), + "both errored and completed dispositions must survive as independent events, got: {ids:?}" + ); + assert_eq!( + results.len(), + 2, + "no last-write-wins collapse expected for an append-only kind, got {} events", + results.len() + ); + + // Lifecycle: latest by created_at is the current state. Both events were + // signed in this order, so completed (signed second) is current. + let latest = results + .iter() + .max_by_key(|e| e["created_at"].as_i64().unwrap_or(0)) + .unwrap(); + assert_eq!(tag_value(latest, "disposition"), Some("completed")); +} + +#[tokio::test] +#[ignore] +async fn h_and_e_filter_server_side_disposition_does_not() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + let requester_hex = requester.public_key().to_hex(); + + let request_a = publish_request(&client, &requester, &channel_id, "@agent request A").await; + let request_b = publish_request(&client, &requester, &channel_id, "@agent request B").await; + + let disp_a = build_disposition( + &agent, + &channel_id, + &request_a, + &requester_hex, + "completed", + "", + ); + let disp_b = build_disposition( + &agent, + &channel_id, + &request_b, + &requester_hex, + "refused", + "not authorized for that action", + ); + for (ev, label) in [(&disp_a, "A"), (&disp_b, "B")] { + let (accepted, msg) = submit_event_http(&client, &agent, ev).await; + assert!(accepted, "disposition {label} rejected: {msg}"); + } + + // #h scopes to the channel: both dispositions come back. + let by_channel = query_events_http( + &client, + &requester_hex, + vec![Filter::new() + .kind(Kind::Custom(KIND_AGENT_DISPOSITION)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + channel_id.clone(), + )], + ) + .await; + let by_channel_ids: Vec<&str> = by_channel.iter().filter_map(|e| e["id"].as_str()).collect(); + assert!(by_channel_ids.contains(&disp_a.id.to_hex().as_str())); + assert!(by_channel_ids.contains(&disp_b.id.to_hex().as_str())); + + // #e scopes to exactly one request: only that request's disposition comes back. + let by_request = query_events_http( + &client, + &requester_hex, + vec![Filter::new() + .kind(Kind::Custom(KIND_AGENT_DISPOSITION)) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::E), + request_b.clone(), + )], + ) + .await; + assert_eq!( + by_request.len(), + 1, + "an #e filter must isolate exactly one request's disposition(s)" + ); + assert_eq!(tag_value(&by_request[0], "disposition"), Some("refused")); + + // Isolating "only refused" is a CLIENT-side filter over the #h-scoped + // set — #disposition is deliberately never sent as a query key (see + // NIP-AD.md "Not a query filter"; the underlying nostr crate's + // Filter.generic_tags accepts only single-letter tag keys and would + // silently ignore a multi-character "#disposition" filter rather than + // erroring, which would silently over-return instead of narrowing). + let refused_only: Vec<&Value> = by_channel + .iter() + .filter(|e| tag_value(e, "disposition") == Some("refused")) + .collect(); + assert_eq!(refused_only.len(), 1); + assert_eq!( + refused_only[0]["id"].as_str(), + Some(disp_b.id.to_hex().as_str()) + ); +} + +#[tokio::test] +#[ignore] +async fn disposition_rejected_without_h_tag() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + // Deliberately skip channel creation — this disposition must be + // rejected for its missing `h` tag before channel access is even + // considered, so no real channel is needed to prove the point. + let request_id = "a".repeat(64); + let content = serde_json::json!({"disposition": "completed", "reason": ""}).to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_AGENT_DISPOSITION), content) + .tags(vec![ + Tag::parse(["e", &request_id]).unwrap(), + Tag::parse(["p", &requester.public_key().to_hex()]).unwrap(), + Tag::parse(["disposition", "completed"]).unwrap(), + ]) + .sign_with_keys(&agent) + .unwrap(); + + let (accepted, message) = submit_event_http(&client, &agent, &event).await; + assert!(!accepted, "disposition without an h tag must be rejected"); + assert!( + message.contains("h tag") || message.contains("channel"), + "expected a channel-scope rejection message, got: {message}" + ); +} + +#[tokio::test] +#[ignore] +async fn disposition_rejected_with_invalid_state_value() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + let request_id = publish_request(&client, &requester, &channel_id, "@agent do something").await; + + let event = build_disposition( + &agent, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "maybe-later", // not one of completed|refused|errored + "", + ); + let (accepted, message) = submit_event_http(&client, &agent, &event).await; + assert!(!accepted, "invalid disposition value must be rejected"); + assert!( + message.contains("must be one of"), + "expected a disposition-enum rejection message, got: {message}" + ); +} + +// --------------------------------------------------------------------------- +// Joined protocol path: signed request -> real relay ingest -> disposition +// publication -> read back -> shared-verifier binding and accounting. +// +// Everything above tests one layer. These tests exercise the whole contract +// against a live relay using the SAME verifier the CLI and desktop use +// (`buzz_core::disposition`), so a change that makes one layer disagree with +// another fails here rather than in review. Three review rounds found +// defects that each individual layer's tests passed straight through. +// --------------------------------------------------------------------------- + +/// Publish a NIP-AD v1 request: marked, targeting exactly one agent, with +/// the matching `p` mention. Mirrors what the composer and +/// `buzz messages send --request-agent` both produce. +async fn publish_v1_request( + client: &Client, + requester: &Keys, + channel_id: &str, + agent_hex: &str, + text: &str, +) -> String { + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), text) + .tags(vec![ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", agent_hex]).unwrap(), + Tag::parse(["p", agent_hex]).unwrap(), + ]) + .sign_with_keys(requester) + .unwrap(); + let (accepted, message) = submit_event_http(client, requester, &event).await; + assert!(accepted, "v1 request rejected: {message}"); + event.id.to_hex() +} + +/// Owned storage so `EventView`s can borrow from relay JSON. +struct FetchedEvent { + id: String, + pubkey: String, + kind: u16, + created_at: i64, + content: String, + tags: Vec>, +} + +impl FetchedEvent { + fn from_json(value: &Value) -> Option { + Some(Self { + id: value.get("id")?.as_str()?.to_string(), + pubkey: value.get("pubkey")?.as_str()?.to_string(), + kind: value.get("kind").and_then(Value::as_u64).unwrap_or(0) as u16, + created_at: value.get("created_at").and_then(Value::as_i64).unwrap_or(0), + content: value + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + tags: value + .get("tags") + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + Some( + row.as_array()? + .iter() + .filter_map(|c| c.as_str().map(String::from)) + .collect(), + ) + }) + .collect() + }) + .unwrap_or_default(), + }) + } + + fn view(&self) -> buzz_core::disposition::EventView<'_> { + buzz_core::disposition::EventView { + id: &self.id, + pubkey: &self.pubkey, + kind: self.kind, + created_at: self.created_at, + content: &self.content, + tags: &self.tags, + } + } +} + +/// Read a channel's marked requests and dispositions back from the relay, +/// exactly as a third-party auditor would. +async fn fetch_channel_state( + client: &Client, + reader_hex: &str, + channel_id: &str, +) -> (Vec, Vec) { + let requests = query_events_http( + client, + reader_hex, + vec![Filter::new() + .kind(Kind::Custom(KIND_STREAM_MESSAGE)) + .custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + [channel_id.to_string()], + ) + .custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::T), + ["request".to_string()], + )], + ) + .await; + let dispositions = query_events_http( + client, + reader_hex, + vec![Filter::new() + .kind(Kind::Custom(KIND_AGENT_DISPOSITION)) + .custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + [channel_id.to_string()], + )], + ) + .await; + ( + requests + .iter() + .filter_map(FetchedEvent::from_json) + .collect(), + dispositions + .iter() + .filter_map(FetchedEvent::from_json) + .collect(), + ) +} + +/// The whole contract, end to end: a request the agent answers is reported +/// resolved by an unrelated third party reading only public relay state. +#[tokio::test] +#[ignore] +async fn live_path_target_agent_resolves_its_own_request() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let auditor = Keys::generate(); // neither the agent nor the requester + let agent_hex = agent.public_key().to_hex(); + let channel_id = create_test_channel(&client, &requester).await; + + let request_id = publish_v1_request( + &client, + &requester, + &channel_id, + &agent_hex, + "@agent summarize yesterday", + ) + .await; + + let event = build_disposition( + &agent, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "completed", + "done", + ); + let (accepted, message) = submit_event_http(&client, &agent, &event).await; + assert!(accepted, "disposition rejected: {message}"); + + let (requests, dispositions) = + fetch_channel_state(&client, &auditor.public_key().to_hex(), &channel_id).await; + let request_views: Vec<_> = requests.iter().map(FetchedEvent::view).collect(); + let disposition_views: Vec<_> = dispositions.iter().map(FetchedEvent::view).collect(); + + let acc = buzz_core::disposition::account( + &request_views, + &disposition_views, + buzz_core::disposition::Coverage::complete(), + ); + assert_eq!( + acc.settled, + vec![request_id], + "the target agent's completion must resolve its own request; got {acc:?}" + ); + assert!(acc.unanswered.is_empty()); + assert!(acc.invalid_requests.is_empty()); + assert!(acc.all_resolved()); +} + +/// The cross-principal spoof, live: a human `p`-mentioned on the request +/// publishes a `completed`. The relay stores it (structural validity is all +/// it checks), but the request stays unanswered for every consumer. +#[tokio::test] +#[ignore] +async fn live_path_mentioned_human_cannot_resolve_the_request() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let human = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + + // The request mentions the human as well as targeting the agent. + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), "@agent and @human, fyi") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &agent.public_key().to_hex()]).unwrap(), + Tag::parse(["p", &agent.public_key().to_hex()]).unwrap(), + Tag::parse(["p", &human.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&requester) + .unwrap(); + let (accepted, message) = submit_event_http(&client, &requester, &event).await; + assert!(accepted, "request rejected: {message}"); + let request_id = event.id.to_hex(); + + let spoof = build_disposition( + &human, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "completed", + "I'll say it's done", + ); + let (stored, _) = submit_event_http(&client, &human, &spoof).await; + assert!( + stored, + "the relay checks structural validity only — this event is well-formed \ + and IS stored. The protection is consumer-side binding, which is what \ + the assertions below verify." + ); + + let (requests, dispositions) = + fetch_channel_state(&client, &requester.public_key().to_hex(), &channel_id).await; + let request_views: Vec<_> = requests.iter().map(FetchedEvent::view).collect(); + let disposition_views: Vec<_> = dispositions.iter().map(FetchedEvent::view).collect(); + + let acc = buzz_core::disposition::account( + &request_views, + &disposition_views, + buzz_core::disposition::Coverage::complete(), + ); + assert_eq!( + acc.unanswered, + vec![request_id], + "a merely p-mentioned human must not close the agent's obligation; got {acc:?}" + ); + assert!(acc.settled.is_empty()); + assert!(!acc.all_resolved()); +} + +/// Another agent, live: signs a `completed` for a request addressed to a +/// different agent. Stored, never bound. +#[tokio::test] +#[ignore] +async fn live_path_another_agent_cannot_resolve_the_request() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let other_agent = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + let request_id = publish_v1_request( + &client, + &requester, + &channel_id, + &agent.public_key().to_hex(), + "@agent handle this", + ) + .await; + + let spoof = build_disposition( + &other_agent, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "completed", + "", + ); + submit_event_http(&client, &other_agent, &spoof).await; + + let (requests, dispositions) = + fetch_channel_state(&client, &requester.public_key().to_hex(), &channel_id).await; + let request_views: Vec<_> = requests.iter().map(FetchedEvent::view).collect(); + let disposition_views: Vec<_> = dispositions.iter().map(FetchedEvent::view).collect(); + + let acc = buzz_core::disposition::account( + &request_views, + &disposition_views, + buzz_core::disposition::Coverage::complete(), + ); + assert_eq!(acc.unanswered, vec![request_id]); +} + +/// A marked request naming no agent is classified invalid, NOT counted as an +/// unanswered agent failure. Blaming an agent for a composer fault produces a +/// gap nobody can ever clear. +#[tokio::test] +#[ignore] +async fn live_path_targetless_marker_is_invalid_not_unanswered() { + let client = http_client(); + let requester = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + + // `publish_request` deliberately emits the pre-v1 shape: marker-less. + // Here we add the marker but no `agent` target — the exact event an old + // client or a buggy composer would produce. + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), "someone look at this") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + ]) + .sign_with_keys(&requester) + .unwrap(); + let (accepted, message) = submit_event_http(&client, &requester, &event).await; + assert!(accepted, "request rejected: {message}"); + + let (requests, dispositions) = + fetch_channel_state(&client, &requester.public_key().to_hex(), &channel_id).await; + let request_views: Vec<_> = requests.iter().map(FetchedEvent::view).collect(); + let disposition_views: Vec<_> = dispositions.iter().map(FetchedEvent::view).collect(); + + let acc = buzz_core::disposition::account( + &request_views, + &disposition_views, + buzz_core::disposition::Coverage::complete(), + ); + assert!( + acc.unanswered.is_empty(), + "a targetless marker is a protocol fault, not an agent gap; got {acc:?}" + ); + assert_eq!(acc.invalid_requests.len(), 1); + assert_eq!( + acc.invalid_requests[0].1, + buzz_core::disposition::InvalidRequest::MissingAgentTarget + ); + assert!( + !acc.all_resolved(), + "an invalid request blocks a clean claim" + ); +} + +/// A multi-target request is unsupported in v1 — classified invalid rather +/// than given a request-wide state that either agent could discharge. +#[tokio::test] +#[ignore] +async fn live_path_multi_target_request_is_unsupported() { + let client = http_client(); + let requester = Keys::generate(); + let agent_a = Keys::generate(); + let agent_b = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), "@a @b both look") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &agent_a.public_key().to_hex()]).unwrap(), + Tag::parse(["agent", &agent_b.public_key().to_hex()]).unwrap(), + Tag::parse(["p", &agent_a.public_key().to_hex()]).unwrap(), + Tag::parse(["p", &agent_b.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&requester) + .unwrap(); + submit_event_http(&client, &requester, &event).await; + + // Even with BOTH agents answering — the case that used to manufacture a + // conflict out of two correct responses — the request is simply + // unsupported, not conflicted and not resolved. + for agent in [&agent_a, &agent_b] { + let d = build_disposition( + agent, + &channel_id, + &event.id.to_hex(), + &requester.public_key().to_hex(), + "completed", + "", + ); + submit_event_http(&client, agent, &d).await; + } + + let (requests, dispositions) = + fetch_channel_state(&client, &requester.public_key().to_hex(), &channel_id).await; + let request_views: Vec<_> = requests.iter().map(FetchedEvent::view).collect(); + let disposition_views: Vec<_> = dispositions.iter().map(FetchedEvent::view).collect(); + + let acc = buzz_core::disposition::account( + &request_views, + &disposition_views, + buzz_core::disposition::Coverage::complete(), + ); + assert_eq!( + acc.unsupported_requests[0].1, + buzz_core::disposition::UnsupportedRequest::MultipleAgentTargets + ); + assert!( + acc.disputed.is_empty(), + "two correct answers are not a dispute" + ); + assert!(acc.settled.is_empty()); + assert!( + acc.unanswered.is_empty(), + "an unsupported request is never an agent's gap" + ); +} + +/// `responded` is stored, read back, and reported open — never resolved. +/// This is the state that keeps a harness from claiming completion. +#[tokio::test] +#[ignore] +async fn live_path_responded_is_open_not_resolved() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + let request_id = publish_v1_request( + &client, + &requester, + &channel_id, + &agent.public_key().to_hex(), + "@agent look into this", + ) + .await; + + let event = build_disposition( + &agent, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "responded", + "", + ); + let (accepted, message) = submit_event_http(&client, &agent, &event).await; + assert!( + accepted, + "`responded` must be a valid wire state: {message}" + ); + + let (requests, dispositions) = + fetch_channel_state(&client, &requester.public_key().to_hex(), &channel_id).await; + let request_views: Vec<_> = requests.iter().map(FetchedEvent::view).collect(); + let disposition_views: Vec<_> = dispositions.iter().map(FetchedEvent::view).collect(); + + let acc = buzz_core::disposition::account( + &request_views, + &disposition_views, + buzz_core::disposition::Coverage::complete(), + ); + assert_eq!( + acc.open, + vec![request_id], + "responded is answered but not settled" + ); + assert!(acc.settled.is_empty()); + assert!(!acc.all_resolved()); +} + +/// HTTP and WebSocket ingest are separate code paths in the relay. A request +/// published over one must be indistinguishable from the same request +/// published over the other — otherwise the desktop app (WS) and the CLI +/// (HTTP) would hand consumers differently-shaped obligations, and the +/// verifier's answer would depend on which client happened to send it. +/// +/// This publishes two independently signed but structurally identical +/// requests, one per transport, and asserts the relay returns tag sets that +/// classify to the same obligation shape. +#[tokio::test] +#[ignore] +async fn live_path_http_and_ws_requests_are_indistinguishable() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let channel_id = create_test_channel(&client, &requester).await; + + let tags = vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["t", "request"]).unwrap(), + Tag::parse(["agent", &agent_hex]).unwrap(), + Tag::parse(["p", &agent_hex]).unwrap(), + ]; + + let via_http = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), "over http") + .tags(tags.clone()) + .sign_with_keys(&requester) + .unwrap(); + let (accepted, message) = submit_event_http(&client, &requester, &via_http).await; + assert!(accepted, "HTTP request rejected: {message}"); + + let via_ws = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE), "over ws") + .tags(tags) + .sign_with_keys(&requester) + .unwrap(); + let mut conn = + buzz_ws_client::NostrWsConnection::connect_authenticated(&relay_url(), &requester, None) + .await + .expect("ws connect + NIP-42 auth failed"); + let ok = conn + .send_event(via_ws.clone()) + .await + .expect("ws EVENT failed"); + assert!(ok.accepted, "WS request rejected: {}", ok.message); + conn.disconnect().await.ok(); + + let (requests, _) = + fetch_channel_state(&client, &requester.public_key().to_hex(), &channel_id).await; + let http_stored = requests + .iter() + .find(|e| e.id == via_http.id.to_hex()) + .expect("HTTP-published request not readable"); + let ws_stored = requests + .iter() + .find(|e| e.id == via_ws.id.to_hex()) + .expect("WS-published request not readable"); + + // Tags survive both ingest paths byte-for-byte. If either transport + // normalized, reordered, or dropped a tag, this is where it shows. + assert_eq!( + http_stored.tags, ws_stored.tags, + "the two transports stored different tag sets" + ); + + // And the verifier derives the same obligation from each — the property + // that actually matters to consumers. + let http_class = buzz_core::disposition::classify_request(&http_stored.view()); + let ws_class = buzz_core::disposition::classify_request(&ws_stored.view()); + match (&http_class, &ws_class) { + ( + buzz_core::disposition::RequestClass::Valid(a), + buzz_core::disposition::RequestClass::Valid(b), + ) => { + assert_eq!(a.channel_id, b.channel_id); + assert_eq!(a.requester_pubkey, b.requester_pubkey); + assert_eq!(a.target_agent_pubkey, b.target_agent_pubkey); + assert_eq!(a.target_agent_pubkey, agent_hex); + } + other => panic!("both transports must yield a valid obligation, got {other:?}"), + } +} + +/// The CLI auditor verifies every event's id and signature before applying a +/// single NIP-AD rule, which is what makes it an independent auditor rather +/// than a semantic verifier trusting the relay. That guarantee has a +/// precondition nothing else states: **the relay must return signatures on +/// read.** If it ever stripped them, verification would reject everything and +/// `buzz dispositions list` would report an empty channel — a silent, total +/// failure that every unit test would sail past, because unit fixtures never +/// round-trip through the relay. +#[tokio::test] +#[ignore] +async fn live_path_reads_return_verifiable_signatures() { + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let channel_id = create_test_channel(&client, &requester).await; + let request_id = publish_v1_request( + &client, + &requester, + &channel_id, + &agent.public_key().to_hex(), + "@agent check the signature path", + ) + .await; + let disposition = build_disposition( + &agent, + &channel_id, + &request_id, + &requester.public_key().to_hex(), + "responded", + "", + ); + let (accepted, message) = submit_event_http(&client, &agent, &disposition).await; + assert!(accepted, "disposition rejected: {message}"); + + for (label, filter) in [ + ( + "requests", + Filter::new() + .kind(Kind::Custom(KIND_STREAM_MESSAGE)) + .custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + [channel_id.clone()], + ), + ), + ( + "dispositions", + Filter::new() + .kind(Kind::Custom(KIND_AGENT_DISPOSITION)) + .custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + [channel_id.clone()], + ), + ), + ] { + let events = + query_events_http(&client, &requester.public_key().to_hex(), vec![filter]).await; + assert!(!events.is_empty(), "{label}: nothing returned to verify"); + for value in &events { + let event: nostr::Event = serde_json::from_value(value.clone()) + .unwrap_or_else(|e| panic!("{label}: read event is not a parseable Nostr event ({e}) — the auditor cannot verify what it cannot parse: {value}")); + event.verify().unwrap_or_else(|e| { + panic!("{label}: read event failed signature verification ({e}) — every consumer that verifies before auditing would silently see an empty channel") + }); + } + } +} + +// --------------------------------------------------------------------------- +// The joined completion path: real CLI binary, real parser, real signing. +// +// Every other test here builds and submits events directly. That proves the +// protocol and the accounting, and proves nothing about whether an agent can +// actually reach a settled state in normal operation — which matters because +// the harness deliberately cannot emit `completed`, making the CLI the only +// path to it. A hand-built event would not have caught the NIP documenting +// `--state` when the flag is `--disposition`. +// --------------------------------------------------------------------------- + +/// Path to the built CLI. Override with `BUZZ_CLI_BIN`. +fn cli_binary() -> std::path::PathBuf { + if let Ok(p) = std::env::var("BUZZ_CLI_BIN") { + return std::path::PathBuf::from(p); + } + let target = std::env::var("CARGO_TARGET_DIR") + .unwrap_or_else(|_| format!("{}/../../target", env!("CARGO_MANIFEST_DIR"))); + std::path::PathBuf::from(target).join("release/buzz") +} + +fn run_cli(keys: &Keys, args: &[&str]) -> (bool, String) { + let out = std::process::Command::new(cli_binary()) + .args(args) + .env("BUZZ_RELAY_URL", relay_url()) + .env("BUZZ_PRIVATE_KEY", keys.secret_key().to_secret_hex()) + .output() + .expect("run buzz CLI"); + let merged = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (out.status.success(), merged) +} + +/// A managed agent settles its own request through the real CLI, and an +/// unrelated authorized reader sees a settled obligation. +/// +/// Also pins the two authorization facts that make `completed` meaningful: +/// the CLI refuses a signer that is not the obligation's target, and the +/// flag the NIP tells agents to use is the flag the parser accepts. +#[tokio::test] +#[ignore] +async fn live_path_agent_settles_its_own_request_through_the_cli() { + let cli = cli_binary(); + if !cli.exists() { + panic!( + "CLI binary not found at {}. Build it first: \ + cargo build --release -p buzz-cli (or set BUZZ_CLI_BIN).", + cli.display() + ); + } + + let client = http_client(); + let requester = Keys::generate(); + let agent = Keys::generate(); + let auditor = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let channel_id = create_test_channel(&client, &requester).await; + + // The CLI refuses to send a message mentioning a non-member, so the agent + // joins first — the same step a real deployment performs when attaching a + // managed agent to a channel. + let (ok, out) = run_cli( + &requester, + &[ + "channels", + "add-member", + "--channel", + &channel_id, + "--pubkey", + &agent_hex, + "--role", + "bot", + ], + ); + assert!(ok, "could not add the agent to the channel: {out}"); + + // The request goes out through the CLI's own marking path, not a + // hand-assembled event. + let (ok, out) = run_cli( + &requester, + &[ + "--format", + "compact", + "messages", + "send", + "--channel", + &channel_id, + "--content", + "@agent please do the thing", + "--request-agent", + &agent_hex, + ], + ); + assert!(ok, "CLI request send failed: {out}"); + let request_id = serde_json::from_str::(out.trim()) + .ok() + .and_then(|v| v["event_id"].as_str().map(String::from)) + .unwrap_or_else(|| panic!("no event_id in CLI output: {out}")); + + // The agent settles it. `--disposition` is the flag the NIP instructs + // agents to use; if they ever diverge this fails here rather than in a + // channel nobody can settle. + let (ok, out) = run_cli( + &agent, + &[ + "dispositions", + "emit", + "--request", + &request_id, + "--disposition", + "completed", + "--reason", + "did the thing", + ], + ); + assert!(ok, "agent could not settle its own request: {out}"); + + // A non-target must be refused by the CLI, before anything is published. + let (ok, out) = run_cli( + &requester, + &[ + "dispositions", + "emit", + "--request", + &request_id, + "--disposition", + "completed", + ], + ); + assert!(!ok, "a non-target was allowed to settle: {out}"); + assert!( + out.contains("is not the agent"), + "expected a target-agent refusal, got: {out}" + ); + + // An unrelated authorized reader audits the channel. + let (ok, out) = run_cli( + &auditor, + &[ + "--format", + "compact", + "dispositions", + "list", + "--channel", + &channel_id, + ], + ); + assert!(ok, "auditor read failed: {out}"); + let report: Value = + serde_json::from_str(out.trim()).unwrap_or_else(|e| panic!("bad CLI JSON ({e}): {out}")); + + assert_eq!( + report["settled"].as_array().map(Vec::len), + Some(1), + "the obligation must read as settled: {report}" + ); + assert_eq!(report["settled"][0].as_str(), Some(request_id.as_str())); + assert!(report["unanswered"].as_array().unwrap().is_empty()); + assert_eq!( + report["unverifiable_events"].as_u64(), + Some(0), + "every audited event must have verified" + ); + + let row = report["dispositions"] + .as_array() + .expect("rows") + .iter() + .find(|r| r["request_id"].as_str() == Some(request_id.as_str())) + .expect("a row for the settled request"); + assert_eq!(row["outcome"].as_str(), Some("settled")); + assert_eq!(row["disposition"].as_str(), Some("completed")); + assert_eq!( + row["reason"].as_str(), + Some("did the thing"), + "the agent's stated reason must survive to the auditor" + ); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0a3c49aa2f9..355c3dbec0f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -106,6 +106,8 @@ export default defineConfig({ "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", + "**/agent-disposition.spec.ts", + "**/agent-disposition-screenshots.spec.ts", "**/inbox-edit.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index 976519a076b..af2f6c135ce 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -522,6 +522,9 @@ mod real_relay_tests { &[], None, &relay_ws_url(), + // Mentioned, not requested: this probe asserts the `p` tag, and a + // mention alone must never create a NIP-AD obligation. + &[], ) .unwrap() .sign_with_keys(&viewer) diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 31559777d2b..a7426e74f98 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -452,12 +452,18 @@ pub async fn send_channel_message( kind: Option, expected_relay_url: Option, expected_signer_pubkey: Option, + // NIP-AD: pubkeys of the agents this message is addressed to. Non-empty + // makes it a marked request; each becomes an `["agent", ]` tag + // that dispositions bind against. Empty/absent means "not a request." + request_agent_pubkeys: Option>, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; let mentions = mention_pubkeys.unwrap_or_default(); let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); + let request_agent_pubkeys = request_agent_pubkeys.unwrap_or_default(); + let request_agents: Vec<&str> = request_agent_pubkeys.iter().map(|s| s.as_str()).collect(); let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); @@ -531,6 +537,7 @@ pub async fn send_channel_message( &link_previews, sent_from_thread_tag.as_deref(), &relay_base, + &request_agents, )? } }; @@ -706,6 +713,7 @@ fn build_managed_agent_channel_message( &[], None, &crate::relay::relay_api_base_url(), + &[], // an agent's own reply is never itself a request client_tags, ) } diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs index 9ccf8baac0d..540f7728c0f 100644 --- a/desktop/src-tauri/src/commands/relay_members.rs +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -101,7 +101,7 @@ pub async fn add_relay_member( role: String, state: State<'_, AppState>, ) -> Result { - let builder = events::build_relay_admin_add(&target_pubkey, &role)?; + let builder = events::relay_admin::build_relay_admin_add(&target_pubkey, &role)?; let result = submit_event(builder, &state).await?; serde_json::to_value(result).map_err(|e| e.to_string()) } @@ -111,7 +111,7 @@ pub async fn remove_relay_member( target_pubkey: String, state: State<'_, AppState>, ) -> Result { - let builder = events::build_relay_admin_remove(&target_pubkey)?; + let builder = events::relay_admin::build_relay_admin_remove(&target_pubkey)?; let result = submit_event(builder, &state).await?; serde_json::to_value(result).map_err(|e| e.to_string()) } @@ -122,7 +122,7 @@ pub async fn change_relay_member_role( new_role: String, state: State<'_, AppState>, ) -> Result { - let builder = events::build_relay_admin_change_role(&target_pubkey, &new_role)?; + let builder = events::relay_admin::build_relay_admin_change_role(&target_pubkey, &new_role)?; let result = submit_event(builder, &state).await?; serde_json::to_value(result).map_err(|e| e.to_string()) } diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 0c2a9573af6..21e502cb176 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -167,6 +167,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], None, &crate::relay::relay_api_base_url(), + &[], ) .unwrap(); let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); @@ -184,6 +185,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], None, &crate::relay::relay_api_base_url(), + &[], ) .unwrap(); assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..a57fd5268d2 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -13,6 +13,7 @@ use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; mod message_tags; +pub(crate) mod relay_admin; use message_tags::{ append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags, @@ -260,6 +261,7 @@ pub fn build_message( link_preview_tags: &[Vec], sent_from_thread_tag: Option<&[String]>, relay_base: &str, + request_agents: &[&str], ) -> Result { build_message_with_client_tags( channel_id, @@ -272,6 +274,7 @@ pub fn build_message( link_preview_tags, sent_from_thread_tag, relay_base, + request_agents, &[], ) } @@ -293,6 +296,7 @@ pub fn build_message_with_client_tags( link_preview_tags: &[Vec], sent_from_thread_tag: Option<&[String]>, relay_base: &str, + request_agents: &[&str], client_tags: &[Vec], ) -> Result { if sent_from_thread_tag.is_some() && thread_ref.is_some() { @@ -309,6 +313,26 @@ pub fn build_message_with_client_tags( mention_reference_tags(mention_ref_tags, &mut tags)?; crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?; + // NIP-AD: marks this message as a request awaiting a disposition, and + // names each agent it is addressed to. The `agent` tags are what a + // disposition binds against — a disposition only resolves this request + // if its signer is one of these pubkeys. That binding is deliberately + // NOT the `p` mention set: `p` includes every mentioned principal + // (humans CC'd on the message included), and a mentioned human must not + // be able to close an agent's obligation. Empty `request_agents` means + // this is not a request at all — the two facts cannot disagree because + // they are the same value. See docs/nips/NIP-AD.md. + if !request_agents.is_empty() { + tags.push(tag(vec!["t", "request"])?); + let mut seen = std::collections::HashSet::new(); + for &hex in request_agents { + check_pubkey(hex)?; + let lower = hex.to_ascii_lowercase(); + if seen.insert(lower.clone()) { + tags.push(tag(vec!["agent", &lower])?); + } + } + } append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -536,53 +560,6 @@ pub fn build_note( Ok(EventBuilder::new(Kind::TextNote, content).tags(tags)) } -// ── Relay admin (NIP-43) ──────────────────────────────────────────────────── - -/// Allowed relay member roles for NIP-43 admin commands. -const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"]; - -fn check_relay_role(role: &str) -> Result<(), String> { - if !VALID_RELAY_ROLES.contains(&role) { - return Err(format!( - "invalid relay role \"{role}\" (expected one of: {})", - VALID_RELAY_ROLES.join(", ") - )); - } - Ok(()) -} - -/// Kind 9030 — add a pubkey to the relay member list. -pub fn build_relay_admin_add(target_pubkey: &str, role: &str) -> Result { - check_pubkey(target_pubkey)?; - check_relay_role(role)?; - let tags = vec![ - tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?, - tag(vec!["role", role])?, - ]; - Ok(EventBuilder::new(Kind::Custom(9030), "").tags(tags)) -} - -/// Kind 9031 — remove a pubkey from the relay member list. -pub fn build_relay_admin_remove(target_pubkey: &str) -> Result { - check_pubkey(target_pubkey)?; - let tags = vec![tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?]; - Ok(EventBuilder::new(Kind::Custom(9031), "").tags(tags)) -} - -/// Kind 9032 — change the role of an existing relay member. -pub fn build_relay_admin_change_role( - target_pubkey: &str, - new_role: &str, -) -> Result { - check_pubkey(target_pubkey)?; - check_relay_role(new_role)?; - let tags = vec![ - tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?, - tag(vec!["role", new_role])?, - ]; - Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags)) -} - // ── NIP-IA identity archival ───────────────────────────────────────────────── // // kind:9035 archive request, kind:9036 unarchive request. diff --git a/desktop/src-tauri/src/events/relay_admin.rs b/desktop/src-tauri/src/events/relay_admin.rs new file mode 100644 index 00000000000..60cfeb84517 --- /dev/null +++ b/desktop/src-tauri/src/events/relay_admin.rs @@ -0,0 +1,48 @@ +use nostr::{EventBuilder, Kind}; + +use super::{check_pubkey, tag}; + +/// Allowed relay member roles for NIP-43 admin commands. +const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"]; + +fn check_relay_role(role: &str) -> Result<(), String> { + if !VALID_RELAY_ROLES.contains(&role) { + return Err(format!( + "invalid relay role \"{role}\" (expected one of: {})", + VALID_RELAY_ROLES.join(", ") + )); + } + Ok(()) +} + +/// Kind 9030 — add a pubkey to the relay member list. +pub fn build_relay_admin_add(target_pubkey: &str, role: &str) -> Result { + check_pubkey(target_pubkey)?; + check_relay_role(role)?; + let tags = vec![ + tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?, + tag(vec!["role", role])?, + ]; + Ok(EventBuilder::new(Kind::Custom(9030), "").tags(tags)) +} + +/// Kind 9031 — remove a pubkey from the relay member list. +pub fn build_relay_admin_remove(target_pubkey: &str) -> Result { + check_pubkey(target_pubkey)?; + let tags = vec![tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?]; + Ok(EventBuilder::new(Kind::Custom(9031), "").tags(tags)) +} + +/// Kind 9032 — change the role of an existing relay member. +pub fn build_relay_admin_change_role( + target_pubkey: &str, + new_role: &str, +) -> Result { + check_pubkey(target_pubkey)?; + check_relay_role(new_role)?; + let tags = vec![ + tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?, + tag(vec!["role", new_role])?, + ]; + Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags)) +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index afa7aed8e05..0b34aa030df 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -659,6 +659,7 @@ pub(crate) fn spawn_transcription_task( &[], None, &crate::relay::relay_api_base_url(), + &[], // auto-posted transcript, not a human/agent request ) { Ok(b) => b, Err(e) => { diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95e3..08c6e059c23 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -53,6 +53,12 @@ import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSend import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages"; import { Button } from "@/shared/ui/button"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; +import { useRenderScopedDispositionHydration } from "@/features/messages/lib/useRenderScopedDispositionHydration"; +import { + deriveChannelDispositionSummary, + hasVisibleRequests, +} from "@/features/messages/lib/dispositionSummary"; +import { DispositionSummaryChip } from "@/features/messages/ui/DispositionSummaryChip"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -409,12 +415,27 @@ export const ChannelPane = React.memo(function ChannelPane({ profiles, threadSummaries, }); + const dispositionSummary = React.useMemo( + () => deriveChannelDispositionSummary(mainTimelineEntries), + [mainTimelineEntries], + ); + const handleJumpToRequest = React.useCallback((messageId: string) => { + document + .querySelector(`[data-message-id="${messageId}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, []); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, threadHeadMessage, threadMessages, }); + useRenderScopedDispositionHydration({ + activeChannel, + mainTimelineEntries, + threadHeadMessage, + threadMessages, + }); const activeVideoReviewCommentSender = activeChannel?.archivedAt ? undefined : onSendVideoReviewComment; @@ -567,6 +588,23 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {isHuddleTranscript ? null : header} + {/* The wrapper is gated on the same predicate as the chip's own + early return, not on a second copy of the condition. Rendering it + unconditionally left its `pb-1.5` behind in every channel with no + marked requests: 6px of dead space under the header, and the + timeline's sticky day divider pushed off its designed offset. */} + {isHuddleTranscript || + !hasVisibleRequests(dispositionSummary) ? null : ( +
+ +
+ )} { await sendMutateRef.current({ content, @@ -296,6 +297,7 @@ export function useChannelPaneHandlers({ mediaTags, channelId: channelId ?? undefined, forceRest, + requestAgentPubkeys, }); }, [], @@ -334,6 +336,7 @@ export function useChannelPaneHandlers({ threadHeadId: string | null; } | null, forceRest?: boolean, + requestAgentPubkeys?: string[], ) => { // Resolve target using captured submit-time context (race-free) or live // refs (legacy path). When threadContext is supplied, no live-ref reads @@ -367,6 +370,7 @@ export function useChannelPaneHandlers({ mediaTags, channelId: channelId ?? undefined, forceRest, + requestAgentPubkeys, }); // Only update thread UI state if the user is still viewing the same diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index e1cdee41a76..610fd3e31eb 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -21,6 +21,7 @@ import { initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; +import { resetRenderScopedDispositionHydration } from "@/features/messages/lib/renderScopedDispositions"; import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; import { @@ -73,6 +74,7 @@ async function resetCommunityState({ resetLinkPreviewMetadataCache(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); + resetRenderScopedDispositionHydration(); resetBackgroundMediaUploads(); resetLinkPreviewPreparations(); clearSearchHitEventCache(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf8..430f1be658e 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -454,6 +454,15 @@ export function useSendMessageMutation( sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; transport?: "auto" | "http"; + /** + * NIP-AD: pubkeys of the agents this message is addressed to. Non-empty + * makes it a marked request awaiting a disposition, and each becomes an + * `["agent", ]` tag that a disposition binds against. Only + * meaningful for real channels — DMs are out of scope for v1 (see + * docs/nips/NIP-AD.md), so this is silently dropped for a DM channel + * rather than requiring every caller to know that boundary. + */ + requestAgentPubkeys?: string[]; }, MessageQueryContext | undefined >({ @@ -468,6 +477,7 @@ export function useSendMessageMutation( sentFromThreadRootId, sentFromThreadRootExcerpt, transport = "auto", + requestAgentPubkeys, }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -495,6 +505,23 @@ export function useSendMessageMutation( throw new Error("No identity available for sending messages."); } + // NIP-AD v1 scopes the request marker to real channels — DMs are out + // of scope (see docs/nips/NIP-AD.md). Resolved here, not upstream, + // because this is the one place `effectiveChannel.channelType` is + // authoritatively known; callers only need to name the agents they + // addressed, not track the channel/DM boundary themselves. + const effectiveRequestAgents = + effectiveChannel.channelType === "dm" + ? [] + : (requestAgentPubkeys ?? []); + const requestTags: string[][] = + effectiveRequestAgents.length > 0 + ? [ + ["t", "request"], + ...effectiveRequestAgents.map((pk) => ["agent", pk]), + ] + : []; + // `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30 // emoji). Split it so each kind goes to its own validated Tauri arg — // emoji tags must NOT ride the imeta-only `media` channel (that gate @@ -549,6 +576,11 @@ export function useSendMessageMutation( mentionTags, linkPreviewTags, sentFromThreadTag, + // expectedRelayUrl / expectedSignerPubkey are positional and unused + // at this call site, same as before NIP-AD. + undefined, + undefined, + effectiveRequestAgents, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -588,6 +620,7 @@ export function useSendMessageMutation( ...mentionTags, ...linkPreviewTags, ...(sentFromThreadTag ? [sentFromThreadTag] : []), + ...requestTags, ], content: content.trim(), sig: "", @@ -598,7 +631,11 @@ export function useSendMessageMutation( effectiveChannel.id, content, recipientPubkeys, - [...mentionTags, ...(sentFromThreadTag ? [sentFromThreadTag] : [])], + [ + ...mentionTags, + ...(sentFromThreadTag ? [sentFromThreadTag] : []), + ...requestTags, + ], ); }, onMutate: async ({ diff --git a/desktop/src/features/messages/lib/dispositionSummary.adapter.test.mjs b/desktop/src/features/messages/lib/dispositionSummary.adapter.test.mjs new file mode 100644 index 00000000000..ef7d1484174 --- /dev/null +++ b/desktop/src/features/messages/lib/dispositionSummary.adapter.test.mjs @@ -0,0 +1,361 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + account, + classifyRequest, + COMPLETE_COVERAGE, + deriveObligation, + REQUEST_KINDS, +} from "@/shared/lib/disposition"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { deriveChannelDispositionSummary } from "./dispositionSummary.ts"; + +/** + * Adapter conformance: the desktop's per-request status and channel summary + * must agree with the shared verifier on every request, field by field. + * + * **This test previously compared bucket sums, and that was not enough.** + * Swapping the desktop meanings of `responded` and `errored` passed all seven + * of its cases, because the mixed fixture had one of each and the assertion + * only checked `respondedUnsettled + attemptFailed` against `open.length`. A + * test built as the structural guarantee against cross-adapter divergence was + * mutation-checked against the one defect it was designed for, and never + * asked what else it should catch. + * + * So the comparison is now per-request and total: for every marked event, the + * classification, outcome kind AND state, latest observation, warnings, + * reason, and resolved flag must match an oracle built directly from the + * shared verifier. Aggregates are checked too, but they are the weaker claim. + */ + +const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); +const HUMAN = "c".repeat(64); +const REQUESTER = "d".repeat(64); +const KIND_AGENT_DISPOSITION = 44300; + +let seq = 0; +const nextId = () => String(++seq).padStart(64, "0"); + +function request(tags, id = nextId()) { + return { + id, + pubkey: REQUESTER, + kind: REQUEST_KINDS[0], + created_at: 100, + content: "@agent do the thing", + tags, + sig: "", + }; +} + +function disposition(requestId, signer, state, createdAt = 200, reason) { + return { + id: nextId(), + pubkey: signer, + kind: KIND_AGENT_DISPOSITION, + created_at: createdAt, + content: JSON.stringify({ + disposition: state, + reason: reason ?? `r-${state}`, + }), + tags: [ + ["e", requestId], + ["h", CHANNEL], + ["p", REQUESTER], + ["disposition", state], + ], + sig: "", + }; +} + +const validTags = (agent = AGENT) => [ + ["h", CHANNEL], + ["t", "request"], + ["agent", agent], + ["p", agent], +]; + +/** + * The oracle: what every marked event's status must be, computed straight + * from the shared verifier with no desktop code involved. + */ +function oracle(events) { + const byRequest = new Map(); + for (const e of events) { + if (e.kind !== KIND_AGENT_DISPOSITION) continue; + const target = e.tags.find((t) => t[0] === "e")?.[1]; + if (target == null) continue; + byRequest.set(target, [...(byRequest.get(target) ?? []), e]); + } + + const expected = new Map(); + for (const event of events) { + const classified = classifyRequest(event); + switch (classified.kind) { + case "not_request": + break; + case "invalid": + case "unsupported": + expected.set(event.id, { + kind: classified.kind, + reason: classified.reason, + }); + break; + case "valid": { + const derived = deriveObligation( + classified.obligation, + byRequest.get(event.id) ?? [], + ); + expected.set(event.id, { + kind: "valid", + outcome: derived.outcome, + latestObservation: derived.latestObservation, + reason: derived.reason, + warnings: derived.warnings, + resolved: derived.outcome.kind === "settled", + }); + break; + } + } + } + return expected; +} + +/** Run one event set through both paths and compare every field. */ +function compare(events, label) { + const expected = oracle(events); + const shared = account(events, events, COMPLETE_COVERAGE); + + const messages = formatTimelineMessages( + events, + { id: CHANNEL, name: "general", channelType: "channel" }, + REQUESTER, + null, + ); + const summary = deriveChannelDispositionSummary( + messages.map((message) => ({ message })), + ); + + // --- per-request equivalence, which is the real claim --- + const actual = new Map( + messages + .filter((m) => m.requestStatus != null) + .map((m) => [m.id, m.requestStatus]), + ); + assert.deepEqual( + [...actual.keys()].sort(), + [...expected.keys()].sort(), + `${label}: the desktop and the verifier disagree about WHICH events carry a status`, + ); + for (const [id, want] of expected) { + assert.deepEqual( + actual.get(id), + want, + `${label}: request ${id} differs from the shared verifier`, + ); + } + + // --- aggregates, the weaker claim, kept as a second net --- + assert.equal(summary.settled, shared.settled.length, `${label}: settled`); + assert.equal( + summary.noRecord.length, + shared.unanswered.length, + `${label}: unanswered — the bucket a client fault used to leak into`, + ); + assert.equal(summary.disputed, shared.disputed.length, `${label}: disputed`); + assert.equal( + summary.invalidOrUnsupported, + shared.invalidRequests.length + shared.unsupportedRequests.length, + `${label}: invalid/unsupported`, + ); + // Split, not summed: summing these is exactly what let a responded/errored + // swap pass. + assert.equal( + summary.respondedUnsettled, + shared.open.filter((id) => expected.get(id)?.outcome?.state === "responded") + .length, + `${label}: responded`, + ); + assert.equal( + summary.attemptFailed, + shared.open.filter((id) => expected.get(id)?.outcome?.state === "errored") + .length, + `${label}: errored`, + ); + return { summary, shared, expected }; +} + +test("settled, open, and unanswered obligations agree per request", () => { + const settled = request(validTags()); + const responded = request(validTags()); + const gap = request(validTags()); + compare( + [ + settled, + responded, + gap, + disposition(settled.id, AGENT, "completed"), + disposition(responded.id, AGENT, "responded"), + ], + "mixed", + ); +}); + +test("responded and errored are distinguished, not summed", () => { + // The mutation the old aggregate test could not see: swapping the desktop + // meanings of these two states. + // + // **The counts here are deliberately asymmetric (2 vs 1).** A one-of-each + // fixture cannot detect a swap — both counts stay 1 — which is precisely + // why the previous version of this file passed with the states reversed. + // A test whose fixture is symmetric in the dimension under test proves + // nothing about that dimension. + const a = request(validTags()); + const b = request(validTags()); + const c = request(validTags()); + const { summary } = compare( + [ + a, + b, + c, + disposition(a.id, AGENT, "responded"), + disposition(b.id, AGENT, "responded"), + disposition(c.id, AGENT, "errored"), + ], + "responded vs errored", + ); + assert.equal(summary.respondedUnsettled, 2); + assert.equal(summary.attemptFailed, 1); +}); + +test("an invalid request is never counted as an agent gap by either path", () => { + const targetless = request([ + ["h", CHANNEL], + ["t", "request"], + ["p", HUMAN], + ]); + const { summary, shared } = compare([targetless], "targetless"); + assert.equal(summary.noRecord.length, 0); + assert.equal(summary.invalidOrUnsupported, 1); + assert.equal(shared.invalidRequests.length, 1); +}); + +test("an unsupported multi-target request agrees per request", () => { + const multi = request([ + ["h", CHANNEL], + ["t", "request"], + ["agent", AGENT], + ["agent", OTHER_AGENT], + ["p", AGENT], + ["p", OTHER_AGENT], + ]); + const { summary } = compare([multi], "multi-target"); + assert.equal(summary.noRecord.length, 0); + assert.equal(summary.invalidOrUnsupported, 1); +}); + +test("a spoofed disposition leaves the obligation unanswered in both paths", () => { + const req = request(validTags()); + const { summary } = compare( + [req, disposition(req.id, HUMAN, "completed")], + "spoof", + ); + assert.equal(summary.noRecord.length, 1); + assert.equal(summary.settled, 0); +}); + +test("a disputed history agrees per request, including its reason", () => { + const req = request(validTags()); + const { expected } = compare( + [ + req, + disposition(req.id, AGENT, "completed", 200), + disposition(req.id, AGENT, "refused", 300, "changed course"), + ], + "disputed", + ); + assert.equal(expected.get(req.id).outcome.kind, "disputed"); +}); + +test("a settled obligation stays settled under a later stray write", () => { + const req = request(validTags()); + const { summary, expected } = compare( + [ + req, + disposition(req.id, AGENT, "completed", 200), + disposition(req.id, AGENT, "errored", 300), + ], + "absorbed", + ); + assert.equal(summary.settled, 1); + assert.equal(summary.attemptFailed, 0); + // The warning and the raw observation must both survive to the UI. + assert.deepEqual(expected.get(req.id).warnings, ["ordered_after_terminal"]); + assert.equal(expected.get(req.id).latestObservation, "errored"); +}); + +test("a structurally invalid disposition never reaches either path", () => { + // The validity boundary: an event the relay would reject must not settle + // anything here either. + const req = request(validTags()); + const doubleE = disposition(req.id, AGENT, "completed"); + doubleE.tags.push(["e", "9".repeat(64)]); + const { summary } = compare([req, doubleE], "invalid disposition"); + assert.equal(summary.settled, 0); + assert.equal(summary.noRecord.length, 1); +}); + +test("every bucket at once still agrees, field by field", () => { + const settled = request(validTags()); + const responded = request(validTags()); + const responded2 = request(validTags()); + const errored = request(validTags()); + const gap = request(validTags()); + const disputed = request(validTags()); + const invalid = request([ + ["h", CHANNEL], + ["t", "request"], + ["p", HUMAN], + ]); + const unsupported = request([ + ["h", CHANNEL], + ["t", "request"], + ["agent", AGENT], + ["agent", OTHER_AGENT], + ["p", AGENT], + ["p", OTHER_AGENT], + ]); + + const { summary } = compare( + [ + settled, + responded, + responded2, + errored, + gap, + disputed, + invalid, + unsupported, + disposition(settled.id, AGENT, "completed"), + disposition(responded.id, AGENT, "responded"), + disposition(responded2.id, AGENT, "responded"), + disposition(errored.id, AGENT, "errored"), + disposition(disputed.id, AGENT, "completed", 200), + disposition(disputed.id, AGENT, "refused", 300), + ], + "all buckets", + ); + + // Asymmetric on purpose — see the responded/errored test above. + assert.equal(summary.total, 8); + assert.equal(summary.settled, 1); + assert.equal(summary.respondedUnsettled, 2); + assert.equal(summary.attemptFailed, 1); + assert.equal(summary.noRecord.length, 1); + assert.equal(summary.disputed, 1); + assert.equal(summary.invalidOrUnsupported, 2); + assert.equal(summary.needsAttention, 7); +}); diff --git a/desktop/src/features/messages/lib/dispositionSummary.test.mjs b/desktop/src/features/messages/lib/dispositionSummary.test.mjs new file mode 100644 index 00000000000..0f7d904ee9d --- /dev/null +++ b/desktop/src/features/messages/lib/dispositionSummary.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveChannelDispositionSummary } from "./dispositionSummary.ts"; + +/** + * Unit tests for the tally itself. The classification these read comes from + * the shared verifier upstream — see `dispositionSummary.adapter.test.mjs` + * for the test that pins this tally against `account()` on real events, which + * is what stops the two from drifting. + */ + +function valid(id, outcome, extra = {}) { + return { + id, + body: `request ${id}`, + requestStatus: { + kind: "valid", + outcome, + latestObservation: outcome.state ?? null, + reason: "", + warnings: [], + resolved: outcome.kind === "settled", + ...extra, + }, + }; +} + +function unclassified(id) { + return { id, body: `ordinary ${id}` }; +} + +test("counts only messages carrying a request status", () => { + const summary = deriveChannelDispositionSummary([ + { message: valid("r1", { kind: "unanswered" }) }, + { message: unclassified("o1") }, + ]); + assert.equal(summary.total, 1); +}); + +test("a valid obligation with nothing bound is a real gap", () => { + const summary = deriveChannelDispositionSummary([ + { message: valid("r1", { kind: "unanswered" }) }, + ]); + assert.deepEqual(summary.noRecord, [{ id: "r1", body: "request r1" }]); + assert.equal(summary.settled, 0); + assert.equal(summary.needsAttention, 1); +}); + +test("settled is the only done count", () => { + const summary = deriveChannelDispositionSummary([ + { message: valid("r1", { kind: "settled", state: "completed" }) }, + { message: valid("r2", { kind: "settled", state: "refused" }) }, + ]); + assert.equal(summary.settled, 2); + assert.equal(summary.needsAttention, 0); +}); + +test("responded and errored are separate counts, and neither is settled", () => { + // They used to share one `answered` count, so a channel whose every turn + // failed rendered "all requests answered". "Has a record" and "was + // answered" are different facts. + const summary = deriveChannelDispositionSummary([ + { message: valid("r1", { kind: "open", state: "responded" }) }, + { message: valid("r2", { kind: "open", state: "errored" }) }, + ]); + assert.equal(summary.respondedUnsettled, 1); + assert.equal(summary.attemptFailed, 1); + assert.equal(summary.settled, 0); + assert.equal(summary.needsAttention, 2); +}); + +test("a disputed history is counted as disputed, never settled", () => { + const summary = deriveChannelDispositionSummary([ + { message: valid("r1", { kind: "disputed" }) }, + ]); + assert.equal(summary.disputed, 1); + assert.equal(summary.settled, 0); + assert.equal(summary.needsAttention, 1); +}); + +test("a warning does not move a settled obligation out of settled", () => { + // The point of separating warnings from outcomes: a duplicate delivery is + // redundant, not disputed, and must not read as an unresolved problem. + const summary = deriveChannelDispositionSummary([ + { + message: valid( + "r1", + { kind: "settled", state: "completed" }, + { warnings: ["duplicate_terminal"] }, + ), + }, + ]); + assert.equal(summary.settled, 1); + assert.equal(summary.disputed, 0); + assert.equal(summary.needsAttention, 0); +}); + +test("invalid and unsupported requests are their own bucket, never gaps", () => { + const summary = deriveChannelDispositionSummary([ + { + message: { + id: "r1", + body: "marked but targetless", + requestStatus: { kind: "invalid", reason: "missing_agent_target" }, + }, + }, + { + message: { + id: "r2", + body: "two agents", + requestStatus: { + kind: "unsupported", + reason: "multiple_agent_targets", + }, + }, + }, + ]); + assert.equal(summary.invalidOrUnsupported, 2); + assert.deepEqual( + summary.noRecord, + [], + "a client fault must never be filed as an agent's unanswered request", + ); + assert.equal(summary.needsAttention, 2); +}); + +test("an empty timeline yields an all-zero summary", () => { + const summary = deriveChannelDispositionSummary([]); + assert.deepEqual(summary, { + total: 0, + settled: 0, + respondedUnsettled: 0, + attemptFailed: 0, + noRecord: [], + disputed: 0, + invalidOrUnsupported: 0, + needsAttention: 0, + }); +}); + +test("every request lands in exactly one bucket", () => { + const summary = deriveChannelDispositionSummary([ + { message: valid("r1", { kind: "settled", state: "completed" }) }, + { message: valid("r2", { kind: "open", state: "responded" }) }, + { message: valid("r3", { kind: "open", state: "errored" }) }, + { message: valid("r4", { kind: "unanswered" }) }, + { message: valid("r5", { kind: "disputed" }) }, + { + message: { + id: "r6", + body: "bad", + requestStatus: { kind: "invalid", reason: "missing_channel" }, + }, + }, + ]); + const bucketed = + summary.settled + + summary.respondedUnsettled + + summary.attemptFailed + + summary.noRecord.length + + summary.disputed + + summary.invalidOrUnsupported; + assert.equal(bucketed, summary.total, "buckets must partition the total"); + assert.equal(summary.needsAttention, summary.total - summary.settled); +}); diff --git a/desktop/src/features/messages/lib/dispositionSummary.ts b/desktop/src/features/messages/lib/dispositionSummary.ts new file mode 100644 index 00000000000..a9b7d2dd7aa --- /dev/null +++ b/desktop/src/features/messages/lib/dispositionSummary.ts @@ -0,0 +1,129 @@ +import type { MainTimelineEntry } from "./threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Channel accountability counts. + * + * Five non-overloaded buckets. An earlier version had an `answered` count + * that incremented for *any* disposition, including `errored`, so a channel + * whose every turn failed could render "all requests answered". "Has some + * record" and "was answered" are different facts, and a summary that blurs + * them is worse than no summary. + * + * Every marked request lands in exactly one bucket, so `total` is their sum + * and nothing is counted twice. + */ +export type ChannelDispositionSummary = { + /** Marked requests among currently-loaded messages. */ + total: number; + /** Settled: a terminal claim bound. The only "done" count. */ + settled: number; + /** Answered, not settled (`responded`). */ + respondedUnsettled: number; + /** The turn failed (`errored`). Not an answer. */ + attemptFailed: number; + /** Valid obligations with nothing bound: real gaps. */ + noRecord: Array<{ id: string; body: string }>; + /** Contradictory terminal claims. */ + disputed: number; + /** + * Marked events that are not usable obligations — malformed (a client bug) + * or unrepresentable in v1. Never counted against an agent, and never + * folded into `noRecord`: filing a client fault as an agent's gap creates a + * gap nobody can ever clear. + */ + invalidOrUnsupported: number; + /** Anything not settled: what a reader should look at. */ + needsAttention: number; +}; + +/** + * Whether the summary has anything to render. + * + * The chip's own early return and its caller's layout wrapper must agree + * exactly. When they were written separately they did not: the chip returned + * `null` for an empty channel while the wrapper still rendered its `pb-1.5`, + * leaving 6px of dead space under the header of every channel with no marked + * requests — which is nearly all of them — and knocking the timeline's sticky + * day divider off its designed 8px offset. + * + * Exported so there is one definition with two callers rather than one + * condition written twice. + */ +export function hasVisibleRequests( + summary: ChannelDispositionSummary, +): boolean { + return summary.total > 0; +} + +/** + * Derives the channel accountability summary from whatever timeline data is + * currently loaded. + * + * **This tallies; it does not derive.** Every `requestStatus` was produced by + * the shared verifier in `formatTimelineMessages`, so there is no second + * classification here that could disagree with the CLI's. The previous + * version *did* classify independently — it treated any marked message + * without a disposition as unanswered — and that put invalid requests in the + * agent-gap bucket, contradicting the rule stated in the file next to it. + * `dispositionSummary.adapter.test.mjs` pins this tally against the shared + * `account()` over the same events. + * + * The counts reflect loaded/visible history, not necessarily the channel's + * full lifetime: a request that scrolled out of the loaded window before its + * disposition was aux-hydrated could be undercounted. That is why no surface + * built on this may make a channel-wide claim — see `Coverage` in the shared + * module, and design.md Decision 5. + */ +export function deriveChannelDispositionSummary( + entries: readonly MainTimelineEntry[], +): ChannelDispositionSummary { + const summary: ChannelDispositionSummary = { + total: 0, + settled: 0, + respondedUnsettled: 0, + attemptFailed: 0, + noRecord: [], + disputed: 0, + invalidOrUnsupported: 0, + needsAttention: 0, + }; + + for (const { message } of entries) { + const status: TimelineMessage["requestStatus"] = message.requestStatus; + if (!status) { + continue; + } + summary.total += 1; + + if (status.kind === "invalid" || status.kind === "unsupported") { + summary.invalidOrUnsupported += 1; + summary.needsAttention += 1; + continue; + } + + switch (status.outcome.kind) { + case "settled": + summary.settled += 1; + break; + case "open": + if (status.outcome.state === "errored") { + summary.attemptFailed += 1; + } else { + summary.respondedUnsettled += 1; + } + summary.needsAttention += 1; + break; + case "unanswered": + summary.noRecord.push({ id: message.id, body: message.body }); + summary.needsAttention += 1; + break; + case "disputed": + summary.disputed += 1; + summary.needsAttention += 1; + break; + } + } + + return summary; +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index ee4cc628f26..5760818e1dc 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -773,3 +773,253 @@ test("verified agent owner may publish a suppression edit", () => { true, ); }); + +// ── NIP-AD dispositions (kind:44300) ──────────────────────────────────────── +// Dispositions overlay the request message the same way reactions do: +// #e-referenced, attached to the request's own row as `.requestStatus`, not +// rendered as their own row. See docs/nips/NIP-AD.md. + +function dispositionEvent(requestId, disposition, overrides = {}) { + return { + id: HEX64_B, + pubkey: PUBKEY_B, + kind: 44300, + created_at: 1_700_000_001, + content: JSON.stringify({ disposition, reason: overrides.reason ?? "" }), + tags: [ + ["h", CHANNEL_ID], + ["e", requestId], + ["p", PUBKEY_A], + ["disposition", disposition], + ], + sig: "sig", + ...overrides, + }; +} + +// A marked request targeting PUBKEY_B — every `dispositionEvent` in this +// section is signed by PUBKEY_B, so the request must name it as an `agent` +// target for the binding check (see formatTimelineMessages.ts) to accept +// the disposition. PUBKEY_A rides along as a plain `p` mention (an +// uninvolved human CC), so any test that passes only because binding fell +// back to the mention set would be caught. `streamMessage()`'s bare default +// is intentionally left alone for tests elsewhere in this file. +function requestForAgent(overrides = {}) { + return streamMessage({ + tags: [ + ["h", CHANNEL_ID], + ["t", "request"], + ["agent", PUBKEY_B], + ["p", PUBKEY_B], + ["p", PUBKEY_A], + ], + ...overrides, + }); +} + +test("a completed disposition attaches to the request message it answers", () => { + const events = [ + requestForAgent(), + dispositionEvent(HEX64_A, "completed", { reason: "" }), + ]; + const [message] = formatTimelineMessages(events, null, undefined, null); + assert.deepEqual(message.requestStatus, { + kind: "valid", + outcome: { kind: "settled", state: "completed" }, + latestObservation: "completed", + reason: "", + warnings: [], + resolved: true, + }); +}); + +test("a refused disposition carries its reason", () => { + const events = [ + requestForAgent(), + dispositionEvent(HEX64_A, "refused", { reason: "outside my delegation" }), + ]; + const [message] = formatTimelineMessages(events, null, undefined, null); + assert.deepEqual(message.requestStatus, { + kind: "valid", + outcome: { kind: "settled", state: "refused" }, + latestObservation: "refused", + reason: "outside my delegation", + warnings: [], + resolved: true, + }); +}); + +test("a disposition from a merely-mentioned signer does not resolve the request", () => { + // Cross-principal binding (external design review): PUBKEY_A is `p`- + // mentioned on the request but is NOT one of its `agent` targets. Under + // the earlier mention-set binding this assertion would fail — that rule + // let any CC'd human close the agent's obligation. + const events = [ + requestForAgent(), + dispositionEvent(HEX64_A, "completed", { pubkey: PUBKEY_A }), + ]; + const [message] = formatTimelineMessages(events, null, undefined, null); + assert.deepEqual( + message.requestStatus.outcome, + { kind: "unanswered" }, + "an unbound disposition must leave the obligation exactly as unanswered as it was", + ); + assert.equal( + message.requestStatus.latestObservation, + null, + "an unbound event contributes nothing at all, not even an observation", + ); +}); + +test("a disposition on a message that was never marked as a request is ignored", () => { + // Binding requires the `t:request` marker too, matching what the CLI's + // gap-detection query selects. Without it the two implementations would + // disagree about which events are even candidates. + const events = [ + streamMessage({ + tags: [ + ["h", CHANNEL_ID], + ["agent", PUBKEY_B], + ], + }), + dispositionEvent(HEX64_A, "completed", { reason: "" }), + ]; + const [message] = formatTimelineMessages(events, null, undefined, null); + assert.equal(message.requestStatus, undefined); +}); + +test("a disposition after a terminal state warns, and never reopens it", () => { + const completed = dispositionEvent(HEX64_A, "completed", { + id: "c".repeat(64), + created_at: 1_700_000_001, + }); + const errored = dispositionEvent(HEX64_A, "errored", { + id: "d".repeat(64), + created_at: 1_700_000_002, + reason: "tool blew up later", + }); + const [message] = formatTimelineMessages( + [requestForAgent(), completed, errored], + null, + undefined, + null, + ); + assert.equal( + message.requestStatus.outcome.state, + "completed", + "a terminal claim absorbs a later weaker observation instead of being reopened", + ); + assert.equal(message.requestStatus.latestObservation, "errored"); + assert.deepEqual(message.requestStatus.warnings, ["ordered_after_terminal"]); + assert.equal( + message.requestStatus.resolved, + true, + "the settled result stands; the stray write is a warning", + ); +}); + +test("errored then completed is a clean repair", () => { + const errored = dispositionEvent(HEX64_A, "errored", { + id: "c".repeat(64), + created_at: 1_700_000_001, + reason: "tool call failed", + }); + const completed = dispositionEvent(HEX64_A, "completed", { + id: "d".repeat(64), + created_at: 1_700_000_002, + reason: "", + }); + const [message] = formatTimelineMessages( + [requestForAgent(), errored, completed], + null, + undefined, + null, + ); + assert.equal(message.requestStatus.outcome.state, "completed"); + assert.deepEqual(message.requestStatus.warnings, []); +}); + +test("same-second dispositions break ties by lexicographically greatest id", () => { + // Both share created_at — construction order must not leak into the + // result (mirrors the buzz-cli same-second tiebreaker test). + const zzz = dispositionEvent(HEX64_A, "errored", { + id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + created_at: 1_700_000_005, + }); + const aaa = dispositionEvent(HEX64_A, "completed", { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + created_at: 1_700_000_005, + }); + + const forward = formatTimelineMessages( + [requestForAgent(), zzz, aaa], + null, + undefined, + null, + ); + // `aaa` (completed) sorts first within the tie and is terminal, so it + // settles and absorbs the later `zzz`. What matters is that the tie order + // is stable, not which state happens to win it. + assert.equal(forward[0].requestStatus.outcome.state, "completed"); + assert.equal(forward[0].requestStatus.latestObservation, "errored"); + + const reversed = formatTimelineMessages( + [requestForAgent(), aaa, zzz], + null, + undefined, + null, + ); + assert.deepEqual( + reversed[0].requestStatus, + forward[0].requestStatus, + "result must not depend on input array order", + ); +}); + +test("completed + refused on one request is disputed, never settled", () => { + const completed = dispositionEvent(HEX64_A, "completed", { + id: "c".repeat(64), + created_at: 1_700_000_001, + }); + const refused = dispositionEvent(HEX64_A, "refused", { + id: "d".repeat(64), + created_at: 1_700_000_002, + reason: "changed course", + }); + const [message] = formatTimelineMessages( + [requestForAgent(), completed, refused], + null, + undefined, + null, + ); + assert.deepEqual(message.requestStatus.outcome, { kind: "disputed" }); + assert.equal( + message.requestStatus.resolved, + false, + "a dispute is not a resolution", + ); + assert.deepEqual( + message.requestStatus.warnings, + [], + "two DIFFERENT terminals are a contradiction, not a duplicate", + ); +}); + +test("a message with no disposition leaves the field undefined", () => { + const [message] = formatTimelineMessages( + [streamMessage()], + null, + undefined, + null, + ); + assert.equal(message.requestStatus, undefined); +}); + +test("kind:44300 never renders as its own timeline row", () => { + const events = [ + requestForAgent(), + dispositionEvent(HEX64_A, "completed", { reason: "" }), + ]; + const out = formatTimelineMessages(events, null, undefined, null); + assert.equal(out.length, 1, "only the request message should render"); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index ab35ecfcc41..d559bebb2ca 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -6,6 +6,7 @@ import type { } from "@/shared/api/types"; import type { + TimelineRequestStatus, TimelineMessage, TimelineReaction, } from "@/features/messages/types"; @@ -20,6 +21,12 @@ import { } from "@/features/profile/lib/identity"; import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; import { + classifyRequest, + deriveObligation, + isResolved, +} from "@/shared/lib/disposition"; +import { + KIND_AGENT_DISPOSITION, KIND_JOB_ACCEPTED, KIND_JOB_CANCEL, KIND_JOB_ERROR, @@ -398,6 +405,84 @@ export function formatTimelineMessages( reactionsByEventId.set(targetId, current); } + // NIP-AD dispositions overlay the request message the same way reactions + // do: `#e`-referenced, not their own row. + // + // All verification and lifecycle logic lives in the shared verifier + // (`@/shared/lib/disposition`), which is pinned to the same conformance + // corpora as the Rust implementation. This file used to derive state itself + // and drifted from buzz-cli's independent copy — different candidate sets, + // different binding rules — which is precisely what the shared module + // exists to prevent. Do not reimplement any of it here; add cases to the + // corpus instead. + // + // A status is attached to EVERY marked request, valid or not. An earlier + // version skipped invalid ones on the reasoning that a client fault is not + // an agent outcome — correct as far as it went, but it left them with no + // status at all, and the summary chip then counted "marked, no disposition" + // as unanswered. The reasoning was right and the result was the exact + // inversion it was trying to avoid. Carrying the classification forward is + // what actually keeps the two consistent. + const statusByRequestId = new Map(); + { + // Grouped by `e` once, not re-scanned per request. Any channel writer + // can store structurally valid but unbound dispositions, so passing the + // whole array to every request made this O(requests x dispositions) over + // public input — the amplification NIP-AD's own security section forbids. + const dispositionsByRequest = new Map(); + for (const event of events) { + if (event.kind !== KIND_AGENT_DISPOSITION) { + continue; + } + const target = event.tags.find( + (t) => t[0] === "e" && typeof t[1] === "string", + )?.[1]; + if (target == null) { + continue; + } + const list = dispositionsByRequest.get(target); + if (list) { + list.push(event); + } else { + dispositionsByRequest.set(target, [event]); + } + } + for (const request of eventsById.values()) { + const classified = classifyRequest(request); + switch (classified.kind) { + case "not_request": + break; + case "invalid": + statusByRequestId.set(request.id, { + kind: "invalid", + reason: classified.reason, + }); + break; + case "unsupported": + statusByRequestId.set(request.id, { + kind: "unsupported", + reason: classified.reason, + }); + break; + case "valid": { + const derived = deriveObligation( + classified.obligation, + dispositionsByRequest.get(classified.obligation.requestId) ?? [], + ); + statusByRequestId.set(classified.obligation.requestId, { + kind: "valid", + outcome: derived.outcome, + latestObservation: derived.latestObservation, + reason: derived.reason, + warnings: derived.warnings, + resolved: isResolved(derived), + }); + break; + } + } + } + } + const authorPubkeyByEventId = new Map(); const authorLabelByEventId = new Map(); const depthByEventId = new Map(); @@ -540,6 +625,7 @@ export function formatTimelineMessages( ) .map(({ earliestCreatedAt: _drop, ...pill }) => pill); })(), + requestStatus: statusByRequestId.get(event.id), }; }); } diff --git a/desktop/src/features/messages/lib/messageRowEquality.test.mjs b/desktop/src/features/messages/lib/messageRowEquality.test.mjs index df3275646aa..150e52a82db 100644 --- a/desktop/src/features/messages/lib/messageRowEquality.test.mjs +++ b/desktop/src/features/messages/lib/messageRowEquality.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { depthGuideActionsEqual, + requestStatusEqual, numberArrayEqual, reactionsEqual, tagsEqual, @@ -112,3 +113,81 @@ test("depthGuideActionsEqual: same values (message by id) → equal", () => { false, ); }); + +test("requestStatusEqual: fresh identity, same values → equal", () => { + const make = () => ({ + kind: "valid", + outcome: { kind: "settled", state: "refused" }, + latestObservation: "refused", + reason: "outside my delegation", + warnings: [], + resolved: true, + }); + assert.equal(requestStatusEqual(make(), make()), true); +}); + +test("requestStatusEqual: changed field → not equal", () => { + const base = { + kind: "valid", + outcome: { kind: "settled", state: "completed" }, + latestObservation: "completed", + reason: "", + warnings: [], + resolved: true, + }; + assert.equal( + requestStatusEqual(base, { + ...base, + outcome: { kind: "settled", state: "refused" }, + }), + false, + ); + assert.equal(requestStatusEqual(base, { ...base, reason: "x" }), false); + assert.equal( + requestStatusEqual(base, { ...base, warnings: ["duplicate_terminal"] }), + false, + ); + assert.equal( + requestStatusEqual(base, { ...base, latestObservation: "errored" }), + false, + "the raw latest observation is part of what the row renders", + ); + // Same outcome kind, different state: the comparator must look past `kind`. + assert.equal( + requestStatusEqual( + { ...base, outcome: { kind: "open", state: "responded" } }, + { ...base, outcome: { kind: "open", state: "errored" } }, + ), + false, + ); +}); + +test("requestStatusEqual: classification kinds are compared", () => { + const invalid = { kind: "invalid", reason: "missing_agent_target" }; + assert.equal(requestStatusEqual(invalid, { ...invalid }), true); + assert.equal( + requestStatusEqual(invalid, { kind: "invalid", reason: "missing_channel" }), + false, + ); + assert.equal( + requestStatusEqual(invalid, { + kind: "unsupported", + reason: "multiple_agent_targets", + }), + false, + ); +}); + +test("requestStatusEqual: undefined handling", () => { + assert.equal(requestStatusEqual(undefined, undefined), true); + const base = { + kind: "valid", + outcome: { kind: "settled", state: "completed" }, + latestObservation: "completed", + reason: "", + warnings: [], + resolved: true, + }; + assert.equal(requestStatusEqual(base, undefined), false); + assert.equal(requestStatusEqual(undefined, base), false); +}); diff --git a/desktop/src/features/messages/lib/messageRowEquality.ts b/desktop/src/features/messages/lib/messageRowEquality.ts index def5d62e064..8ec4f817d66 100644 --- a/desktop/src/features/messages/lib/messageRowEquality.ts +++ b/desktop/src/features/messages/lib/messageRowEquality.ts @@ -69,6 +69,36 @@ export function reactionsEqual( return true; } +export function requestStatusEqual( + a: TimelineMessage["requestStatus"], + b: TimelineMessage["requestStatus"], +): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.kind !== b.kind) return false; + if (a.kind === "invalid" || a.kind === "unsupported") { + // Narrowing `a` does not narrow `b`; the kind check above guarantees they + // match, and both carry `reason` in these arms. + return a.reason === (b as typeof a).reason; + } + const other = b as typeof a; + return ( + a.outcome.kind === other.outcome.kind && + // `state` is absent on unanswered/disputed outcomes; reading it as an + // optional field covers all four shapes without a second switch. + (a.outcome as { state?: string }).state === + (other.outcome as { state?: string }).state && + a.latestObservation === other.latestObservation && + a.reason === other.reason && + a.resolved === other.resolved && + // Compared by value, not identity: the warning array is rebuilt fresh on + // every format pass, so an identity check would defeat the memo for every + // row carrying a request status. + a.warnings.length === other.warnings.length && + a.warnings.every((warning, i) => warning === other.warnings[i]) + ); +} + export function numberArrayEqual( a: readonly number[] | undefined, b: readonly number[] | undefined, diff --git a/desktop/src/features/messages/lib/renderScopedDispositions.test.mjs b/desktop/src/features/messages/lib/renderScopedDispositions.test.mjs new file mode 100644 index 00000000000..9563a424f89 --- /dev/null +++ b/desktop/src/features/messages/lib/renderScopedDispositions.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + claimUnhydratedRenderScopedDispositionIds, + hydrateRenderScopedDispositions, + releaseRenderScopedDispositionIds, + resetRenderScopedDispositionHydration, +} from "./renderScopedDispositions.ts"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + +function hex(char) { + return char.repeat(64); +} + +function event(id, kind, overrides = {}) { + return { + id, + pubkey: hex("a"), + kind, + created_at: 1_700_000_000, + content: "", + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +function makeQueryClientStub(initialEvents = []) { + const store = new Map([ + [JSON.stringify(channelMessagesKey(CHANNEL_ID)), initialEvents], + ]); + return { + getQueryData(key) { + return store.get(JSON.stringify(key)); + }, + setQueryData(key, updater) { + const k = JSON.stringify(key); + const next = + typeof updater === "function" ? updater(store.get(k) ?? []) : updater; + store.set(k, next); + return next; + }, + }; +} + +test.afterEach(() => { + resetRenderScopedDispositionHydration(); +}); + +test("claims each rendered message id once per channel and can retry released ids", () => { + assert.deepEqual( + claimUnhydratedRenderScopedDispositionIds(CHANNEL_ID, [ + hex("1"), + hex("2"), + hex("1"), + ]), + [hex("1"), hex("2")], + ); + assert.deepEqual( + claimUnhydratedRenderScopedDispositionIds(CHANNEL_ID, [hex("1"), hex("2")]), + [], + ); + assert.deepEqual( + claimUnhydratedRenderScopedDispositionIds("other-channel", [hex("1")]), + [hex("1")], + ); + + releaseRenderScopedDispositionIds(CHANNEL_ID, [hex("2")]); + assert.deepEqual( + claimUnhydratedRenderScopedDispositionIds(CHANNEL_ID, [hex("1"), hex("2")]), + [hex("2")], + ); +}); + +test("hydrates visible dispositions into the channel timeline cache", async () => { + const messageId = hex("1"); + const dispositionId = hex("2"); + const message = event(messageId, 9, { + pubkey: hex("a"), + content: "@agent do the thing", + // Marked as a request targeting hex("b") — the disposition below is + // signed by hex("b"), and the binding check requires the request to + // name its signer as an `agent` target (see formatTimelineMessages.ts). + tags: [ + ["h", CHANNEL_ID], + ["t", "request"], + ["agent", hex("b")], + ["p", hex("b")], + ], + }); + const disposition = event(dispositionId, 44300, { + pubkey: hex("b"), + content: JSON.stringify({ disposition: "completed", reason: "" }), + tags: [ + ["h", CHANNEL_ID], + ["e", messageId], + ["p", hex("a")], + ["disposition", "completed"], + ], + }); + const queryClient = makeQueryClientStub([message]); + const calls = []; + + await hydrateRenderScopedDispositions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchDispositionEventsForMessages: async (channelId, messageIds) => { + calls.push({ channelId, messageIds }); + return [disposition]; + }, + }, + }); + + assert.deepEqual(calls, [{ channelId: CHANNEL_ID, messageIds: [messageId] }]); + const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID)); + assert.ok(cached.some((e) => e.id === dispositionId)); + + const timeline = formatTimelineMessages(cached, null, undefined, null); + assert.deepEqual(timeline.find((m) => m.id === messageId)?.requestStatus, { + kind: "valid", + outcome: { kind: "settled", state: "completed" }, + latestObservation: "completed", + reason: "", + warnings: [], + resolved: true, + }); +}); + +test("failed hydration releases ids so the next render can retry", async () => { + const messageId = hex("1"); + const queryClient = makeQueryClientStub([event(messageId, 9)]); + + await hydrateRenderScopedDispositions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchDispositionEventsForMessages: async () => { + throw new Error("relay timeout"); + }, + }, + }); + + assert.deepEqual( + claimUnhydratedRenderScopedDispositionIds(CHANNEL_ID, [messageId]), + [messageId], + ); +}); diff --git a/desktop/src/features/messages/lib/renderScopedDispositions.ts b/desktop/src/features/messages/lib/renderScopedDispositions.ts new file mode 100644 index 00000000000..493561b18b7 --- /dev/null +++ b/desktop/src/features/messages/lib/renderScopedDispositions.ts @@ -0,0 +1,109 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { channelMessagesKey, sortMessages } from "./messageQueryKeys"; +import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelDispositionAuxFilter } from "@/shared/api/relayChannelFilters"; +import type { RelayEvent } from "@/shared/api/types"; + +export type RenderScopedDispositionDeps = { + fetchDispositionEventsForMessages: ( + channelId: string, + messageIds: string[], + ) => Promise; +}; + +const defaultDeps: RenderScopedDispositionDeps = { + fetchDispositionEventsForMessages: (channelId, messageIds) => + relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelDispositionAuxFilter, + ), +}; + +const hydratedMessageIdsByChannel = new Map>(); + +export function resetRenderScopedDispositionHydration() { + hydratedMessageIdsByChannel.clear(); +} + +function hydratedSetForChannel(channelId: string): Set { + let hydrated = hydratedMessageIdsByChannel.get(channelId); + if (!hydrated) { + hydrated = new Set(); + hydratedMessageIdsByChannel.set(channelId, hydrated); + } + return hydrated; +} + +export function claimUnhydratedRenderScopedDispositionIds( + channelId: string, + messageIds: readonly string[], +): string[] { + const hydrated = hydratedSetForChannel(channelId); + const claimed: string[] = []; + + for (const id of messageIds) { + if (hydrated.has(id)) { + continue; + } + hydrated.add(id); + claimed.push(id); + } + + return claimed; +} + +export function releaseRenderScopedDispositionIds( + channelId: string, + messageIds: readonly string[], +) { + const hydrated = hydratedMessageIdsByChannel.get(channelId); + if (!hydrated) { + return; + } + + for (const id of messageIds) { + hydrated.delete(id); + } + + if (hydrated.size === 0) { + hydratedMessageIdsByChannel.delete(channelId); + } +} + +export async function hydrateRenderScopedDispositions(input: { + channelId: string; + messageIds: readonly string[]; + queryClient: QueryClient; + deps?: RenderScopedDispositionDeps; +}): Promise { + const messageIds = claimUnhydratedRenderScopedDispositionIds( + input.channelId, + input.messageIds, + ); + if (messageIds.length === 0) { + return; + } + + try { + const dispositionEvents = await ( + input.deps ?? defaultDeps + ).fetchDispositionEventsForMessages(input.channelId, messageIds); + if (dispositionEvents.length === 0) { + return; + } + + input.queryClient.setQueryData( + channelMessagesKey(input.channelId), + (current = []) => sortMessages([...current, ...dispositionEvents]), + ); + } catch (error) { + releaseRenderScopedDispositionIds(input.channelId, messageIds); + console.error( + "Failed to hydrate visible dispositions for channel", + input.channelId, + error, + ); + } +} diff --git a/desktop/src/features/messages/lib/requestMarking.test.mjs b/desktop/src/features/messages/lib/requestMarking.test.mjs new file mode 100644 index 00000000000..c1657b78c53 --- /dev/null +++ b/desktop/src/features/messages/lib/requestMarking.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decideRequestMarking, + requestAgentPubkeysFor, +} from "./requestMarking.ts"; + +const AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); + +test("no agent mentioned is an ordinary message", () => { + const marking = decideRequestMarking([], false); + assert.deepEqual(marking, { kind: "none" }); + assert.deepEqual(requestAgentPubkeysFor(marking), []); +}); + +test("exactly one agent creates a tracked obligation", () => { + const marking = decideRequestMarking([AGENT], false); + assert.deepEqual(marking, { kind: "tracked", targetPubkey: AGENT }); + assert.deepEqual(requestAgentPubkeysFor(marking), [AGENT]); +}); + +test("the same agent mentioned twice is still one target", () => { + // Two mentions of one agent is one obligation. Emitting the tag twice + // would be a duplicate target, which the shared verifier rejects as + // non-canonical — so the composer must never produce that shape. + const marking = decideRequestMarking([AGENT, AGENT], false); + assert.deepEqual(marking, { kind: "tracked", targetPubkey: AGENT }); + assert.deepEqual(requestAgentPubkeysFor(marking), [AGENT]); +}); + +test("two agents are reported as multi-agent, never silently marked", () => { + // v1 cannot say whether either agent may answer or both must. The + // composer's job here is to say so, not to quietly send an untracked + // message and let the sender find out later that nothing was recorded. + const marking = decideRequestMarking([AGENT, OTHER_AGENT], false); + assert.deepEqual(marking, { + kind: "multi-agent", + targetPubkeys: [AGENT, OTHER_AGENT], + }); + assert.deepEqual( + requestAgentPubkeysFor(marking), + [], + "a multi-target request would be classified unsupported and could never be discharged", + ); +}); + +test("opting out suppresses tracking for a single agent", () => { + const marking = decideRequestMarking([AGENT], true); + assert.deepEqual(marking, { kind: "opted-out" }); + assert.deepEqual(requestAgentPubkeysFor(marking), []); +}); + +test("opting out with no agent mentioned is still just an ordinary message", () => { + // The opt-out is about suppressing an obligation that would otherwise + // exist; with no agent there is nothing to suppress, and reporting + // "opted-out" would make the banner claim a choice the sender never faced. + assert.deepEqual(decideRequestMarking([], true), { kind: "none" }); +}); + +test("the marking decision never yields more than one agent tag", () => { + // The invariant the whole v1 contract rests on, asserted directly rather + // than inferred from the cases above. + for (const mentioned of [ + [], + [AGENT], + [AGENT, AGENT], + [AGENT, OTHER_AGENT], + [AGENT, OTHER_AGENT, AGENT], + ]) { + for (const optedOut of [false, true]) { + assert.ok( + requestAgentPubkeysFor(decideRequestMarking(mentioned, optedOut)) + .length <= 1, + `${mentioned.length} mentions, optedOut=${optedOut}`, + ); + } + } +}); diff --git a/desktop/src/features/messages/lib/requestMarking.ts b/desktop/src/features/messages/lib/requestMarking.ts new file mode 100644 index 00000000000..d9620fb6701 --- /dev/null +++ b/desktop/src/features/messages/lib/requestMarking.ts @@ -0,0 +1,66 @@ +/** + * Decides whether an outgoing message creates a tracked NIP-AD obligation. + * + * Split out of the send flow so the composer's banner and the send path read + * the same answer. They used to be separate: the send path silently derived a + * target from the mention list, and nothing in the UI said so. The two + * failures that came from that — an obligation created by "@agent thanks", + * and a request silently untracked because two agents were mentioned — were + * both invisible to the sender at the moment they could still be fixed. + * + * See docs/nips/NIP-AD.md "Request validity". + */ +export type RequestMarking = + | { + /** No agent mentioned: an ordinary message. */ + kind: "none"; + } + | { + /** Exactly one agent mentioned; the message will be marked. */ + kind: "tracked"; + targetPubkey: string; + } + | { + /** + * More than one agent mentioned. v1 tracks exactly one obligation per + * request and will not guess, so the message goes out unmarked — which + * the composer must say out loud rather than leave to be discovered. + */ + kind: "multi-agent"; + targetPubkeys: string[]; + } + | { + /** The sender explicitly opted out of tracking. */ + kind: "opted-out"; + }; + +/** + * `mentionedAgentPubkeys` must already be filtered to agents — a merely + * `p`-mentioned human must never become a target, or any of them could close + * the agent's obligation. + */ +export function decideRequestMarking( + mentionedAgentPubkeys: readonly string[], + optedOut: boolean, +): RequestMarking { + if (mentionedAgentPubkeys.length === 0) { + return { kind: "none" }; + } + if (optedOut) { + return { kind: "opted-out" }; + } + const unique = [...new Set(mentionedAgentPubkeys)]; + if (unique.length > 1) { + return { kind: "multi-agent", targetPubkeys: unique }; + } + return { kind: "tracked", targetPubkey: unique[0] }; +} + +/** + * The `agent` tag values to attach. Exactly one, or none — never several: the + * shared verifier classifies a multi-target request as unsupported, so + * emitting one would create a request nobody could ever discharge. + */ +export function requestAgentPubkeysFor(marking: RequestMarking): string[] { + return marking.kind === "tracked" ? [marking.targetPubkey] : []; +} diff --git a/desktop/src/features/messages/lib/useAutocompleteInserts.ts b/desktop/src/features/messages/lib/useAutocompleteInserts.ts new file mode 100644 index 00000000000..43ef8691fb2 --- /dev/null +++ b/desktop/src/features/messages/lib/useAutocompleteInserts.ts @@ -0,0 +1,88 @@ +import * as React from "react"; + +import type { AutocompleteEdit } from "@/features/messages/lib/useRichTextEditor"; +import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; +import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; + +/** + * The three autocomplete inserters (mention, channel link, custom emoji). + * + * They are identical in shape — read the cursor, ask the relevant + * autocomplete for an edit, apply it — and differ only in which autocomplete + * they consult. Extracted from `MessageComposer` as a sibling so the composer + * stays under the file-size guard, matching the pattern the other composer + * siblings already follow. + */ +export function useAutocompleteInserts(input: { + replacePlainTextRange: ( + from: number, + to: number, + text: string, + customEmojiShortcode?: string, + ) => void; + getPlainTextAndCursor: () => { cursor: number }; + insertMention: ( + suggestion: MentionSuggestion, + cursor: number, + ) => AutocompleteEdit; + insertChannel: ( + suggestion: ChannelSuggestion, + cursor: number, + ) => AutocompleteEdit; + insertEmoji: ( + suggestion: EmojiSuggestion, + cursor: number, + ) => AutocompleteEdit; +}) { + const { + replacePlainTextRange, + getPlainTextAndCursor, + insertMention, + insertChannel, + insertEmoji, + } = input; + + const applyAutocompleteEdit = React.useCallback( + (edit: AutocompleteEdit) => { + replacePlainTextRange( + edit.replaceFromOffset, + edit.replaceToOffset, + edit.insertText, + edit.customEmojiShortcode, + ); + }, + [replacePlainTextRange], + ); + + const applyMentionInsert = React.useCallback( + (suggestion: MentionSuggestion) => { + const { cursor } = getPlainTextAndCursor(); + applyAutocompleteEdit(insertMention(suggestion, cursor)); + }, + [applyAutocompleteEdit, insertMention, getPlainTextAndCursor], + ); + + const applyChannelInsert = React.useCallback( + (suggestion: ChannelSuggestion) => { + const { cursor } = getPlainTextAndCursor(); + applyAutocompleteEdit(insertChannel(suggestion, cursor)); + }, + [applyAutocompleteEdit, insertChannel, getPlainTextAndCursor], + ); + + const applyEmojiInsert = React.useCallback( + (suggestion: EmojiSuggestion) => { + const { cursor } = getPlainTextAndCursor(); + applyAutocompleteEdit(insertEmoji(suggestion, cursor)); + }, + [applyAutocompleteEdit, insertEmoji, getPlainTextAndCursor], + ); + + return { + applyAutocompleteEdit, + applyMentionInsert, + applyChannelInsert, + applyEmojiInsert, + }; +} diff --git a/desktop/src/features/messages/lib/useComposerRequestMarking.ts b/desktop/src/features/messages/lib/useComposerRequestMarking.ts new file mode 100644 index 00000000000..43202e992af --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerRequestMarking.ts @@ -0,0 +1,63 @@ +import * as React from "react"; + +import { + decideRequestMarking, + type RequestMarking, +} from "@/features/messages/lib/requestMarking"; + +/** + * Tracks whether the current draft will create a NIP-AD obligation, so the + * composer can disclose it before the message goes out. + * + * The decision itself lives in `decideRequestMarking`, shared with the send + * path. They were separate before — the send path derived a target from the + * mention list and nothing in the UI said so — which is how "@agent thanks" + * silently became an unanswered obligation, and how a two-agent request + * silently became untracked chat. + */ +export function useComposerRequestMarking( + isAgentPubkey: (pubkey: string) => boolean, + getDisplayName: (pubkey: string) => string | null, +) { + const [optedOut, setOptedOut] = React.useState(false); + const [mentionedAgentPubkeys, setMentionedAgentPubkeys] = React.useState< + string[] + >([]); + + // A draft with no agent mention opts back in: the choice was about the + // message that had one. + React.useEffect(() => { + if (mentionedAgentPubkeys.length === 0 && optedOut) { + setOptedOut(false); + } + }, [mentionedAgentPubkeys.length, optedOut]); + + const syncFromText = React.useCallback( + (pubkeys: string[]) => { + setMentionedAgentPubkeys(pubkeys.filter(isAgentPubkey)); + }, + [isAgentPubkey], + ); + + const marking: RequestMarking = decideRequestMarking( + mentionedAgentPubkeys, + optedOut, + ); + + const disableRequest = React.useCallback(() => setOptedOut(true), []); + + return { + marking, + optedOut, + syncFromText, + /** Spread straight into `ComposerRequestBanner`. */ + bannerProps: { + multiAgent: marking.kind === "multi-agent", + targetLabel: + marking.kind === "tracked" + ? getDisplayName(marking.targetPubkey) + : null, + onDisableRequest: disableRequest, + }, + }; +} diff --git a/desktop/src/features/messages/lib/useRenderScopedDispositionHydration.ts b/desktop/src/features/messages/lib/useRenderScopedDispositionHydration.ts new file mode 100644 index 00000000000..e27eddb6aa0 --- /dev/null +++ b/desktop/src/features/messages/lib/useRenderScopedDispositionHydration.ts @@ -0,0 +1,54 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { collectRenderScopedReactionMessageIds } from "./renderScopedReactions"; +import { hydrateRenderScopedDispositions } from "./renderScopedDispositions"; +import type { MainTimelineEntry } from "./threadPanel"; +import type { TimelineMessage } from "../types"; +import type { Channel } from "@/shared/api/types"; + +/** + * Mirrors {@link useRenderScopedReactionHydration} exactly — same visible-id + * collection (reused as-is; it's kind-agnostic despite the reaction-specific + * name), same debounce shape — but fetches NIP-AD dispositions instead of + * reactions. Kept as a separate hook/effect rather than folded into the + * reaction one so a disposition-hydration bug can never regress reactions. + */ +export function useRenderScopedDispositionHydration(input: { + activeChannel: Channel | null; + mainTimelineEntries: MainTimelineEntry[]; + threadHeadMessage: TimelineMessage | null; + threadMessages: MainTimelineEntry[]; +}) { + const queryClient = useQueryClient(); + + React.useEffect(() => { + const channelId = input.activeChannel?.id; + if (!channelId || input.activeChannel?.channelType === "forum") { + return; + } + + const messageIds = collectRenderScopedReactionMessageIds({ + mainEntries: input.mainTimelineEntries, + threadHeadMessage: input.threadHeadMessage, + threadEntries: input.threadMessages, + }); + if (messageIds.length === 0) return; + + const timeout = window.setTimeout(() => { + void hydrateRenderScopedDispositions({ + channelId, + messageIds, + queryClient, + }); + }, 0); + + return () => window.clearTimeout(timeout); + }, [ + input.activeChannel, + input.mainTimelineEntries, + input.threadHeadMessage, + input.threadMessages, + queryClient, + ]); +} diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index ec656f22366..eba77794f2f 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -1,3 +1,11 @@ +import type { + DispositionState, + EffectiveOutcome, + HistoryWarning, + InvalidRequestReason, + UnsupportedRequestReason, +} from "@/shared/lib/disposition"; + export type TimelineReaction = { emoji: string; /** Custom (image) emoji URL from the reaction's NIP-30 `emoji` tag, if any. */ @@ -11,6 +19,49 @@ export type TimelineReaction = { }>; }; +/** + * A marked request's NIP-AD status, straight from the shared verifier + * (`@/shared/lib/disposition`). See docs/nips/NIP-AD.md. + * + * Attached to **every** marked request, including the ones that are not valid + * obligations. An earlier version attached nothing to those, so they rendered + * no badge — and the summary chip, counting marked messages without a + * disposition, reported them as unanswered. That inverted the NIP's own rule + * that a client fault must never be filed as an agent's gap, and it was + * reachable by any channel member publishing a malformed marked event. + * + * `resolved` is deliberately not `state === "completed"`: an obligation is + * settled only when a terminal claim bound to it. `responded` and `errored` + * are non-terminal — the agent answered, or the turn failed, but nothing + * asserted the work was done. + */ +export type TimelineRequestStatus = + | { + kind: "invalid"; + /** A malformed marked event: a client bug, never an agent failure. */ + reason: InvalidRequestReason; + } + | { + kind: "unsupported"; + /** Well-formed, but an intent v1 cannot represent. Nobody's fault. */ + reason: UnsupportedRequestReason; + } + | { + kind: "valid"; + /** Terminal-absorbing outcome. The only basis for a display decision. */ + outcome: EffectiveOutcome; + /** + * What arrived last, regardless of absorption. Kept for audit; never + * use it to decide whether an obligation is done. + */ + latestObservation: DispositionState | null; + reason: string; + /** Diagnostics that do NOT change the outcome — see `HistoryWarning`. */ + warnings: HistoryWarning[]; + /** Settled: the only "done" condition. */ + resolved: boolean; + }; + export type TimelineMessage = { id: string; /** Stable local key used to avoid remounting optimistic rows on send ack. */ @@ -49,4 +100,6 @@ export type TimelineMessage = { kind?: number; tags?: string[][]; reactions?: TimelineReaction[]; + /** Present on every message marked ["t","request"]. See TimelineRequestStatus. */ + requestStatus?: TimelineRequestStatus; }; diff --git a/desktop/src/features/messages/ui/ComposerRequestBanner.tsx b/desktop/src/features/messages/ui/ComposerRequestBanner.tsx new file mode 100644 index 00000000000..09f16eadf71 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerRequestBanner.tsx @@ -0,0 +1,77 @@ +import { CircleSlash, ScrollText, X } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +const BANNER_CLASS = + "relative z-0 -mb-4 flex transform-gpu items-center gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"; + +/** + * Discloses that this message will create a tracked NIP-AD obligation, and + * lets the sender opt out. + * + * **Why this exists.** Mentioning an agent used to mark the message as a + * request invisibly, so "@agent thanks for the help" — which asks for nothing + * — became an obligation that showed as an unanswered gap until the agent + * happened to speak again. And mentioning *two* agents silently dropped the + * marker, turning a request the user clearly intended into untracked chat + * with no signal that accountability had been switched off. + * + * Both were the same mistake: the composer decided something consequential + * about the outbound event and told nobody. A request is a claim on someone + * else's time that a third party can later audit, so the sender should be + * able to see it before it goes out — and, in the common case, do nothing. + */ +export function ComposerRequestBanner({ + targetLabel, + multiAgent, + onDisableRequest, +}: { + /** Display name of the single agent this request is addressed to. */ + targetLabel: string | null; + /** + * True when more than one agent is mentioned. v1 tracks exactly one + * obligation per request, so nothing is tracked — said out loud rather + * than left for the reader to notice. + */ + multiAgent: boolean; + /** Omitted when the sender has already opted out. */ + onDisableRequest?: () => void; +}) { + if (multiAgent) { + return ( +
+ +

+ Mentions more than one agent, so this won't be tracked as a request. + Address one agent to make it accountable. +

+
+ ); + } + + if (!targetLabel) { + return null; + } + + return ( +
+ +

+ Tracked as a request to{" "} + {targetLabel} +

+ {onDisableRequest ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/DispositionBadge.tsx b/desktop/src/features/messages/ui/DispositionBadge.tsx new file mode 100644 index 00000000000..7572c76a3a8 --- /dev/null +++ b/desktop/src/features/messages/ui/DispositionBadge.tsx @@ -0,0 +1,230 @@ +import { + AlertTriangle, + CheckCircle2, + CircleSlash, + MessageSquare, + Scale, + ScanLine, + XCircle, +} from "lucide-react"; + +import { Badge } from "@/shared/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import type { TimelineRequestStatus } from "@/features/messages/types"; +import type { + DispositionState, + HistoryWarning, + InvalidRequestReason, + UnsupportedRequestReason, +} from "@/shared/lib/disposition"; + +const STATE_META: Record< + DispositionState, + { + label: string; + variant: "success" | "warning" | "destructive" | "outline"; + Icon: typeof CheckCircle2; + } +> = { + completed: { + label: "Completed", + variant: "success", + Icon: CheckCircle2, + }, + refused: { + label: "Refused", + variant: "warning", + Icon: XCircle, + }, + // Deliberately neutral, not a success variant: the agent answered, but + // nothing asserted the work was done. Styling it green would restore the + // exact overclaim `responded` exists to remove. + responded: { + label: "Responded", + variant: "outline", + Icon: MessageSquare, + }, + errored: { + label: "Errored", + variant: "destructive", + Icon: AlertTriangle, + }, +}; + +/** + * Warnings are diagnostic and do NOT change the outcome. An earlier version + * forced every one of them into the destructive variant and appended + * "· disputed", so a duplicate delivery looked identical to a genuine + * contradiction — which defeated the point of categorizing them at all. + */ +const WARNING_TEXT: Record = { + duplicate_terminal: + "This request was finalized more than once with the same outcome. Redundant, not contradictory — the outcome stands.", + ordered_after_terminal: + "A later disposition sorts after this request was settled. It does not reopen the outcome; the settled result stands.", +}; + +const INVALID_TEXT: Record = { + missing_agent_target: + "This message is marked as a request but names no agent, so nothing was asked of anyone.", + malformed_agent_target: + "This request's agent target is not a valid public key.", + missing_channel: "This request carries no channel, so it has no scope.", + multiple_channels: + "This request names more than one channel, so its scope is ambiguous.", + target_not_mentioned: + "This request names an agent but never mentions it, so the request was never actually delivered to it.", + duplicate_agent_target: + "This request repeats the same agent target, which is not a well-formed request.", + unsupported_kind: + "This message kind cannot carry a request marker in this version.", +}; + +const UNSUPPORTED_TEXT: Record = { + multiple_agent_targets: + "This request addresses more than one agent. This version cannot say whether either agent may answer or both must, so it does not track an outcome for it.", +}; + +/** + * NIP-AD status badge for a marked REQUEST message (not the agent's reply — + * matches the protocol's `e`-tag data model, and is robust when a reply + * doesn't thread back to a specific request). + * + * Reads `TimelineMessage.requestStatus`, which comes straight from the shared + * verifier — including the target-agent binding that keeps a merely + * `p`-mentioned human from closing an agent's obligation. No fetch, no local + * state, and no verification logic of its own. See docs/nips/NIP-AD.md. + * + * Invalid and unsupported requests render their own badge rather than nothing. + * Rendering nothing made them invisible to the reader while the summary chip + * still counted them, so the one surface that could have explained the + * problem stayed silent about it. + */ +export function DispositionBadge({ + status, +}: { + status: TimelineRequestStatus | undefined; +}) { + if (!status) { + return null; + } + + if (status.kind === "invalid" || status.kind === "unsupported") { + const isInvalid = status.kind === "invalid"; + return ( + + + + {isInvalid ? ( + + + +

+ {isInvalid + ? INVALID_TEXT[status.reason] + : UNSUPPORTED_TEXT[status.reason]} +

+

+ {isInvalid + ? "This is a problem with how the request was sent, not with any agent." + : "No agent is accountable for this request in this version."} +

+
+
+ ); + } + + // A valid obligation with nothing bound is a real gap, but the badge is + // about what happened — an absence is shown by the summary, not by a badge + // on every unanswered request. + if (status.outcome.kind === "unanswered") { + return null; + } + + // A dispute gets its own presentation and never borrows a state label. + // + // An earlier version fell back to `latestObservation` for the icon and + // text, so `completed → refused → responded` rendered as + // "Responded · disputed" — naming the one state that is NOT part of the + // contradiction, and taking its reason from a stray later event rather than + // from either terminal claim. The dispute is between `completed` and + // `refused`; saying anything else misdescribes it. + if (status.outcome.kind === "disputed") { + return ( + + + + + + +

+ This request carries both a completed and a{" "} + refused disposition — two contradictory outcomes. + There is no settled result. +

+ {status.latestObservation ? ( +

+ Most recent record: {STATE_META[status.latestObservation].label}. + Shown for context only — it does not settle the request. +

+ ) : null} + {status.warnings.map((warning) => ( +

+ {WARNING_TEXT[warning]} +

+ ))} +
+
+ ); + } + + const { label, variant, Icon } = STATE_META[status.outcome.state]; + const reason = status.reason.trim(); + + return ( + + + + + + + {reason ? ( +

{reason}

+ ) : ( +

No reason given.

+ )} + {status.outcome.state === "responded" ? ( +

+ The agent responded. Nothing has asserted the request is complete. +

+ ) : null} + {status.warnings.map((warning) => ( +

+ {WARNING_TEXT[warning]} +

+ ))} +
+
+ ); +} diff --git a/desktop/src/features/messages/ui/DispositionSummaryChip.tsx b/desktop/src/features/messages/ui/DispositionSummaryChip.tsx new file mode 100644 index 00000000000..c3d72164d67 --- /dev/null +++ b/desktop/src/features/messages/ui/DispositionSummaryChip.tsx @@ -0,0 +1,125 @@ +import { CheckCircle2, ChevronDown } from "lucide-react"; + +import { + type ChannelDispositionSummary, + hasVisibleRequests, +} from "@/features/messages/lib/dispositionSummary"; +import { Badge } from "@/shared/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; + +/** + * Channel accountability summary — "N requests answered" derived from + * currently-loaded timeline data (see dispositionSummary.ts for the scope + * caveat, surfaced below in the popover too). Expands to a list of + * unanswered requests with jump-to-message. Placement is a visual decision + * left to PR review (see design.md's Open Questions) — this component only + * needs a spot near the timeline. + * + * The green "all resolved" treatment requires more than + * `unanswered.length === 0`: `errored` is non-terminal (NIP-AD.md's + * lifecycle calls it open/repairable, not resolved) and a `conflict` is an + * unresolved contradiction by definition, so both must be zero too. A + * channel with every request answered but one still `errored` or + * conflicted is not "all good" and must not render as if it were. + */ +export function DispositionSummaryChip({ + summary, + onJumpToMessage, +}: { + summary: ChannelDispositionSummary; + onJumpToMessage: (messageId: string) => void; +}) { + if (!hasVisibleRequests(summary)) { + return null; + } + + const needsAttention = summary.needsAttention; + const allSettled = needsAttention === 0; + + // "visible", never "all". This summary derives from the currently loaded + // timeline window, which is not provably the channel's whole history — the + // shared verifier calls that incomplete `Coverage`, and a surface that says + // "all requests" from a partial window makes exactly the channel-wide claim + // it cannot support. The qualifier is in the chip itself, not only in the + // popover, because the chip is what gets read. + // + // There is deliberately no "all answered" state. An earlier version had + // one, computed from `unanswered.length === 0` against a count that + // incremented for any disposition including `errored` — so a channel whose + // every turn failed rendered "All visible requests answered". Settled is + // the only claim worth making, and everything else is "needs attention". + const headerText = allSettled + ? "All visible requests settled" + : `${needsAttention} of ${summary.total} need${needsAttention === 1 ? "s" : ""} attention`; + + return ( + + + + {allSettled ? ( + + + +

{headerText}

+

+ Counts only messages loaded in this view, not the channel's full + history. +

+ {/* Five non-overloaded counts. Each request appears in exactly one. */} +
+
+
Settled
+
{summary.settled}
+
+
+
Responded (not settled)
+
{summary.respondedUnsettled}
+
+
+
Attempt failed
+
{summary.attemptFailed}
+
+
+
No record
+
{summary.noRecord.length}
+
+
+
Disputed
+
{summary.disputed}
+
+ {summary.invalidOrUnsupported > 0 ? ( +
+ {/* Attributed to the sender, never to an agent. */} +
Invalid or not tracked
+
{summary.invalidOrUnsupported}
+
+ ) : null} +
+ {summary.noRecord.length > 0 ? ( +
    + {summary.noRecord.map((request) => ( +
  • + +
  • + ))} +
+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 31a40c86b67..80b1ccc25af 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -3,11 +3,9 @@ import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; -import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; -import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { findSpoileredImetaMediaUrls, @@ -32,7 +30,6 @@ import { } from "@/features/messages/lib/normalizeMentionClipboard"; import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode"; import { - type AutocompleteEdit, type LinkSelectionInfo, useRichTextEditor, } from "@/features/messages/lib/useRichTextEditor"; @@ -43,12 +40,12 @@ import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; +import { ComposerRequestBanner } from "./ComposerRequestBanner"; +import { useComposerRequestMarking } from "@/features/messages/lib/useComposerRequestMarking"; +import { useAutocompleteInserts } from "@/features/messages/lib/useAutocompleteInserts"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { EmojiAutocomplete } from "./EmojiAutocomplete"; -import { - MentionAutocomplete, - type MentionSuggestion, -} from "./MentionAutocomplete"; +import { MentionAutocomplete } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; @@ -205,6 +202,13 @@ function MessageComposerImpl({ const onEditSaveRef = React.useRef(onEditSave); const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); const editTargetRef = React.useRef(editTarget); + // NIP-AD: does this draft create a tracked obligation? Disclosed by the + // banner below and used verbatim by the send path. + const requestMarking = useComposerRequestMarking( + mentions.isAgentPubkey, + mentions.getMentionDisplayName, + ); + const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys); const ownerPubkeyRef = React.useRef(ownerPubkey); disabledRef.current = disabled; @@ -269,6 +273,7 @@ function MessageComposerImpl({ onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); setPreviewContent(linkPreviewContent); + requestMarking.syncFromText(extractMentionPubkeysRef.current(text)); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); @@ -404,50 +409,14 @@ function MessageComposerImpl({ // ── Mention / channel / emoji autocomplete insertion ──────────────── // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). - const applyAutocompleteEdit = React.useCallback( - (edit: AutocompleteEdit) => { - richText.replacePlainTextRange( - edit.replaceFromOffset, - edit.replaceToOffset, - edit.insertText, - edit.customEmojiShortcode, - ); - }, - [richText.replacePlainTextRange], - ); - const applyMentionInsert = React.useCallback( - (suggestion: MentionSuggestion) => { - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - mentions.insertMention, - richText.getPlainTextAndCursor, - ], - ); - const applyChannelInsert = React.useCallback( - (suggestion: ChannelSuggestion) => { - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(channelLinks.insertChannel(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - channelLinks.insertChannel, - richText.getPlainTextAndCursor, - ], - ); - const applyEmojiInsert = React.useCallback( - (suggestion: EmojiSuggestion) => { - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(emojiAutocomplete.insertEmoji(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - emojiAutocomplete.insertEmoji, - richText.getPlainTextAndCursor, - ], - ); + const { applyMentionInsert, applyChannelInsert, applyEmojiInsert } = + useAutocompleteInserts({ + replacePlainTextRange: richText.replacePlainTextRange, + getPlainTextAndCursor: richText.getPlainTextAndCursor, + insertMention: mentions.insertMention, + insertChannel: channelLinks.insertChannel, + insertEmoji: emojiAutocomplete.insertEmoji, + }); // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -601,6 +570,7 @@ function MessageComposerImpl({ recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, + requestTrackingOptedOut: requestMarking.optedOut, audienceGeneration: persistentAudience.generation, audienceRevision: audienceScope ? persistentAudience.revision : null, }); @@ -642,6 +612,7 @@ function MessageComposerImpl({ mentions.getDraftMentionRefs, mentions.restoreDraftMentionRefs, mentions.revalidateMentionPubkeys, + requestMarking.optedOut, ]); submitMessageRef.current = submitMessage; // Draft auto-submit runs once after persisted editor state loads. @@ -866,6 +837,7 @@ function MessageComposerImpl({ onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} /> + {showBackgroundUploadProgress ? ( Promise; placeholder?: string; profiles?: UserProfileLookup; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index fd5be7d9a86..a7bba94cf78 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -3,6 +3,7 @@ import { AlertTriangle } from "lucide-react"; import { depthGuideActionsEqual, + requestStatusEqual, numberArrayEqual, reactionsEqual, tagsEqual, @@ -14,6 +15,7 @@ import { import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; +import { DispositionBadge } from "@/features/messages/ui/DispositionBadge"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -678,6 +680,7 @@ export const MessageRow = React.memo( void handleReactionSelect(emoji); }} /> + {reactionErrorMessage ? (

{reactionErrorMessage} @@ -935,6 +938,10 @@ export const MessageRow = React.memo( // checks made every row re-render on every streamed event in an open // thread (see messageRowEquality.ts). reactionsEqual(prev.message.reactions, next.message.reactions) && + requestStatusEqual( + prev.message.requestStatus, + next.message.requestStatus, + ) && tagsEqual(prev.message.tags, next.message.tags) && prev.message.role === next.message.role && prev.message.personaDisplayName === next.message.personaDisplayName && diff --git a/desktop/src/features/messages/ui/useAgentMentionPreparation.ts b/desktop/src/features/messages/ui/useAgentMentionPreparation.ts new file mode 100644 index 00000000000..8b0a6535e50 --- /dev/null +++ b/desktop/src/features/messages/ui/useAgentMentionPreparation.ts @@ -0,0 +1,250 @@ +import * as React from "react"; +import { + type CreateChannelManagedAgentInput, + type useAttachManagedAgentToChannelMutation, + useAvailableAcpRuntimes, + type useCreateChannelManagedAgentMutation, + useManagedAgentsQuery, + type useProvisionChannelManagedAgentMutation, + type useStartManagedAgentMutation, +} from "@/features/agents/hooks"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + getErrorMessage, + isManagedAgentRunning, + isProviderBackedAgent, + uniqueNormalizedPubkeys, +} from "./useMentionSendFlow.helpers"; + +type UseAgentMentionPreparationOptions = { + channelType: ChannelType | null; + onPrepareSendChannel?: ( + additionalParticipantPubkeys?: string[], + ) => Promise; + mentions: Pick< + UseMentionsResult, + "memberPubkeys" | "extractMentionPersonas" | "registerMentionPubkey" + >; + attachAgentMutation: ReturnType< + typeof useAttachManagedAgentToChannelMutation + >; + startAgentMutation: ReturnType; + createPersonaAgentMutation: ReturnType< + typeof useCreateChannelManagedAgentMutation + >; + provisionPersonaAgentMutation: ReturnType< + typeof useProvisionChannelManagedAgentMutation + >; +}; + +/** + * Resolves mentioned agents -- existing managed agents or fresh persona + * mentions -- into ready, running managed agents before a message send. + * + * Split out of `useMentionSendFlow`: this cluster only ever touches + * agent/persona readiness (fetch the managed-agent map, fetch available + * runtimes, start/attach existing agents, create persona-backed agents on + * the fly). It never touches the send orchestration or non-member-prompt + * state that makes up the rest of that hook, so the mutation/query objects + * it needs are passed in rather than re-subscribed here -- the caller + * already holds them for its own `isPending` reads. + */ +export function useAgentMentionPreparation({ + channelType, + onPrepareSendChannel, + mentions, + attachAgentMutation, + startAgentMutation, + createPersonaAgentMutation, + provisionPersonaAgentMutation, +}: UseAgentMentionPreparationOptions) { + const managedAgentsQuery = useManagedAgentsQuery(); + const availableRuntimesQuery = useAvailableAcpRuntimes(); + + const getManagedAgentsByPubkey = React.useCallback(async () => { + const agents = + managedAgentsQuery.data ?? + (await managedAgentsQuery.refetch()).data ?? + []; + return new Map( + agents.map((agent) => [normalizePubkey(agent.pubkey), agent]), + ); + }, [managedAgentsQuery.data, managedAgentsQuery.refetch]); + + const getAvailableRuntimes = React.useCallback(async (): Promise< + AcpRuntime[] + > => { + const cached = availableRuntimesQuery.data ?? []; + if (cached.length > 0 || !availableRuntimesQuery.isLoading) { + return cached; + } + const refetched = await availableRuntimesQuery.refetch(); + return (refetched.data ?? []).filter( + (runtime): runtime is AcpRuntime => + runtime.availability === "available" && + runtime.command !== null && + runtime.binaryPath !== null, + ); + }, [ + availableRuntimesQuery.data, + availableRuntimesQuery.isLoading, + availableRuntimesQuery.refetch, + ]); + + const ensureManagedAgentMentionsReady = React.useCallback( + async ( + mentionPubkeys: string[], + capturedChannelId: string, + preparedParticipantPubkeys: string[] = [], + preparedManagedAgents: ManagedAgent[] = [], + ) => { + if (!capturedChannelId || mentionPubkeys.length === 0) { + return { + errors: [] as string[], + pubkeys: [] as string[], + }; + } + const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + for (const agent of preparedManagedAgents) { + managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); + } + const participantPubkeys = new Set([ + ...mentions.memberPubkeys, + ...preparedParticipantPubkeys.map(normalizePubkey), + ]); + const errors: string[] = []; + const pubkeys: string[] = []; + for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { + const agent = managedAgentsByPubkey.get(pubkey); + if (!agent) { + continue; + } + try { + if (participantPubkeys.has(pubkey)) { + if (isProviderBackedAgent(agent)) { + if (agent.status !== "deployed") { + await startAgentMutation.mutateAsync(agent.pubkey); + } + } else if (!isManagedAgentRunning(agent)) { + await startAgentMutation.mutateAsync(agent.pubkey); + } + } else { + await attachAgentMutation.mutateAsync({ + channelId: capturedChannelId, + agent, + role: "bot", + }); + } + pubkeys.push(pubkey); + } catch (error) { + errors.push( + `${agent.name}: ${getErrorMessage( + error, + "Could not prepare agent.", + )}`, + ); + } + } + return { + errors, + pubkeys: uniqueNormalizedPubkeys(pubkeys), + }; + }, + [ + attachAgentMutation, + getManagedAgentsByPubkey, + mentions.memberPubkeys, + startAgentMutation, + ], + ); + + const createMentionedPersonaAgents = React.useCallback( + async (trimmed: string, capturedChannelId: string) => { + const personaMentions = mentions.extractMentionPersonas(trimmed); + if (!capturedChannelId || personaMentions.length === 0) { + return { + errors: [] as string[], + agents: [] as ManagedAgent[], + pubkeys: [] as string[], + }; + } + const runtimes = await getAvailableRuntimes(); + const defaultRuntime = runtimes[0] ?? null; + const errors: string[] = []; + const agents: ManagedAgent[] = []; + const pubkeys: string[] = []; + const seenPersonaIds = new Set(); + const shouldProvisionForDm = + channelType === "dm" && Boolean(onPrepareSendChannel); + for (const { displayName, persona } of personaMentions) { + if (seenPersonaIds.has(persona.id)) { + continue; + } + seenPersonaIds.add(persona.id); + const { runtime } = resolvePersonaRuntime( + persona.runtime, + runtimes, + defaultRuntime, + ); + if (!runtime) { + errors.push(`${displayName}: No agent runtime available.`); + continue; + } + try { + const input: CreateChannelManagedAgentInput & { + channelId: string; + } = { + channelId: capturedChannelId, + runtime, + name: persona.displayName, + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot", + ensureRunning: true, + }; + const result = shouldProvisionForDm + ? await provisionPersonaAgentMutation.mutateAsync(input) + : await createPersonaAgentMutation.mutateAsync(input); + const pubkey = normalizePubkey(result.agent.pubkey); + agents.push(result.agent); + pubkeys.push(pubkey); + mentions.registerMentionPubkey(displayName, pubkey, { + isAgent: true, + }); + } catch (error) { + errors.push( + `${displayName}: ${getErrorMessage( + error, + "Could not create agent.", + )}`, + ); + } + } + return { + agents, + errors, + pubkeys: uniqueNormalizedPubkeys(pubkeys), + }; + }, + [ + createPersonaAgentMutation, + channelType, + getAvailableRuntimes, + mentions.extractMentionPersonas, + mentions.registerMentionPubkey, + onPrepareSendChannel, + provisionPersonaAgentMutation, + ], + ); + + return { + getManagedAgentsByPubkey, + ensureManagedAgentMentionsReady, + createMentionedPersonaAgents, + }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 4bd87c15d64..067daf96ff3 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -34,6 +34,13 @@ export type PendingNonMemberMentionSend = { audienceGeneration: number; audienceRevision: number | null; explicitAgentPubkeys: string[]; + /** + * The sender dismissed the composer's request banner, so this message goes + * out unmarked even though it addresses one agent. Captured with the rest + * of the draft so a send that pauses on the non-member prompt keeps the + * choice the sender actually made. + */ + requestTrackingOptedOut: boolean; }; export type SendMessageWithMentionFlowInput = { @@ -49,6 +56,8 @@ export type SendMessageWithMentionFlowInput = { trimmed: string; audienceGeneration?: number; audienceRevision?: number | null; + /** The sender dismissed the composer's request banner. */ + requestTrackingOptedOut?: boolean; }; export async function resolvePreviewTags( diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index e322f91987d..6343739d657 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -1,15 +1,11 @@ import * as React from "react"; import { toast } from "sonner"; import { - type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, - useAvailableAcpRuntimes, useCreateChannelManagedAgentMutation, - useManagedAgentsQuery, useProvisionChannelManagedAgentMutation, useStartManagedAgentMutation, } from "@/features/agents/hooks"; -import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation, useCanAddChannelMembers, @@ -34,13 +30,12 @@ import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { ChannelType } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import { useAgentMentionPreparation } from "./useAgentMentionPreparation"; import { getErrorMessage, - isManagedAgentRunning, - isProviderBackedAgent, MENTION_REFERENCE_TAG, mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, @@ -48,6 +43,10 @@ import { resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; +import { + decideRequestMarking, + requestAgentPubkeysFor, +} from "@/features/messages/lib/requestMarking"; type UseMentionSendFlowOptions = { channelId: string | null; channelLinks: Pick; @@ -69,6 +68,7 @@ type UseMentionSendFlowOptions = { threadHeadId: string | null; } | null, forceRest?: boolean, + requestAgentPubkeys?: string[], ) => Promise >; richText: Pick< @@ -142,183 +142,20 @@ export function useMentionSendFlow({ useCreateChannelManagedAgentMutation(channelId); const provisionPersonaAgentMutation = useProvisionChannelManagedAgentMutation(channelId); - const availableRuntimesQuery = useAvailableAcpRuntimes(); - const managedAgentsQuery = useManagedAgentsQuery(); const startAgentMutation = useStartManagedAgentMutation(); - const getManagedAgentsByPubkey = React.useCallback(async () => { - const agents = - managedAgentsQuery.data ?? - (await managedAgentsQuery.refetch()).data ?? - []; - return new Map( - agents.map((agent) => [normalizePubkey(agent.pubkey), agent]), - ); - }, [managedAgentsQuery.data, managedAgentsQuery.refetch]); - const getAvailableRuntimes = React.useCallback(async (): Promise< - AcpRuntime[] - > => { - const cached = availableRuntimesQuery.data ?? []; - if (cached.length > 0 || !availableRuntimesQuery.isLoading) { - return cached; - } - const refetched = await availableRuntimesQuery.refetch(); - return (refetched.data ?? []).filter( - (runtime): runtime is AcpRuntime => - runtime.availability === "available" && - runtime.command !== null && - runtime.binaryPath !== null, - ); - }, [ - availableRuntimesQuery.data, - availableRuntimesQuery.isLoading, - availableRuntimesQuery.refetch, - ]); - const ensureManagedAgentMentionsReady = React.useCallback( - async ( - mentionPubkeys: string[], - capturedChannelId: string, - preparedParticipantPubkeys: string[] = [], - preparedManagedAgents: ManagedAgent[] = [], - ) => { - if (!capturedChannelId || mentionPubkeys.length === 0) { - return { - errors: [] as string[], - pubkeys: [] as string[], - }; - } - const managedAgentsByPubkey = await getManagedAgentsByPubkey(); - for (const agent of preparedManagedAgents) { - managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); - } - const participantPubkeys = new Set([ - ...mentions.memberPubkeys, - ...preparedParticipantPubkeys.map(normalizePubkey), - ]); - const errors: string[] = []; - const pubkeys: string[] = []; - for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { - const agent = managedAgentsByPubkey.get(pubkey); - if (!agent) { - continue; - } - try { - if (participantPubkeys.has(pubkey)) { - if (isProviderBackedAgent(agent)) { - if (agent.status !== "deployed") { - await startAgentMutation.mutateAsync(agent.pubkey); - } - } else if (!isManagedAgentRunning(agent)) { - await startAgentMutation.mutateAsync(agent.pubkey); - } - } else { - await attachAgentMutation.mutateAsync({ - channelId: capturedChannelId, - agent, - role: "bot", - }); - } - pubkeys.push(pubkey); - } catch (error) { - errors.push( - `${agent.name}: ${getErrorMessage( - error, - "Could not prepare agent.", - )}`, - ); - } - } - return { - errors, - pubkeys: uniqueNormalizedPubkeys(pubkeys), - }; - }, - [ - attachAgentMutation, - getManagedAgentsByPubkey, - mentions.memberPubkeys, - startAgentMutation, - ], - ); - const createMentionedPersonaAgents = React.useCallback( - async (trimmed: string, capturedChannelId: string) => { - const personaMentions = mentions.extractMentionPersonas(trimmed); - if (!capturedChannelId || personaMentions.length === 0) { - return { - errors: [] as string[], - agents: [] as ManagedAgent[], - pubkeys: [] as string[], - }; - } - const runtimes = await getAvailableRuntimes(); - const defaultRuntime = runtimes[0] ?? null; - const errors: string[] = []; - const agents: ManagedAgent[] = []; - const pubkeys: string[] = []; - const seenPersonaIds = new Set(); - const shouldProvisionForDm = - channelType === "dm" && Boolean(onPrepareSendChannel); - for (const { displayName, persona } of personaMentions) { - if (seenPersonaIds.has(persona.id)) { - continue; - } - seenPersonaIds.add(persona.id); - const { runtime } = resolvePersonaRuntime( - persona.runtime, - runtimes, - defaultRuntime, - ); - if (!runtime) { - errors.push(`${displayName}: No agent runtime available.`); - continue; - } - try { - const input: CreateChannelManagedAgentInput & { - channelId: string; - } = { - channelId: capturedChannelId, - runtime, - name: persona.displayName, - personaId: persona.id, - systemPrompt: persona.systemPrompt, - avatarUrl: persona.avatarUrl ?? undefined, - model: persona.model ?? undefined, - role: "bot", - ensureRunning: true, - }; - const result = shouldProvisionForDm - ? await provisionPersonaAgentMutation.mutateAsync(input) - : await createPersonaAgentMutation.mutateAsync(input); - const pubkey = normalizePubkey(result.agent.pubkey); - agents.push(result.agent); - pubkeys.push(pubkey); - mentions.registerMentionPubkey(displayName, pubkey, { - isAgent: true, - }); - } catch (error) { - errors.push( - `${displayName}: ${getErrorMessage( - error, - "Could not create agent.", - )}`, - ); - } - } - return { - agents, - errors, - pubkeys: uniqueNormalizedPubkeys(pubkeys), - }; - }, - [ - createPersonaAgentMutation, - channelType, - getAvailableRuntimes, - mentions.extractMentionPersonas, - mentions.registerMentionPubkey, - onPrepareSendChannel, - provisionPersonaAgentMutation, - ], - ); + const { + getManagedAgentsByPubkey, + ensureManagedAgentMentionsReady, + createMentionedPersonaAgents, + } = useAgentMentionPreparation({ + channelType, + onPrepareSendChannel, + mentions, + attachAgentMutation, + startAgentMutation, + createPersonaAgentMutation, + provisionPersonaAgentMutation, + }); const clearComposer = React.useCallback( (postSendContent = "") => { @@ -577,6 +414,27 @@ export function useMentionSendFlow({ draft.explicitAgentPubkeys, revalidatedMentionPubkeys, ); + // NIP-AD: the agent this message is addressed to. Any agent in the + // final mention list counts, however it got there (autocomplete, + // explicit picker, or a raw pubkey/URI). It becomes the `agent` + // tag, and a disposition only resolves this request if signed by + // it. Non-agent mentions are deliberately excluded: they still get + // a `p` tag, but a mentioned human must not be able to close an + // agent's obligation. + // + // The decision itself lives in `decideRequestMarking` so the + // composer banner shows the sender the same answer this uses. It + // was derived only here before, which is how a message could + // silently become an obligation — or silently stop being one. + const mentionedAgents = revalidatedMentionPubkeys.filter( + mentions.isAgentPubkey, + ); + const requestAgentPubkeys = requestAgentPubkeysFor( + decideRequestMarking( + mentionedAgents, + draft.requestTrackingOptedOut, + ), + ); await send( finalContent, revalidatedMentionPubkeys, @@ -584,6 +442,7 @@ export function useMentionSendFlow({ sendChannelId, draft.capturedThreadContext, draft.preparedLinkPreviews != null, + requestAgentPubkeys, ); if (signal?.aborted || isSendCancelled()) return; if (revalidatedExplicitAgentPubkeys.length > 0) { @@ -691,6 +550,7 @@ export function useMentionSendFlow({ trimmed, audienceGeneration = 0, audienceRevision = null, + requestTrackingOptedOut = false, }: SendMessageWithMentionFlowInput) => { if (isMentionSendPendingRef.current) { return; @@ -812,6 +672,7 @@ export function useMentionSendFlow({ savedMentionRefs: mentions.getDraftMentionRefs(trimmed), audienceGeneration, audienceRevision, + requestTrackingOptedOut, explicitAgentPubkeys, }; diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index b9f623a75f8..e01173e44d7 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -3,6 +3,7 @@ import { CHANNEL_EVENT_KINDS, CHANNEL_TIMELINE_CONTENT_KINDS, HOME_MENTION_EVENT_KINDS, + KIND_AGENT_DISPOSITION, KIND_DELETION, KIND_NIP29_DELETE_EVENT, KIND_REACTION, @@ -130,6 +131,19 @@ export function buildChannelReactionAuxFilter( return buildChannelAuxKindFilter(messageIds, [KIND_REACTION]); } +/** + * NIP-AD disposition filter for the message rows the GUI is currently + * rendering. Kept separate from reactions and structural aux for the same + * reason those are separate from each other: an unrelated slow scan must + * never delay pixels a different aux kind is about to paint. + */ +export function buildChannelDispositionAuxFilter( + _channelId: string, + messageIds: string[], +): RelaySubscriptionFilter { + return buildChannelAuxKindFilter(messageIds, [KIND_AGENT_DISPOSITION]); +} + export function buildChannelAuxDeletionFilter( _channelId: string, auxEventIds: string[], diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index b31b8fe9776..b5a47af5df1 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -15,8 +15,6 @@ import type { CanvasResponse, GetHomeFeedInput, HomeFeedResponse, - ManagedAgent, - ManagedAgentBackend, RelayAgent, RelayMember, RelayMemberRole, @@ -29,9 +27,7 @@ import type { SetCanvasResult, ThreadCursor, ThreadRepliesResponse, - CreateManagedAgentInput, AgentModelsResponse, - UpdateManagedAgentInput, AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, @@ -43,6 +39,7 @@ import type { export * from "@/shared/api/tauriChannels"; export { sendChannelMessage } from "@/shared/api/tauriMessages"; +export * from "@/shared/api/tauriManagedAgents"; type RawPresenceLookup = Record; @@ -109,64 +106,6 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; -import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; -export type RawManagedAgent = { - pubkey: string; - name: string; - persona_id: string | null; - // Optional: pre-feature fixtures may omit it. The record's harness/runtime id. - runtime?: string | null; - team_id?: string | null; - relay_url: string; - acp_command: string; - agent_command: string; - agent_command_override?: string | null; - agent_args: string[]; - mcp_command: string; - turn_timeout_seconds: number; - idle_timeout_seconds: number | null; - max_turn_duration_seconds: number | null; - parallelism: number; - system_prompt: string | null; - avatar_url?: string | null; - model: string | null; - model_source?: ManagedAgent["modelSource"]; - provider: string | null; - persona_out_of_date: boolean; - persona_orphaned: boolean; - needs_restart: boolean; - restart_diff?: RawRestartDiffEntry[]; - env_vars?: Record; - status: ManagedAgent["status"]; - pid: number | null; - created_at: string; - updated_at: string; - last_started_at: string | null; - last_stopped_at: string | null; - last_exit_code: number | null; - last_error: string | null; - last_error_code: number | null; - log_path: string; - start_on_app_launch: boolean; - auto_restart_on_config_change?: boolean; - backend: ManagedAgentBackend; - backend_agent_id: string | null; - // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. - respond_to?: ManagedAgent["respondTo"]; - respond_to_allowlist?: string[]; -}; - -type RawCreateManagedAgentResponse = { - agent: RawManagedAgent; - private_key_nsec: string; - profile_sync_error: string | null; - spawn_error: string | null; -}; - -type RawManagedAgentLog = { - content: string; - log_path: string; -}; export type RawAcpRuntimeCatalogEntry = { id: string; @@ -630,52 +569,6 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { }; } -export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { - return { - pubkey: agent.pubkey, - name: agent.name, - personaId: agent.persona_id, - runtime: agent.runtime ?? null, - teamId: agent.team_id ?? null, - relayUrl: agent.relay_url, - acpCommand: agent.acp_command, - agentCommand: agent.agent_command, - agentCommandOverride: agent.agent_command_override ?? null, - agentArgs: agent.agent_args, - mcpCommand: agent.mcp_command, - turnTimeoutSeconds: agent.turn_timeout_seconds, - idleTimeoutSeconds: agent.idle_timeout_seconds, - maxTurnDurationSeconds: agent.max_turn_duration_seconds, - parallelism: agent.parallelism, - systemPrompt: agent.system_prompt, - avatarUrl: agent.avatar_url ?? null, - model: agent.model, - modelSource: agent.model_source ?? null, - provider: agent.provider ?? null, - personaOutOfDate: agent.persona_out_of_date ?? false, - personaOrphaned: agent.persona_orphaned ?? false, - needsRestart: agent.needs_restart ?? false, - restartDiff: agent.restart_diff ?? [], - envVars: agent.env_vars ?? {}, - status: agent.status, - pid: agent.pid, - createdAt: agent.created_at, - updatedAt: agent.updated_at, - lastStartedAt: agent.last_started_at, - lastStoppedAt: agent.last_stopped_at, - lastExitCode: agent.last_exit_code, - lastError: agent.last_error, - lastErrorCode: agent.last_error_code ?? null, - logPath: agent.log_path, - startOnAppLaunch: agent.start_on_app_launch, - autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, - backend: agent.backend, - backendAgentId: agent.backend_agent_id, - respondTo: agent.respond_to ?? "owner-only", - respondToAllowlist: agent.respond_to_allowlist ?? [], - }; -} - export function fromRawAcpRuntimeCatalogEntry( entry: RawAcpRuntimeCatalogEntry, ): AcpRuntimeCatalogEntry { @@ -779,76 +672,6 @@ export async function listRelayAgents(): Promise { ); } -export async function listManagedAgents(): Promise { - return (await invokeTauri("list_managed_agents")).map( - fromRawManagedAgent, - ); -} -export async function createManagedAgent(input: CreateManagedAgentInput) { - const response = await invokeTauri( - "create_managed_agent", - { - input: { - name: input.name, - personaId: input.personaId, - teamId: input.teamId, - relayUrl: input.relayUrl, - acpCommand: input.acpCommand, - agentCommand: input.agentCommand, - harnessOverride: input.harnessOverride ?? false, - agentArgs: input.agentArgs, - mcpCommand: input.mcpCommand, - turnTimeoutSeconds: input.turnTimeoutSeconds, - idleTimeoutSeconds: input.idleTimeoutSeconds, - maxTurnDurationSeconds: input.maxTurnDurationSeconds, - parallelism: input.parallelism, - systemPrompt: input.systemPrompt, - avatarUrl: input.avatarUrl, - model: input.model, - provider: input.provider, - envVars: input.envVars ?? {}, - spawnAfterCreate: input.spawnAfterCreate, - startOnAppLaunch: input.startOnAppLaunch, - backend: input.backend, - respondTo: input.respondTo, - respondToAllowlist: input.respondToAllowlist, - relayMesh: input.relayMesh, - }, - }, - ); - return { - agent: fromRawManagedAgent(response.agent), - privateKeyNsec: response.private_key_nsec, - profileSyncError: response.profile_sync_error, - spawnError: response.spawn_error, - }; -} - -export async function deleteManagedAgent( - pubkey: string, - forceRemoteDelete?: boolean, -): Promise { - await invokeTauri("delete_managed_agent", { - pubkey, - forceRemoteDelete: forceRemoteDelete ?? null, - }); -} - -export async function getManagedAgentLog(pubkey: string, lineCount?: number) { - const response = await invokeTauri( - "get_managed_agent_log", - { - pubkey, - lineCount, - }, - ); - - return { - content: response.content, - logPath: response.log_path, - }; -} - export async function discoverGitBashPrerequisite(): Promise { const prerequisite = await invokeTauri( "discover_git_bash_prerequisite", @@ -1023,24 +846,6 @@ export async function getBakedBuildEnv(): Promise { return invokeTauri("get_baked_build_env"); } -type RawUpdateManagedAgentResponse = { - agent: RawManagedAgent; - profile_sync_error: string | null; -}; - -export async function updateManagedAgent( - input: UpdateManagedAgentInput, -): Promise<{ agent: ManagedAgent; profileSyncError: string | null }> { - const response = await invokeTauri( - "update_managed_agent", - { input }, - ); - return { - agent: fromRawManagedAgent(response.agent), - profileSyncError: response.profile_sync_error, - }; -} - // ── Backend provider discovery ──────────────────────────────────────────────── export async function discoverBackendProviders(): Promise< diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 9f77566da99..3a879de137f 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -1,12 +1,205 @@ -import { - fromRawManagedAgent, - invokeTauri, - type RawManagedAgent, -} from "@/shared/api/tauri"; +import { invokeTauri } from "@/shared/api/tauri"; import type { + CreateManagedAgentInput, ManagedAgent, + ManagedAgentBackend, ManagedAgentRuntimeStatus, + UpdateManagedAgentInput, } from "@/shared/api/types"; +import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; + +export type RawManagedAgent = { + pubkey: string; + name: string; + persona_id: string | null; + // Optional: pre-feature fixtures may omit it. The record's harness/runtime id. + runtime?: string | null; + team_id?: string | null; + relay_url: string; + acp_command: string; + agent_command: string; + agent_command_override?: string | null; + agent_args: string[]; + mcp_command: string; + turn_timeout_seconds: number; + idle_timeout_seconds: number | null; + max_turn_duration_seconds: number | null; + parallelism: number; + system_prompt: string | null; + avatar_url?: string | null; + model: string | null; + model_source?: ManagedAgent["modelSource"]; + provider: string | null; + persona_out_of_date: boolean; + persona_orphaned: boolean; + needs_restart: boolean; + restart_diff?: RawRestartDiffEntry[]; + env_vars?: Record; + status: ManagedAgent["status"]; + pid: number | null; + created_at: string; + updated_at: string; + last_started_at: string | null; + last_stopped_at: string | null; + last_exit_code: number | null; + last_error: string | null; + last_error_code: number | null; + log_path: string; + start_on_app_launch: boolean; + auto_restart_on_config_change?: boolean; + backend: ManagedAgentBackend; + backend_agent_id: string | null; + // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. + respond_to?: ManagedAgent["respondTo"]; + respond_to_allowlist?: string[]; +}; + +type RawCreateManagedAgentResponse = { + agent: RawManagedAgent; + private_key_nsec: string; + profile_sync_error: string | null; + spawn_error: string | null; +}; + +type RawManagedAgentLog = { + content: string; + log_path: string; +}; + +export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + personaId: agent.persona_id, + runtime: agent.runtime ?? null, + teamId: agent.team_id ?? null, + relayUrl: agent.relay_url, + acpCommand: agent.acp_command, + agentCommand: agent.agent_command, + agentCommandOverride: agent.agent_command_override ?? null, + agentArgs: agent.agent_args, + mcpCommand: agent.mcp_command, + turnTimeoutSeconds: agent.turn_timeout_seconds, + idleTimeoutSeconds: agent.idle_timeout_seconds, + maxTurnDurationSeconds: agent.max_turn_duration_seconds, + parallelism: agent.parallelism, + systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, + model: agent.model, + modelSource: agent.model_source ?? null, + provider: agent.provider ?? null, + personaOutOfDate: agent.persona_out_of_date ?? false, + personaOrphaned: agent.persona_orphaned ?? false, + needsRestart: agent.needs_restart ?? false, + restartDiff: agent.restart_diff ?? [], + envVars: agent.env_vars ?? {}, + status: agent.status, + pid: agent.pid, + createdAt: agent.created_at, + updatedAt: agent.updated_at, + lastStartedAt: agent.last_started_at, + lastStoppedAt: agent.last_stopped_at, + lastExitCode: agent.last_exit_code, + lastError: agent.last_error, + lastErrorCode: agent.last_error_code ?? null, + logPath: agent.log_path, + startOnAppLaunch: agent.start_on_app_launch, + autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, + backend: agent.backend, + backendAgentId: agent.backend_agent_id, + respondTo: agent.respond_to ?? "owner-only", + respondToAllowlist: agent.respond_to_allowlist ?? [], + }; +} + +export async function listManagedAgents(): Promise { + return (await invokeTauri("list_managed_agents")).map( + fromRawManagedAgent, + ); +} + +export async function createManagedAgent(input: CreateManagedAgentInput) { + const response = await invokeTauri( + "create_managed_agent", + { + input: { + name: input.name, + personaId: input.personaId, + teamId: input.teamId, + relayUrl: input.relayUrl, + acpCommand: input.acpCommand, + agentCommand: input.agentCommand, + harnessOverride: input.harnessOverride ?? false, + agentArgs: input.agentArgs, + mcpCommand: input.mcpCommand, + turnTimeoutSeconds: input.turnTimeoutSeconds, + idleTimeoutSeconds: input.idleTimeoutSeconds, + maxTurnDurationSeconds: input.maxTurnDurationSeconds, + parallelism: input.parallelism, + systemPrompt: input.systemPrompt, + avatarUrl: input.avatarUrl, + model: input.model, + provider: input.provider, + envVars: input.envVars ?? {}, + spawnAfterCreate: input.spawnAfterCreate, + startOnAppLaunch: input.startOnAppLaunch, + backend: input.backend, + respondTo: input.respondTo, + respondToAllowlist: input.respondToAllowlist, + relayMesh: input.relayMesh, + }, + }, + ); + return { + agent: fromRawManagedAgent(response.agent), + privateKeyNsec: response.private_key_nsec, + profileSyncError: response.profile_sync_error, + spawnError: response.spawn_error, + }; +} + +export async function deleteManagedAgent( + pubkey: string, + forceRemoteDelete?: boolean, +): Promise { + await invokeTauri("delete_managed_agent", { + pubkey, + forceRemoteDelete: forceRemoteDelete ?? null, + }); +} + +export async function getManagedAgentLog(pubkey: string, lineCount?: number) { + const response = await invokeTauri( + "get_managed_agent_log", + { + pubkey, + lineCount, + }, + ); + + return { + content: response.content, + logPath: response.log_path, + }; +} + +type RawUpdateManagedAgentResponse = { + agent: RawManagedAgent; + profile_sync_error: string | null; +}; + +export async function updateManagedAgent( + input: UpdateManagedAgentInput, +): Promise<{ agent: ManagedAgent; profileSyncError: string | null }> { + const response = await invokeTauri( + "update_managed_agent", + { input }, + ); + return { + agent: fromRawManagedAgent(response.agent), + profileSyncError: response.profile_sync_error, + }; +} export async function startManagedAgent( pubkey: string, diff --git a/desktop/src/shared/api/tauriMessages.ts b/desktop/src/shared/api/tauriMessages.ts index 4abe03ee09b..f2dd0c32dc3 100644 --- a/desktop/src/shared/api/tauriMessages.ts +++ b/desktop/src/shared/api/tauriMessages.ts @@ -15,6 +15,15 @@ export async function sendChannelMessage( sentFromThreadTag?: string[], expectedRelayUrl?: string, expectedSignerPubkey?: string, + /** + * NIP-AD: pubkeys of the agents this message is addressed to. A non-empty + * list makes it a marked request awaiting a disposition, and each pubkey + * becomes an `["agent", ]` tag that a disposition must be signed + * by to resolve this request. Deliberately not derived from the `p` + * mention set — a mentioned human must not be able to close an agent's + * obligation. See docs/nips/NIP-AD.md. + */ + requestAgentPubkeys?: string[], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -36,6 +45,7 @@ export async function sendChannelMessage( // closed when the active identity no longer matches, so a community // switch cannot re-sign the captured tenant's content as the new one. expectedSignerPubkey: expectedSignerPubkey ?? null, + requestAgentPubkeys: requestAgentPubkeys ?? null, }, ); return { diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 4f8b7afe2bd..e0af6831230 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -60,6 +60,11 @@ export const KIND_MANAGED_AGENT = 30177; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; +// NIP-AD: signed record of how an agent resolved a human/agent request +// (completed | refused | errored). Plaintext, world-readable, `#e`-referenced +// to the request message — structurally an aux overlay kind, same shape as a +// reaction. See docs/nips/NIP-AD.md. +export const KIND_AGENT_DISPOSITION = 44300; export const KIND_EVENT_REMINDER = 30300; export const KIND_REPO_ANNOUNCEMENT = 30617; export const KIND_REPO_STATE = 30618; @@ -105,6 +110,7 @@ export const CHANNEL_EVENT_KINDS = [ KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 — huddle lifecycle overlay KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 — huddle lifecycle overlay KIND_HUDDLE_ENDED, // 48103 — huddle lifecycle overlay + KIND_AGENT_DISPOSITION, // 44300 — NIP-AD disposition (live arrival) ] as const; // Auxiliary (non-row) timeline kinds: events that overlay onto or hide an @@ -121,6 +127,7 @@ export const CHANNEL_AUX_EVENT_KINDS = [ KIND_REACTION, // 7 — NIP-25 reactions KIND_NIP29_DELETE_EVENT, // 9005 — NIP-29 / Buzz-native deletions KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits + KIND_AGENT_DISPOSITION, // 44300 — NIP-AD disposition, backfilled by #e reference ] as const; // Visible content kinds the main timeline renders as their own rows. Mirrors diff --git a/desktop/src/shared/lib/disposition.test.mjs b/desktop/src/shared/lib/disposition.test.mjs new file mode 100644 index 00000000000..9701f1a084f --- /dev/null +++ b/desktop/src/shared/lib/disposition.test.mjs @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + account, + allResolved, + bindDisposition, + classifyRequest, + COMPLETE_COVERAGE, + deriveObligation, + isMarkedRequest, + REQUEST_KINDS, +} from "./disposition.ts"; + +// The same corpora the Rust verifier runs (crates/buzz-core/tests/ +// nip_ad_conformance.rs). +// +// - nip-ad-conformance.json is hand-written and pins the two IMPLEMENTATIONS +// to each other, which is how the earlier silent divergence was closed. +// - nip-ad-lifecycle-exhaustive.json is generated with expectations computed +// from declarative rules rather than from either implementation, so it also +// pins both to the SPEC — the gap that let a hand-written transition table +// contradict working code with every test still green. +const corpusPath = (name) => + fileURLToPath(new URL(`../../../../docs/nips/${name}`, import.meta.url)); + +const CORPUS = JSON.parse( + readFileSync(corpusPath("nip-ad-conformance.json"), "utf8"), +); +const EXHAUSTIVE = JSON.parse( + readFileSync(corpusPath("nip-ad-lifecycle-exhaustive.json"), "utf8"), +); + +const KIND_AGENT_DISPOSITION = 44300; + +const CONSTANTS = (() => { + const map = new Map(); + for (const [key, value] of Object.entries(CORPUS.constants)) { + map.set(`$${key}`, value); + } + // Derived so the corpus can express the uppercase-target case without + // duplicating a 64-char literal. + map.set("$AGENT_UPPER", CORPUS.constants.agent.toUpperCase()); + return map; +})(); + +function resolve(raw) { + if (CONSTANTS.has(raw)) { + return CONSTANTS.get(raw); + } + let out = raw; + for (const [placeholder, value] of CONSTANTS) { + out = out.split(placeholder).join(value); + } + return out; +} + +function resolveTags(rows) { + return rows.map((row) => row.map(resolve)); +} + +// Events are built from the CORPUS's kind, never this implementation's. +// Defaulting to REQUEST_KINDS[0] on both sides is what let a Rust/TS +// divergence on the request-kind set pass both suites. +const CORPUS_REQUEST_KIND = CORPUS.requestKinds[0]; + +function requestEvent(tags, kind = CORPUS_REQUEST_KIND) { + return { + id: CORPUS.constants.requestId, + pubkey: CORPUS.constants.requester, + kind, + created_at: 100, + content: "@agent do the thing", + tags, + }; +} + +/** The canonical valid request every binding and lifecycle case derives from. */ +function canonicalObligation() { + const classified = classifyRequest( + requestEvent(resolveTags(CORPUS.requestClassification[0].tags)), + ); + assert.equal( + classified.kind, + "valid", + "corpus case 0 must be a valid request", + ); + return classified.obligation; +} + +function dispositionEvent(id, createdAt, state) { + return { + id, + pubkey: CORPUS.constants.agent, + kind: KIND_AGENT_DISPOSITION, + created_at: createdAt, + content: JSON.stringify({ disposition: state, reason: `r-${state}` }), + tags: [ + ["e", CORPUS.constants.requestId], + ["h", CORPUS.constants.channel], + ["p", CORPUS.constants.requester], + ["disposition", state], + ], + }; +} + +function assertDerived(name, derived, expect) { + assert.deepEqual(derived.outcome, expect.outcome, `${name}: wrong outcome`); + assert.equal( + derived.latestObservation, + expect.latestObservation, + `${name}: wrong latest observation`, + ); + assert.deepEqual( + derived.warnings, + expect.warnings, + `${name}: wrong warnings`, + ); + assert.equal( + derived.outcome.kind === "settled", + expect.resolved, + `${name}: wrong resolved`, + ); +} + +test("request kinds match the corpus", () => { + assert.deepEqual( + [...REQUEST_KINDS], + CORPUS.requestKinds, + "this implementation's request-kind set disagrees with the corpus", + ); +}); + +test("request classification matches the corpus", () => { + for (const testCase of CORPUS.requestClassification) { + const { name, expect } = testCase; + const event = requestEvent( + resolveTags(testCase.tags), + testCase.kind ?? CORPUS_REQUEST_KIND, + ); + const classified = classifyRequest(event); + assert.equal(classified.kind, expect.kind, `${name}: wrong class`); + + if (expect.kind === "valid") { + // Pin the whole obligation, not just the target: a classifier that got + // the channel or requester wrong would still pass a target-only check. + assert.equal( + classified.obligation.targetAgentPubkey, + resolve(expect.targetAgent), + `${name}: wrong target`, + ); + assert.equal(classified.obligation.requestId, CORPUS.constants.requestId); + assert.equal( + classified.obligation.requesterPubkey, + CORPUS.constants.requester, + ); + assert.equal(classified.obligation.channelId, CORPUS.constants.channel); + } else if (expect.kind === "not_request") { + assert.equal( + isMarkedRequest(event), + false, + `${name}: marker disagreement`, + ); + } else { + assert.equal(classified.reason, expect.reason, `${name}: wrong reason`); + } + } +}); + +test("binding matches the corpus", () => { + const obligation = canonicalObligation(); + for (const testCase of CORPUS.binding) { + const { name, expect } = testCase; + const result = bindDisposition(obligation, { + id: "d0", + pubkey: resolve(testCase.signer), + kind: testCase.kind ?? KIND_AGENT_DISPOSITION, + created_at: 200, + content: resolve(testCase.content), + tags: resolveTags(testCase.tags), + }); + assert.equal(result.bound, expect.bound, `${name}: wrong bound`); + if (expect.bound) { + assert.equal(result.state, expect.state, `${name}: wrong state`); + } else { + assert.equal(result.reason, expect.reason, `${name}: wrong bind failure`); + } + } +}); + +test("lifecycle matches the corpus", () => { + const obligation = canonicalObligation(); + for (const testCase of CORPUS.lifecycle) { + const events = testCase.events.map(([id, createdAt, state]) => + dispositionEvent(id, createdAt, state), + ); + assertDerived( + testCase.name, + deriveObligation(obligation, events), + testCase.expect, + ); + } +}); + +test("exhaustive lifecycle matches the generated corpus", () => { + const obligation = canonicalObligation(); + assert.equal( + EXHAUSTIVE.cases.length, + EXHAUSTIVE.caseCount, + "corpus caseCount disagrees with its own case list", + ); + for (const { history, expect } of EXHAUSTIVE.cases) { + // Index order is sort order: increasing created_at, distinct ids. + const events = history.map((state, i) => + dispositionEvent(`d${String(i).padStart(4, "0")}`, 100 + i, state), + ); + assertDerived( + `[${history.join(" -> ")}]`, + deriveObligation(obligation, events), + expect, + ); + } +}); + +test("accounting never reports a malformed request as an agent gap", () => { + const cases = CORPUS.requestClassification + .map((c, i) => ({ ...c, index: i })) + .filter( + (c) => + (c.expect.kind === "invalid" || c.expect.kind === "unsupported") && + // The unsupported-kind case is about the event's kind, not its tags, + // and is covered by the classification test. + c.kind == null, + ); + + const requests = cases.map((c) => ({ + ...requestEvent(resolveTags(c.tags)), + id: String(c.index).padStart(64, "0"), + })); + + const acc = account(requests, [], COMPLETE_COVERAGE); + assert.equal( + acc.invalidRequests.length, + cases.filter((c) => c.expect.kind === "invalid").length, + ); + assert.equal( + acc.unsupportedRequests.length, + cases.filter((c) => c.expect.kind === "unsupported").length, + ); + assert.deepEqual( + acc.unanswered, + [], + "malformed requests must never be counted as unanswered obligations", + ); + assert.equal( + allResolved(acc), + false, + "unanswerable requests block a clean claim", + ); +}); + +test("an unbound claim is excluded from state but reported", () => { + // Spoof attempts must not vanish silently — an auditor needs to see them, + // they just must not affect the outcome. + const spoof = { + ...dispositionEvent("d1", 200, "completed"), + pubkey: CORPUS.constants.human, + }; + const acc = account( + [requestEvent(resolveTags(CORPUS.requestClassification[0].tags))], + [spoof], + COMPLETE_COVERAGE, + ); + assert.deepEqual(acc.unanswered, [CORPUS.constants.requestId]); + assert.equal(acc.rejectedClaims.length, 1); + assert.equal(acc.rejectedClaims[0].failure, "not_target_agent"); + assert.equal(acc.rejectedClaims[0].signer, CORPUS.constants.human); +}); diff --git a/desktop/src/shared/lib/disposition.ts b/desktop/src/shared/lib/disposition.ts new file mode 100644 index 00000000000..cc816944e73 --- /dev/null +++ b/desktop/src/shared/lib/disposition.ts @@ -0,0 +1,772 @@ +/** + * NIP-AD: Agent Disposition — the shared request/disposition verifier and + * lifecycle derivation, mirroring `crates/buzz-core/src/disposition.rs`. + * + * The two implementations are pinned to one JSON corpus + * (`docs/nips/nip-ad-conformance.json`), run by `disposition.test.mjs` here + * and `nip_ad_conformance.rs` there. They previously drifted — different + * candidate sets, different binding rules, different conflict semantics — and + * the drift was invisible because each side tested itself with its own + * fixtures. Change behavior here and the Rust corpus test fails, and vice + * versa. That is deliberate. + * + * **This pins the two implementations to each other, not to the spec.** That + * gap is real and was exploited once already: the NIP's transition table and + * `isResolved` disagreed about whether an anomalous terminal history was + * resolved, and every test passed because both languages agreed with each + * other. The normative table in NIP-AD.md is now generated from this corpus + * (`scripts/gen-nip-ad-tables.mjs`) so that particular drift cannot recur. + * + * See docs/nips/NIP-AD.md. + */ + +/** Terminal states settle an obligation; non-terminal ones leave it open. */ +export type DispositionState = + | "completed" + | "refused" + | "responded" + | "errored"; + +export const DISPOSITION_STATES: readonly DispositionState[] = [ + "completed", + "refused", + "responded", + "errored", +]; + +/** + * `completed` and `refused` settle the obligation. `responded` and `errored` + * do not — which is what makes `errored → completed` and + * `responded → completed` legal repairs rather than contradictions. + */ +export function isTerminalDisposition(state: DispositionState): boolean { + return state === "completed" || state === "refused"; +} + +export function parseDispositionState( + value: string | undefined, +): DispositionState | null { + return value != null && + (DISPOSITION_STATES as readonly string[]).includes(value) + ? (value as DispositionState) + : null; +} + +/** + * Event kinds v1 accepts `["t","request"]` markers on. + * + * Centralized so every query builder and classifier derives its candidate + * universe from one list. The CLI previously queried only kind 9 while the + * desktop timeline carried several message and job kinds, so even identical + * derivation logic could produce different accounting for one channel. + * + * Mirrors `REQUEST_KINDS` in the Rust module. + */ +export const REQUEST_KINDS: readonly number[] = [9]; + +export function isRequestKind(kind: number): boolean { + return REQUEST_KINDS.includes(kind); +} + +/** Why a marked event is malformed. Never an agent failure. */ +export type InvalidRequestReason = + | "missing_agent_target" + | "malformed_agent_target" + | "missing_channel" + | "multiple_channels" + | "target_not_mentioned" + | "duplicate_agent_target" + | "unsupported_kind"; + +/** + * A well-formed request whose intent v1 cannot represent. Distinct from + * invalid: an invalid request is a bug to fix, an unsupported one is a + * feature to build. Both block a clean claim; only one implies fault. + */ +export type UnsupportedRequestReason = "multiple_agent_targets"; + +/** + * Why an event is not a structurally valid kind:44300 disposition. + * + * Mirrors `InvalidDisposition` in the Rust module. Every member corresponds + * to a rule in NIP-AD's "Event" and "Content" sections. + */ +export type InvalidDisposition = + | "wrong_kind" + | "request_id_cardinality" + | "channel_cardinality" + | "requester_cardinality" + | "state_cardinality" + | "malformed_request_id" + | "malformed_requester" + | "unknown_state" + | "content_not_object" + | "content_state_mismatch" + | "missing_reason" + | "reason_not_string" + | "request_id_not_string" + | "content_request_id_mismatch"; + +/** + * Why a candidate disposition does not bind. + * + * Structural invalidity is flattened into this union rather than nested, so a + * rejected-claims diagnostic names the rule that was broken. + */ +export type BindFailure = + | InvalidDisposition + | "request_mismatch" + | "channel_mismatch" + | "requester_mismatch" + | "not_target_agent"; + +/** A structurally valid kind:44300 event, with its fields extracted once. */ +export type CanonicalDisposition = { + eventId: string; + signer: string; + createdAt: number; + requestId: string; + channelId: string; + requesterPubkey: string; + state: DispositionState; + /** Required to be present; permitted to be empty. */ + reason: string; +}; + +/** Mirrors `KIND_AGENT_DISPOSITION` in buzz-core. */ +export const KIND_AGENT_DISPOSITION = 44300; + +/** + * Something worth surfacing that does not change an outcome. + * + * Strictly diagnostic. An earlier version made every one of these force the + * obligation out of its settled state and render as "disputed", which + * defeated the point of categorizing them: a duplicate delivery and a genuine + * contradiction produced identical, equally destructive results. + */ +export type HistoryWarning = "duplicate_terminal" | "ordered_after_terminal"; + +/** The one obligation a marked request creates in v1. */ +export type Obligation = { + requestId: string; + channelId: string; + requesterPubkey: string; + targetAgentPubkey: string; +}; + +/** Total: every event lands in exactly one arm. No caller pre-check needed. */ +export type RequestClass = + | { kind: "not_request" } + | { kind: "invalid"; reason: InvalidRequestReason } + | { kind: "unsupported"; reason: UnsupportedRequestReason } + | { kind: "valid"; obligation: Obligation }; + +/** + * What actually became of an obligation. + * + * Separate from the raw latest observation on purpose. Deriving state as + * "latest event wins" made `terminal` a lie: a late `errored` silently + * reopened a settled obligation. Terminal claims are **absorbing** — once + * something settles, a later weaker observation is a warning and cannot + * reopen it. + */ +export type EffectiveOutcome = + | { kind: "unanswered" } + | { kind: "open"; state: DispositionState } + | { kind: "settled"; state: DispositionState } + | { kind: "disputed" }; + +export type DerivedObligation = { + obligation: Obligation; + /** What became of it. Every accounting and display surface uses this. */ + outcome: EffectiveOutcome; + /** + * The last bound state by `(created_at, id)`, regardless of absorption. + * Retained for audit — it answers "what arrived last", a different + * question from "what is true". Never decide doneness from it. + */ + latestObservation: DispositionState | null; + /** Reason from the disposition that determined the outcome. */ + reason: string; + /** Diagnostics that do not change the outcome. */ + warnings: HistoryWarning[]; +}; + +/** Minimal event shape this module needs — adapt from RelayEvent or a test. */ +export type DispositionEventView = { + id: string; + pubkey: string; + /** + * Event kind. Required so request classification can reject kinds v1 does + * not accept requests on — without it, two consumers querying different + * kind sets produce different accounting and both look correct. + */ + kind: number; + created_at: number; + content: string; + tags: string[][]; +}; + +function tagValues(event: DispositionEventView, key: string): string[] { + return event.tags + .filter((t) => t.length >= 2 && t[0] === key) + .map((t) => t[1]); +} + +function firstTag( + event: DispositionEventView, + key: string, +): string | undefined { + return tagValues(event, key)[0]; +} + +function hasTagPair( + event: DispositionEventView, + key: string, + value: string, +): boolean { + return tagValues(event, key).includes(value); +} + +/** + * Canonical 64-character lowercase hex. + * + * Case is part of the contract, not formatting: a case-insensitive writer + * paired with a case-sensitive reader silently produces dispositions nobody + * binds. One canonical form, compared exactly, everywhere. + */ +export function isCanonicalHex64(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value); +} + +/** + * Whether an event carries the request marker. A marker check alone says + * nothing about validity — prefer {@link classifyRequest}, which is total. + */ +export function isMarkedRequest(event: DispositionEventView): boolean { + return hasTagPair(event, "t", "request"); +} + +/** + * Classify any event. Total — no precondition, no caller-side marker check. + * + * Totality is the point. An earlier version required callers to check the + * marker first, and the ACP harness grew its own looser predicate instead, + * accepting uppercase targets, multi-target requests, and targets that were + * never `p`-mentioned. + */ +export function classifyRequest(event: DispositionEventView): RequestClass { + if (!isMarkedRequest(event)) { + return { kind: "not_request" }; + } + if (!isRequestKind(event.kind)) { + return { kind: "invalid", reason: "unsupported_kind" }; + } + + const channels = tagValues(event, "h"); + if (channels.length === 0) { + return { kind: "invalid", reason: "missing_channel" }; + } + if (channels.length > 1) { + return { kind: "invalid", reason: "multiple_channels" }; + } + const channelId = channels[0]; + + const agents = tagValues(event, "agent"); + if (agents.length === 0) { + return { kind: "invalid", reason: "missing_agent_target" }; + } + + // **Malformed beats unsupported.** Every target is validated before + // cardinality is judged, so a request naming one real agent and one garbage + // value is a malformed event — not "a feature v1 cannot represent". + // Returning unsupported first (as an earlier version did) filed client bugs + // under "not anyone's fault". + // + // `p`-mention is checked in the same pass: `p` is what routes a message to + // a principal, so an `agent` tag without it names a target that will never + // be asked. + for (const target of agents) { + if (!isCanonicalHex64(target)) { + return { kind: "invalid", reason: "malformed_agent_target" }; + } + if (!hasTagPair(event, "p", target)) { + return { kind: "invalid", reason: "target_not_mentioned" }; + } + } + + const unique = [...new Set(agents)]; + if (unique.length > 1) { + // Distinct, well-formed, reachable targets: a coherent intent v1 cannot + // represent. + return { kind: "unsupported", reason: "multiple_agent_targets" }; + } + if (agents.length > 1) { + // One target written twice — not canonical. + return { kind: "invalid", reason: "duplicate_agent_target" }; + } + const target = agents[0]; + + return { + kind: "valid", + obligation: { + requestId: event.id, + channelId, + requesterPubkey: event.pubkey, + targetAgentPubkey: target, + }, + }; +} + +/** + * Whether `disposition` validly answers `obligation`. + * + * The target-agent clause is the cross-principal check: without it any + * channel member — including a merely `p`-mentioned human — can close an + * agent's obligation. The relay cannot enforce this (it would need to fetch + * a second event mid-validation), so every consumer must, identically. + */ +/** + * Validate an event against the **complete** NIP-AD structural contract. + * + * The single validity boundary, mirroring `validate_disposition_event` in the + * Rust module (which relay ingest itself calls). It exists because the two + * sides had drifted apart in the worst direction: the relay enforced kind, + * exact tag cardinality, and a required string `reason`, while the + * consumer-side binder checked none of them — it read the *first* matching + * tag and ignored the rest. An event with two `e` tags, or no `reason`, or + * not even of kind 44300, was rejected by the relay and simultaneously + * accepted as a settling disposition here. + */ +export function validateDispositionEvent( + event: DispositionEventView, +): + | { valid: true; disposition: CanonicalDisposition } + | { valid: false; reason: InvalidDisposition } { + if (event.kind !== KIND_AGENT_DISPOSITION) { + return { valid: false, reason: "wrong_kind" }; + } + + // Exactly one of each. `firstTag` semantics are deliberately not used here: + // silently taking the first of several is how the validators diverged. + const exactlyOne = ( + key: string, + reason: InvalidDisposition, + ): + | { ok: true; value: string } + | { ok: false; reason: InvalidDisposition } => { + const values = tagValues(event, key); + return values.length === 1 + ? { ok: true, value: values[0] } + : { ok: false, reason }; + }; + + const e = exactlyOne("e", "request_id_cardinality"); + if (!e.ok) return { valid: false, reason: e.reason }; + const h = exactlyOne("h", "channel_cardinality"); + if (!h.ok) return { valid: false, reason: h.reason }; + const p = exactlyOne("p", "requester_cardinality"); + if (!p.ok) return { valid: false, reason: p.reason }; + const d = exactlyOne("disposition", "state_cardinality"); + if (!d.ok) return { valid: false, reason: d.reason }; + + if (!isCanonicalHex64(e.value)) { + return { valid: false, reason: "malformed_request_id" }; + } + if (!isCanonicalHex64(p.value)) { + return { valid: false, reason: "malformed_requester" }; + } + const state = parseDispositionState(d.value); + if (state == null) { + return { valid: false, reason: "unknown_state" }; + } + + let body: unknown; + try { + body = JSON.parse(event.content); + } catch { + return { valid: false, reason: "content_not_object" }; + } + if (body == null || typeof body !== "object" || Array.isArray(body)) { + return { valid: false, reason: "content_not_object" }; + } + const record = body as Record; + if (record.disposition !== state) { + return { valid: false, reason: "content_state_mismatch" }; + } + if (!("reason" in record)) { + return { valid: false, reason: "missing_reason" }; + } + if (typeof record.reason !== "string") { + return { valid: false, reason: "reason_not_string" }; + } + if ("request_id" in record) { + if (typeof record.request_id !== "string") { + return { valid: false, reason: "request_id_not_string" }; + } + if (record.request_id !== e.value) { + return { valid: false, reason: "content_request_id_mismatch" }; + } + } + + return { + valid: true, + disposition: { + eventId: event.id, + signer: event.pubkey, + createdAt: event.created_at, + requestId: e.value, + channelId: h.value, + requesterPubkey: p.value, + state, + reason: record.reason, + }, + }; +} + +/** + * Whether `disposition` validly answers `obligation`. + * + * Two stages: structural validity (above), then binding. The target-agent + * clause is the cross-principal check — without it any channel member, + * including a merely `p`-mentioned human, can close an agent's obligation. + */ +export function bindDisposition( + obligation: Obligation, + disposition: DispositionEventView, +): + | { bound: true; state: DispositionState } + | { bound: false; reason: BindFailure } { + const validated = validateDispositionEvent(disposition); + if (!validated.valid) { + return { bound: false, reason: validated.reason }; + } + return bindCanonical(obligation, validated.disposition); +} + +/** Stage 2 alone, for callers that already validated. */ +export function bindCanonical( + obligation: Obligation, + disposition: CanonicalDisposition, +): + | { bound: true; state: DispositionState } + | { bound: false; reason: BindFailure } { + if (disposition.requestId !== obligation.requestId) { + return { bound: false, reason: "request_mismatch" }; + } + if (disposition.channelId !== obligation.channelId) { + return { bound: false, reason: "channel_mismatch" }; + } + if (disposition.requesterPubkey !== obligation.requesterPubkey) { + return { bound: false, reason: "requester_mismatch" }; + } + if (disposition.signer !== obligation.targetAgentPubkey) { + return { bound: false, reason: "not_target_agent" }; + } + return { bound: true, state: disposition.state }; +} + +function reasonOf(disposition: DispositionEventView): string { + try { + const body: unknown = JSON.parse(disposition.content); + if (body != null && typeof body === "object") { + const reason = (body as Record).reason; + if (typeof reason === "string") { + return reason; + } + } + } catch { + // fall through + } + return ""; +} + +/** Ordinal (code-unit) comparison — matches Rust's byte-wise `str::cmp` + * exactly for the lowercase-hex ids this orders. Deliberately not + * `localeCompare`, whose collation is locale-dependent. */ +function ordinalCompare(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Derive an obligation's state from candidate dispositions. + * + * Candidates are bound, deduplicated by event id (merged relay results can + * legitimately carry the same event twice), then ordered by + * `(created_at, id)`. + * + * **That ordering is deterministic, not causal.** `created_at` is + * whole-second and publisher-supplied, so a later publication can carry an + * earlier timestamp. `ordered_after_terminal` is named for what it can + * actually establish — sort order — rather than for a write it cannot + * observe. + */ +export function deriveObligation( + obligation: Obligation, + candidates: readonly DispositionEventView[], +): DerivedObligation { + const bound: Array<{ event: DispositionEventView; state: DispositionState }> = + []; + const seen = new Set(); + for (const candidate of candidates) { + const result = bindDisposition(obligation, candidate); + if (!result.bound || seen.has(candidate.id)) { + continue; + } + seen.add(candidate.id); + bound.push({ event: candidate, state: result.state }); + } + + bound.sort( + (a, b) => + a.event.created_at - b.event.created_at || + ordinalCompare(a.event.id, b.event.id), + ); + + const latestObservation = bound[bound.length - 1]?.state ?? null; + const hasCompleted = bound.some((b) => b.state === "completed"); + const hasRefused = bound.some((b) => b.state === "refused"); + const firstTerminalIdx = bound.findIndex((b) => + isTerminalDisposition(b.state), + ); + + // Terminal-absorbing: the first terminal claim decides, and a later + // non-terminal observation is a warning rather than a reopening. + let outcome: EffectiveOutcome; + if (hasCompleted && hasRefused) { + outcome = { kind: "disputed" }; + } else if (firstTerminalIdx !== -1) { + outcome = { kind: "settled", state: bound[firstTerminalIdx].state }; + } else if (latestObservation != null) { + outcome = { kind: "open", state: latestObservation }; + } else { + outcome = { kind: "unanswered" }; + } + + const warnings: HistoryWarning[] = []; + // "Duplicate" means the SAME terminal state twice. Counting any two + // terminals would fire on `completed` + `refused`, which is a contradiction + // (already `disputed`), not a duplicate. + const duplicated = (["completed", "refused"] as const).some( + (state) => bound.filter((b) => b.state === state).length > 1, + ); + if (duplicated) { + warnings.push("duplicate_terminal"); + } + if ( + firstTerminalIdx !== -1 && + bound + .slice(firstTerminalIdx + 1) + .some((b) => !isTerminalDisposition(b.state)) + ) { + warnings.push("ordered_after_terminal"); + } + + // The settling event's reason, not the latest event's — a stray write after + // a refusal must not blank out why the agent refused. + const reasonSource = + firstTerminalIdx !== -1 && outcome.kind !== "disputed" + ? bound[firstTerminalIdx] + : bound[bound.length - 1]; + + return { + obligation, + outcome, + latestObservation, + reason: reasonSource ? reasonOf(reasonSource.event) : "", + warnings, + }; +} + +export function isResolved(derived: DerivedObligation): boolean { + return derived.outcome.kind === "settled"; +} + +export function isOpen(derived: DerivedObligation): boolean { + return derived.outcome.kind === "open"; +} + +export function isUnanswered(derived: DerivedObligation): boolean { + return derived.outcome.kind === "unanswered"; +} + +export function isDisputed(derived: DerivedObligation): boolean { + return derived.outcome.kind === "disputed"; +} + +/** + * How much of the record a caller actually examined. + * + * Both sides are required. A caller could paginate every request, fetch one + * page of dispositions, see a `completed`, miss the later `refused`, and + * truthfully set a one-sided flag while reporting a disputed obligation as + * resolved. + */ +export type Coverage = { + requestsComplete: boolean; + dispositionsComplete: boolean; +}; + +export const PARTIAL_COVERAGE: Coverage = { + requestsComplete: false, + dispositionsComplete: false, +}; + +export const COMPLETE_COVERAGE: Coverage = { + requestsComplete: true, + dispositionsComplete: true, +}; + +export function isCoverageComplete(coverage: Coverage): boolean { + return coverage.requestsComplete && coverage.dispositionsComplete; +} + +/** + * A stored disposition that named an obligation but did not bind to it. + * Excluded from state; reported so spoof attempts are visible rather than + * silently vanishing. + */ +export type RejectedClaim = { + eventId: string; + referencedRequest: string; + signer: string; + failure: BindFailure; +}; + +/** Accounting over a set of requests, with its coverage stated. */ +export type Accounting = { + settled: string[]; + open: string[]; + unanswered: string[]; + disputed: string[]; + invalidRequests: Array<{ requestId: string; reason: InvalidRequestReason }>; + unsupportedRequests: Array<{ + requestId: string; + reason: UnsupportedRequestReason; + }>; + rejectedClaims: RejectedClaim[]; + coverage: Coverage; +}; + +/** + * Every request examined is settled, over provably complete coverage. + * + * `invalidRequests` and `unsupportedRequests` block this too, which is easy + * to miss: neither is an agent failure, but both are marked events nobody can + * ever answer, and a green check would hide the problem. + */ +export function allResolved(acc: Accounting): boolean { + return ( + isCoverageComplete(acc.coverage) && + acc.open.length === 0 && + acc.unanswered.length === 0 && + acc.disputed.length === 0 && + acc.invalidRequests.length === 0 && + acc.unsupportedRequests.length === 0 + ); +} + +/** Requests examined, across every bucket. */ +export function accountingTotal(acc: Accounting): number { + return ( + acc.settled.length + + acc.open.length + + acc.unanswered.length + + acc.disputed.length + + acc.invalidRequests.length + + acc.unsupportedRequests.length + ); +} + +/** + * Build accounting from candidate requests and dispositions. + * + * `requests` may contain any events — classification is total, and + * non-requests are ignored. Dispositions are grouped by `e` tag once, so this + * is O(requests + dispositions): any channel member can store structurally + * valid but unbound dispositions, so the quadratic version was an avoidable + * denial-of-service surface. + */ +export function account( + requests: readonly DispositionEventView[], + dispositions: readonly DispositionEventView[], + coverage: Coverage, +): Accounting { + const acc: Accounting = { + settled: [], + open: [], + unanswered: [], + disputed: [], + invalidRequests: [], + unsupportedRequests: [], + rejectedClaims: [], + coverage, + }; + + const byRequest = new Map(); + for (const d of dispositions) { + const e = firstTag(d, "e"); + if (e == null) { + continue; + } + const list = byRequest.get(e); + if (list) { + list.push(d); + } else { + byRequest.set(e, [d]); + } + } + + for (const request of requests) { + const classified = classifyRequest(request); + switch (classified.kind) { + case "not_request": + break; + case "invalid": + acc.invalidRequests.push({ + requestId: request.id, + reason: classified.reason, + }); + break; + case "unsupported": + acc.unsupportedRequests.push({ + requestId: request.id, + reason: classified.reason, + }); + break; + case "valid": { + const candidates = byRequest.get(request.id) ?? []; + const derived = deriveObligation(classified.obligation, candidates); + const id = classified.obligation.requestId; + switch (derived.outcome.kind) { + case "settled": + acc.settled.push(id); + break; + case "open": + acc.open.push(id); + break; + case "unanswered": + acc.unanswered.push(id); + break; + case "disputed": + acc.disputed.push(id); + break; + } + for (const candidate of candidates) { + const result = bindDisposition(classified.obligation, candidate); + if (!result.bound) { + acc.rejectedClaims.push({ + eventId: candidate.id, + referencedRequest: classified.obligation.requestId, + signer: candidate.pubkey, + failure: result.reason, + }); + } + } + break; + } + } + } + + return acc; +} diff --git a/desktop/tests/e2e/agent-disposition-screenshots.spec.ts b/desktop/tests/e2e/agent-disposition-screenshots.spec.ts new file mode 100644 index 00000000000..bd99c0a033e --- /dev/null +++ b/desktop/tests/e2e/agent-disposition-screenshots.spec.ts @@ -0,0 +1,305 @@ +/** + * Screenshot spec for the NIP-AD disposition surface (kind:44300). + * + * Captures the three badge states on a marked request, the conflict marker, + * and both summary-chip states (all-resolved vs. needs-attention with the + * popover open). See docs/nips/NIP-AD.md. + * + * Every shot is scoped with `locator.screenshot()` (or a tight `clip` where an + * overlay must be included) rather than a full-page capture — an unscoped + * page shot renders the same pixels for several of these states and produces + * byte-identical PNGs. Verify with `shasum -a 256` before posting. + */ + +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const SHOTS = "test-results/nip-ad-screenshots"; + +// The agent every request is addressed to, and that signs every disposition. +// Binding requires the request to name its disposition's signer as an `agent` +// target, so these must agree. +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const REQUESTER_PUBKEY = "deadbeef".repeat(8); +const AGENT_PUBKEY = "b".repeat(64); + +const REQ_COMPLETED = "1".repeat(64); +const REQ_REFUSED = "2".repeat(64); +const REQ_ERRORED = "3".repeat(64); +const REQ_CONFLICT = "4".repeat(64); +const REQ_UNANSWERED = "5".repeat(64); + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(async () => + page.evaluate( + (currentChannelName) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +async function openGeneral(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); +} + +function emitRequest( + page: import("@playwright/test").Page, + id: string, + content: string, +) { + return page.evaluate( + ({ id: eventId, content: body, agent }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + id: eventId, + extraTags: [ + ["t", "request"], + ["agent", agent], + ["p", agent], + ], + }); + }, + { id, content, agent: AGENT_PUBKEY }, + ); +} + +function emitDisposition( + page: import("@playwright/test").Page, + requestId: string, + disposition: "completed" | "refused" | "errored", + reason: string, + createdAt?: number, +) { + return page.evaluate( + ({ + requestId: e, + disposition: d, + reason: r, + agent, + createdAt: at, + channelId, + requester, + }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: JSON.stringify({ disposition: d, reason: r }), + kind: 44300, + pubkey: agent, + createdAt: at, + extraTags: [ + // No ["h", …] here: the mock bridge adds the channel tag itself, + // and a second one makes the event structurally invalid. + ["e", e], + ["p", requester], + ["disposition", d], + ], + }); + }, + { + requestId, + disposition, + reason, + agent: AGENT_PUBKEY, + createdAt, + channelId: GENERAL_CHANNEL_ID, + requester: REQUESTER_PUBKEY, + }, + ); +} + +function requestRow(page: import("@playwright/test").Page, content: string) { + return page.getByTestId("message-row").filter({ hasText: content }).last(); +} + +test.describe("NIP-AD disposition screenshots", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test.beforeEach(async ({ page }) => { + await installMockBridge(page); + }); + + test("01 — completed badge on the request it answers", async ({ page }) => { + await openGeneral(page); + await emitRequest( + page, + REQ_COMPLETED, + "@agent summarize yesterday's decisions", + ); + await emitDisposition(page, REQ_COMPLETED, "completed", ""); + + const row = requestRow(page, "@agent summarize yesterday's decisions"); + await expect(row.getByText("Completed")).toBeVisible(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/01-completed-badge.png` }); + }); + + test("02 — refused badge, signed reason readable in the tooltip", async ({ + page, + }) => { + await openGeneral(page); + await emitRequest(page, REQ_REFUSED, "@agent delete all of Bob's messages"); + await emitDisposition( + page, + REQ_REFUSED, + "refused", + "outside my delegation — I have no moderation authority in this channel", + ); + + const badge = requestRow( + page, + "@agent delete all of Bob's messages", + ).getByText("Refused"); + await expect(badge).toBeVisible(); + await waitForAnimations(page); + await badge.hover(); + const tip = page.getByText("outside my delegation", { exact: false }); + await expect(tip).toBeVisible(); + await waitForAnimations(page); + // The tooltip is an overlay outside the row's own box, so this needs a + // page-level clip — derived from the badge's actual position rather than + // hardcoded, which silently captured the wrong region. + const box = await badge.boundingBox(); + if (!box) { + throw new Error("refused badge has no bounding box"); + } + await page.screenshot({ + path: `${SHOTS}/02-refused-tooltip.png`, + // The tooltip is ~320px wide and renders left of the badge, far enough + // that it overhangs the message pane — so the clip starts from the + // window edge rather than the pane's, or the reason text gets cut. + clip: { + x: Math.max(0, box.x - 360), + y: Math.max(0, box.y - 110), + width: 820, + height: 220, + }, + }); + }); + + test("03 — errored badge marks an open, repairable request", async ({ + page, + }) => { + await openGeneral(page); + await emitRequest( + page, + REQ_ERRORED, + "@agent read the deploy log and summarize", + ); + await emitDisposition( + page, + REQ_ERRORED, + "errored", + "turn ended with an error", + ); + + const row = requestRow(page, "@agent read the deploy log and summarize"); + await expect(row.getByText("Errored")).toBeVisible(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/03-errored-badge.png` }); + }); + + test("04 — contradictory dispositions render an explicit conflict", async ({ + page, + }) => { + await openGeneral(page); + await emitRequest( + page, + REQ_CONFLICT, + "@agent close out the release checklist", + ); + await emitDisposition(page, REQ_CONFLICT, "completed", "", 1_700_000_100); + await emitDisposition( + page, + REQ_CONFLICT, + "refused", + "changed course — this needs a human sign-off", + 1_700_000_200, + ); + + const row = requestRow(page, "@agent close out the release checklist"); + await expect(row.getByText("Disputed")).toBeVisible(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/04-conflict-badge.png` }); + }); + + test("05 — summary chip: every request resolved", async ({ page }) => { + await openGeneral(page); + await emitRequest( + page, + REQ_COMPLETED, + "@agent summarize yesterday's decisions", + ); + await emitDisposition(page, REQ_COMPLETED, "completed", ""); + await emitRequest(page, REQ_REFUSED, "@agent delete all of Bob's messages"); + await emitDisposition( + page, + REQ_REFUSED, + "refused", + "outside my delegation", + ); + + const chip = page.getByText(/2 visible requests · 2 settled/); + await expect(chip).toBeVisible(); + await waitForAnimations(page); + await chip.screenshot({ path: `${SHOTS}/05-summary-all-resolved.png` }); + }); + + test("06 — summary chip expanded: unanswered and needs-attention", async ({ + page, + }) => { + await openGeneral(page); + await emitRequest( + page, + REQ_COMPLETED, + "@agent summarize yesterday's decisions", + ); + await emitDisposition(page, REQ_COMPLETED, "completed", ""); + await emitRequest( + page, + REQ_ERRORED, + "@agent read the deploy log and summarize", + ); + await emitDisposition( + page, + REQ_ERRORED, + "errored", + "turn ended with an error", + ); + await emitRequest( + page, + REQ_UNANSWERED, + "@agent open a PR for the changelog", + ); + + const chip = page.getByText(/3 visible requests · 1 settled/); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page.getByText("No record")).toBeVisible(); + await waitForAnimations(page); + // Full-page clip: the popover is an overlay anchored below the chip. + await page.screenshot({ + path: `${SHOTS}/06-summary-expanded.png`, + clip: { x: 256, y: 40, width: 700, height: 420 }, + }); + }); +}); diff --git a/desktop/tests/e2e/agent-disposition.spec.ts b/desktop/tests/e2e/agent-disposition.spec.ts new file mode 100644 index 00000000000..96ebec07667 --- /dev/null +++ b/desktop/tests/e2e/agent-disposition.spec.ts @@ -0,0 +1,239 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +// NIP-AD disposition surface: badges on request messages and the channel +// accountability summary chip. See docs/nips/NIP-AD.md and +// desktop/src/features/messages/lib/{formatTimelineMessages, +// dispositionSummary}.ts. + +const REQUEST_ID_COMPLETED = "1".repeat(64); +const REQUEST_ID_REFUSED = "2".repeat(64); +const REQUEST_ID_UNANSWERED = "3".repeat(64); + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, + kind?: number, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ currentChannelName, kind: k }) => { + return ( + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + kind: k, + }) ?? false + ); + }, + { currentChannelName: channelName, kind }, + ); + }) + .toBe(true); +} + +async function openGeneral(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); +} + +// The agent every request below is addressed to, and that signs every +// disposition. Binding requires the request to name its disposition's +// signer as an `agent` target — see docs/nips/NIP-AD.md. +// Mock-bridge constants the binding now depends on: a disposition must +// carry the request's own channel and author to bind. +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const REQUESTER_PUBKEY = "deadbeef".repeat(8); +const AGENT_PUBKEY = "b".repeat(64); +// A human CC'd on requests but never an `agent` target. +const HUMAN_PUBKEY = "c".repeat(64); + +function emitRequest( + page: import("@playwright/test").Page, + id: string, + content: string, +) { + return page.evaluate( + ({ id: eventId, content: body, agent, human }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + id: eventId, + extraTags: [ + ["t", "request"], + ["agent", agent], + ["p", agent], + ["p", human], + ], + }); + }, + { id, content, agent: AGENT_PUBKEY, human: HUMAN_PUBKEY }, + ); +} + +function emitDisposition( + page: import("@playwright/test").Page, + requestId: string, + disposition: "completed" | "refused" | "errored", + reason: string, + signer: string = AGENT_PUBKEY, +) { + return page.evaluate( + ({ + requestId: e, + disposition: d, + reason: r, + signer: pubkey, + channelId, + requester, + }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: JSON.stringify({ disposition: d, reason: r }), + kind: 44300, + pubkey, + extraTags: [ + // No ["h", …] here: the mock bridge adds the channel tag itself, + // and a second one makes the event structurally invalid. + ["e", e], + ["p", requester], + ["disposition", d], + ], + }); + }, + { + requestId, + disposition, + reason, + signer, + channelId: GENERAL_CHANNEL_ID, + requester: REQUESTER_PUBKEY, + }, + ); +} + +function requestRow(page: import("@playwright/test").Page, content: string) { + return page.getByTestId("message-row").filter({ hasText: content }).last(); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("a completed disposition shows a completed badge on the request message", async ({ + page, +}) => { + await openGeneral(page); + await emitRequest(page, REQUEST_ID_COMPLETED, "@agent summarize yesterday"); + await emitDisposition(page, REQUEST_ID_COMPLETED, "completed", ""); + + const row = requestRow(page, "@agent summarize yesterday"); + await expect(row.getByText("Completed")).toBeVisible(); +}); + +test("a refused disposition's reason is readable from the badge without leaving the timeline", async ({ + page, +}) => { + await openGeneral(page); + await emitRequest(page, REQUEST_ID_REFUSED, "@agent delete everything"); + await emitDisposition( + page, + REQUEST_ID_REFUSED, + "refused", + "outside my delegation", + ); + + const row = requestRow(page, "@agent delete everything"); + const badge = row.getByText("Refused"); + await expect(badge).toBeVisible(); + + await waitForAnimations(page); + await badge.hover(); + await expect(page.getByText("outside my delegation")).toBeVisible(); +}); + +test("channel summary chip counts marked requests and flags the unanswered one", async ({ + page, +}) => { + await openGeneral(page); + await emitRequest(page, REQUEST_ID_COMPLETED, "@agent summarize yesterday"); + await emitDisposition(page, REQUEST_ID_COMPLETED, "completed", ""); + await emitRequest(page, REQUEST_ID_UNANSWERED, "@agent open a PR for this"); + + const chip = page.getByText(/2 visible requests · 1 settled/); + await expect(chip).toBeVisible(); + + await chip.click(); + await expect(page.getByText("No record")).toBeVisible(); + await expect( + page.getByRole("button", { name: "@agent open a PR for this" }), + ).toBeVisible(); +}); + +test("an ordinary acknowledgement without an agent mention is never counted as a request", async ({ + page, +}) => { + await openGeneral(page); + await page.evaluate(() => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "thanks!", + }); + }); + + await expect(page.getByText(/visible requests? ·/)).not.toBeVisible(); +}); + +test("a disposition signed by a merely-mentioned human does not resolve the request", async ({ + page, +}) => { + // The end-to-end form of the cross-principal binding check: HUMAN_PUBKEY + // is `p`-mentioned on the request but is not an `agent` target, so its + // signed "completed" must leave the request visibly unanswered rather + // than rendering a Completed badge. + await openGeneral(page); + await emitRequest(page, REQUEST_ID_COMPLETED, "@agent summarize yesterday"); + await emitDisposition( + page, + REQUEST_ID_COMPLETED, + "completed", + "", + HUMAN_PUBKEY, + ); + + const row = requestRow(page, "@agent summarize yesterday"); + await expect(row.getByText("Completed")).toHaveCount(0); + await expect(page.getByText(/1 visible request · 0 settled/)).toBeVisible(); +}); + +test("the summary occupies no layout in a channel with no marked requests", async ({ + page, +}) => { + // The chip returns null for an empty channel, but its layout wrapper was + // rendered unconditionally, so `pb-1.5` reserved 6px under the header of + // every channel with no marked requests — nearly all of them — and pushed + // the timeline's sticky day divider off its designed offset. Upstream's + // date-divider test caught that only incidentally, by measuring the offset. + // + // This asserts the invariant directly: nothing to show, no element. The slot + // and the chip are gated on one shared predicate (`hasVisibleRequests`) + // rather than two copies of `total === 0` free to drift apart. + await openGeneral(page); + await expect(page.getByTestId("disposition-summary-slot")).toHaveCount(0); + + // ...and it appears once there is something to summarize, so the assertion + // above cannot pass merely because the slot never renders at all. + await emitRequest(page, REQUEST_ID_COMPLETED, "@agent summarize yesterday"); + await expect(page.getByTestId("disposition-summary-slot")).toHaveCount(1); +}); diff --git a/docs/nips/NIP-AD.md b/docs/nips/NIP-AD.md new file mode 100644 index 00000000000..e28cbed5605 --- /dev/null +++ b/docs/nips/NIP-AD.md @@ -0,0 +1,778 @@ +NIP-AD +====== + +Agent Disposition +------------------ + +`draft` `optional` `relay` + +This NIP defines a durable, plaintext event kind for recording how an AI +agent resolved one human→agent request: `completed`, `refused`, `responded`, +or `errored`. An agent (or its harness) publishes one `kind:44300` event per +disposition, signed by the agent's own key, so any authorized reader of the +channel — not just the requester or the agent's owner — can verify that a +request was answered and read why it was refused, without parsing chat +bodies or trusting unverifiable prose. + +**One request, one obligation, one answerable agent.** A disposition is only +meaningful if "which obligation does this discharge, and who was obliged?" +has exactly one answer. This NIP therefore defines an *obligation* as the +unit of accounting — a marked request naming exactly one target agent — and +binds every disposition to the obligation's target by signing key. A marked +request that does not name exactly one reachable target is a malformed +request, reported as a protocol fault rather than as an agent's unanswered +gap. + +**Scope of "readable."** Kind 44300 is not specially gated: it carries no +owner-only, author-only, or p-gated restriction, and is plaintext rather +than encrypted (the deliberate inverse of [NIP-AM](NIP-AM.md)). But reads +still inherit ordinary channel access like every other channel-scoped kind. +An unauthenticated process on the open internet cannot audit a private +channel's dispositions; an external auditor must authenticate as a member of +that channel (or the channel must be one it can already read). "Verifiable +by anyone who can see the conversation" is the accurate claim. + +## Motivation + +Buzz agents answer requests inline, as ordinary chat messages. That answer is +never distinguishable from chat at the protocol layer: a reader must parse +every message body and guess which ones are dispositions, there is no kind +filter and no relay-enforced shape, and a plain "thanks!" is indistinguishable +from an unanswered request. No third party can currently verify that an agent +answered every request directed at it, or recover a refusal's stated reason +without reading transcript prose. + +[NIP-AM](NIP-AM.md) (kind 44200) already gives owners durable, harness-independent +accounting for *token usage* — but it is deliberately the opposite of this NIP +in every property that matters here: encrypted rather than plaintext, and +owner-gated rather than readable by the whole channel, because token cost is +private and a disposition's entire purpose is the reverse — verifiability by +anyone who can see the conversation. +Kind 44300 stores only the disposition: which request it answers, what +happened, and (for `refused`) why. + +## Definitions + +- **Agent**: an AI process with its own Nostr keypair, participating in a + Buzz channel. +- **Requester**: the principal (human or agent) whose message triggered the + agent's turn. +- **Request**: a channel message carrying `["t","request"]`, addressing an + agent and asking it to act. +- **Obligation**: a *valid* request — one naming exactly one target agent + that is also `p`-mentioned — together with the identity of that agent. + The obligation, not the raw request, is the unit this NIP accounts for. + A marked request that is not a valid obligation is an **invalid request** + (see "Request validity"), which is a fault of the emitting client, not an + unmet duty of any agent. +- **Disposition**: a single kind 44300 event recording how the agent resolved + one request — `completed`, `refused`, `responded`, or `errored`. +- **Binding**: the consumer-side check that a disposition actually + discharges a given obligation. An unbound disposition contributes nothing + to that obligation's state. +- **Unsupported request**: a *well-formed* marked request expressing an + intent v1 cannot represent — currently only a request naming several + agents. Distinct from an invalid one: an invalid request is a bug to fix, + an unsupported one is a feature to build. Both block a clean claim; only + one implies anybody did anything wrong. + +## Event + +`kind:44300` is a regular event by Buzz convention (alongside +44100/44101/44200): stored and never replaced. It is the deliberate inverse +of kind 44200 in visibility: plaintext content, no encryption, readable by +any authorized reader of its channel rather than only the agent's owner — a +disposition that could be silently overwritten would defeat the point of a +verifiable record. + +**"Append-only" is a property of the kind, not a retention guarantee.** +Being a regular (non-replaceable) event means no later event can overwrite +a stored disposition in place. It does not mean history is permanent: +[NIP-09](09.md) deletion applies as normal, and relay retention policy can +make older events unavailable. A verifier that needs to prove nothing was +removed needs deletion tombstones or Buzz's hash-chain audit log — neither +is part of this NIP's query contract. Read a clean disposition history as +"nothing contradicts this," not "nothing was ever removed." + +```json +{ + "kind": 44300, + "pubkey": "", + "created_at": 0, + "content": "{\"disposition\":\"refused\",\"reason\":\"outside my delegation\"}", + "tags": [ + ["e", ""], + ["h", ""], + ["p", ""], + ["disposition", "refused"] + ], + "sig": "..." +} +``` + +Events MUST have exactly one `e` tag (the request event id), exactly one `h` +tag (the channel UUID), exactly one `p` tag (the requesting principal), and +exactly one `disposition` tag whose value is exactly one of `completed`, +`refused`, `responded`, `errored`. The `disposition` tag is lifted out of `content` onto +the tag list specifically so a reader can determine state directly from tags +— without parsing any event body — for every event already fetched by +another filter. It is NOT itself a server-side query filter: see "Not a +query filter" below. + +### v1 scope: channel-scoped requests only + +The `h` tag is REQUIRED, not optional. Buzz's channel-scoping model is a +strict binary: a kind is either global-only (no `h` tag; channel identity, if +any, stays out of tags entirely) or channel-required (`h` mandatory). Direct +messages already use a distinct kind family (`KIND_DM_OPEN` and friends) +rather than an optional `h` tag on a shared kind, so there is no existing +"conditionally channel-scoped" precedent for this NIP to extend. Dispositions +for DM-scoped requests are out of scope for this version; a future revision +may add a distinct mechanism if real usage demands it. + +Exactly one `h` tag is enforced by kind 44300's own ingest validation, not +merely by the shared channel-scope check. That shared check only asks "is +there a usable `h` tag?" and silently takes the first — so a second, +different `h` would scope storage and authorization to one channel while +the other still rode along on the stored event for any generic tag +matching. For a kind whose entire purpose is unambiguous attribution, that +cross-channel ambiguity is worth rejecting outright, even though it makes +44300 stricter than other channel-scoped kinds. + +### Not a query filter: `#disposition` + +`#e` and `#h` are ordinary NIP-01 single-letter tag filters and work exactly +as expected in REQ subscriptions and the HTTP query bridge. `disposition` is +NOT: NIP-01's `#` filter grammar is defined only for single-character +tag names, and the `nostr` crate Buzz builds on types `Filter.generic_tags` +as a map keyed by exactly one character — its deserializer silently drops +any `"#"` key that isn't a single character rather than rejecting it. +A client that sends `{"kinds":[44300],"#h":[...],"#disposition":["refused"]}` +expecting the relay to filter by state gets back **every** disposition in +the channel with no error — a silent over-return, not a filtered result. +Consumers MUST treat `disposition` as a tag to read after fetching (state is +always visible on every returned event) and MUST NOT send `#disposition` as +a filter key expecting server-side narrowing. + +## Content + +`content` is a plaintext (never encrypted) UTF-8 JSON object: + +```jsonc +{ + "disposition": "refused", // REQUIRED: must equal the `disposition` tag + "reason": "outside my delegation", // REQUIRED string; MAY be empty for any state + "request_id": "" // OPTIONAL; if present, MUST equal the `e` tag +} +``` + +`disposition` and `reason` are REQUIRED. `reason` MUST be present as a +string, but MAY be an empty string for ANY disposition state, not only +`completed` — the relay's schema guard accepts `""` uniformly across all +four states. In practice a meaningful `reason` is expected for `refused` +(a refusal record with no stated reason defeats much of its own purpose), +but this is a convention for emitters to follow, not a protocol-enforced +requirement. Omitting the `reason` field entirely is invalid, not +equivalent to an empty string. `request_id`, when present, is a redundant +self-check: consumers MUST reject a disposition whose `content.request_id` +disagrees with its own `e` tag, since agreement is exactly the invariant a +verifier depends on. + +Consumers MUST ignore unknown fields (forward compatibility). + +## Lifecycle + +The four states divide into **terminal** and **non-terminal**: + +| State | Terminal? | Means | +|---|---|---| +| `completed` | yes | The agent asserts it did what was asked. | +| `refused` | yes | The agent declined, and says why. | +| `responded` | **no** | The agent produced an answer but does not claim the request is settled. | +| `errored` | **no** | The turn failed; the request may still be retried and resolved. | + +An obligation is **resolved** only in a terminal state. `responded` exists +because most agent turns genuinely end this way: the agent replied, and +whether that reply actually satisfied the request is not something the agent +can honestly assert on its own behalf. A harness that mapped every +successful turn to `completed` would be publishing signed claims of success +it has no basis for — the ledger's value depends on `completed` meaning +something, so an emitter MUST NOT emit `completed` merely because a turn +ended without error. Emitting `responded` is the honest default; `completed` +is reserved for cases where completion is actually established (for example, +a requester or a workflow confirms it). + +**Terminal claims are absorbing, and an obligation's outcome is distinct +from its latest observation.** Consumers MUST derive two separate things: + +- **Effective outcome** — `unanswered`, `open(state)`, `settled(state)`, or + `disputed`. This is the only basis for any display or accounting decision. +- **Latest observation** — the last bound state by `(created_at, id)`, + retained for audit. It answers "what arrived last", which is a different + question from "what is true", and MUST NOT be used to decide whether an + obligation is done. + +The rules, in order: + +1. Both terminal states bound → `disputed`. There is no settled answer. +2. Otherwise, a terminal claim → `settled` with that state, **regardless of + anything ordered after it**. +3. Otherwise, anything bound → `open` with the latest state. +4. Otherwise → `unanswered`. + +Rule 2 is what makes "terminal" mean terminal. Deriving state as "latest +wins" — as an earlier version of this NIP did — let a late `errored` silently +reopen a settled obligation, which contradicted this document's own claim +that `completed` and `refused` are terminal. + +Unbound dispositions are not part of the history at all (see "Target-agent +binding"). + +### Known limitation: terminal claims cannot be corrected in v1 + +Absorption makes `completed` and `refused` **irrevocable**, not merely +"not reopened by a weaker observation." Nothing in v1 can express "the earlier +claim was wrong; this is the corrected result." Three consequences, stated +plainly because a reader will otherwise discover them the hard way: + +- **Premature completion.** An agent emits `completed`, then finds the + operation rolled back or never committed. A later `errored` is absorbed; the + obligation stays settled as completed. This bites hardest precisely because + a disposition is self-reported (see Security Considerations) — the protocol + already admits terminal claims may be mistaken, and then gives no way to fix + one. +- **A wrong or missing refusal reason.** Re-emitting `refused` with better + text is only a `duplicate_terminal` warning; the settled reason comes from + whichever terminal sorts first. And because `(created_at, id)` is + publisher-supplied rather than causal, a later-published correction can even + be backdated into that position — which is a way to *replace* a reason, not + a way to correct one honestly. +- **Switching between terminals.** The only expression available is the + opposing state, which produces a permanent `disputed` outcome rather than a + correction. + +[NIP-09](09.md) deletion is not an adequate substitute: this NIP already +states that deletion and retention can make history unavailable and that the +query contract carries no tombstone-completeness guarantee. + +**Why v1 ships without a correction protocol.** A correction relation — for +example a `["correction", ]` tag, same-signer, same-obligation, +acyclic, with only unsuperseded terminal leaves determining the outcome — is a +genuine protocol extension with its own authority and ordering questions. It +is the right shape for v2, and inventing it inside a fix pass is how the +earlier defects in this document got here. v1 therefore states the limitation +and makes terminal claims expensive to emit: the agent-facing instruction says +`completed` and `refused` are final, and only the target agent can emit either. + +Consumers MUST NOT present a settled obligation as correctable, and clients +SHOULD make emitting a terminal state a deliberate act rather than a default. + +**Warnings are diagnostic and never change an outcome.** Two exist: + +| Warning | History | Meaning | +|---|---|---| +| `duplicate_terminal` | the **same** terminal state bound more than once | Redundant, usually a retry that re-published. The outcome stands. | +| `ordered_after_terminal` | a **non-terminal** disposition sorts after a terminal one | A stale or late weak observation. The settled result stands. | + +They are deliberately non-overlapping: a terminal after a terminal is already +fully described by `duplicate_terminal` (same state) or by a `disputed` +outcome (opposing states). + +`ordered_after_terminal` is named for what it can actually establish. The +ordering is deterministic but publisher-supplied, so no consumer can show +that anything was *written after* settlement — only that it sorts later. An +earlier name (`post_terminal_write`) asserted a causal fact the algorithm +cannot observe; a real causal claim needs a trusted receive sequence or an +attempt ordinal. + +An earlier version made every warning force the obligation out of its settled +state and render as "disputed", which defeated the point of categorizing +them: a duplicate delivery and a genuine contradiction produced identical, +equally destructive results. + +**Same-second ties.** `created_at` is whole-second Nostr precision. Two +dispositions for the same request published within the same second are +tied — "latest" is then undefined by timestamp alone. Consumers MUST break +such a tie by event `id` (lexicographically greatest), so current-state +derivation is a deterministic function of the stored events rather than of +arrival or query-result order. This is the same class of ambiguity +[NIP-AM](NIP-AM.md) resolves with an explicit `(sessionId, turnSeq)` +ordinal; that mechanism is disproportionate here — a real turn retry takes +materially longer than one second, so the tie case is a synthetic/adversarial +edge rather than a normal-operation one, and a tiebreaker is enough to make +behavior well-defined without new required fields. + +The complete transition table. **This table is generated** from the same +declarative rules that produce the conformance corpus +(`scripts/gen-nip-ad-corpus.mjs`), and CI fails if it drifts. An earlier +hand-written version claimed a terminal history carrying a warning was +resolved while every implementation said the opposite, and nothing caught +it — the corpus pinned the two implementations to each other, never to this +document. + +Rows cover every distinct (outcome, warning-set) pair the rules can produce. + + +| History (ordered) | Effective outcome | Resolved? | Warnings | +|---|---|---|---| +| (nothing bound) | unanswered | no | — | +| completed | **settled** (`completed`) | yes | — | +| refused | **settled** (`refused`) | yes | — | +| responded | open (`responded`) | no | — | +| errored | open (`errored`) | no | — | +| completed → completed | **settled** (`completed`) | yes | `duplicate_terminal` | +| completed → refused | **disputed** | no | — | +| completed → responded | **settled** (`completed`) | yes | `ordered_after_terminal` | +| refused → refused | **settled** (`refused`) | yes | `duplicate_terminal` | +| refused → responded | **settled** (`refused`) | yes | `ordered_after_terminal` | +| completed → completed → refused | **disputed** | no | `duplicate_terminal` | +| completed → completed → responded | **settled** (`completed`) | yes | `duplicate_terminal`, `ordered_after_terminal` | +| completed → refused → responded | **disputed** | no | `ordered_after_terminal` | +| refused → refused → responded | **settled** (`refused`) | yes | `duplicate_terminal`, `ordered_after_terminal` | + + +- **errored → completed** and **responded → completed** are normal repair: + the one class of legal post-hoc transition, legal precisely because + `errored` and `responded` are non-terminal. Readers report the obligation as + settled, not as ever having been a gap. +- Repeated `errored` or `responded` with no eventual terminal state is an + open, unsettled obligation — visible as such, not a warning. +- A warning never changes an outcome. A duplicated `completed` is still + settled; only two *opposing* terminal claims produce `disputed`. + +Note what this ordering does and does not give you. `(created_at, id)` makes +current-state derivation a deterministic function of the stored events, but +it is not *causal* ordering — event ids carry no turn semantics, and a +disposition published later can carry an earlier `created_at`. A protocol +needing true attempt ordering would need an explicit ordinal (as +[NIP-AM](NIP-AM.md) uses `(sessionId, turnSeq)`); this NIP deliberately does +not. Terminal absorption is what keeps the weaker ordering from silently +producing a wrong settled answer: no ordering accident can unsettle an +obligation, because nothing ordered after a terminal claim replaces it. + +## Relay Behavior + +On receiving a kind 44300 event, a relay MUST: + +1. Validate the event signature per NIP-01. +2. Validate the tag envelope: exactly one each of `e`, `h`, `p`, and + `disposition`; `e` and `p` are 64 lowercase hex characters; `disposition` + is one of the four valid values. +3. Validate `content`: valid JSON object; `disposition` field present and + equal to the tag; `reason` field present and a string; if `request_id` is + present, it equals the `e` tag. +4. Reject the event (do not store it) if any of the above fail. +5. Enforce the same channel-membership/access check applied to other + channel-scoped writes (the publisher must have write access to the `h` + channel). +6. Store the event durably, scoped to the channel, with the `disposition` + tag preserved on every read so consumers can determine state without + parsing `content` (see "Not a query filter" above — this is a per-event + tag read, not a server-side filter). +7. Never gate reads beyond ordinary channel membership — kind 44300 is + deliberately absent from every read-gating list (result-gated, author-only, + p-gated). Any member of the channel — not only the requester or the + agent's owner — can read it. + +**Structural validity is a single shared contract, and consumers MUST apply +exactly the same one.** Every rule in "Event" and "Content" above is checked +by one validator that relay ingest itself calls; no event may contribute to +any obligation without passing it. This is normative because the alternative +already happened: the relay enforced kind, exact tag cardinality, and a +required `reason` while consumer-side binding checked none of them — it read +the first matching tag and ignored the rest — so an event with two `e` tags, +or no `reason`, or not even of kind 44300 was rejected at ingest and +simultaneously counted as a settling disposition by an auditor. A consumer +that verifies signatures but not structure is not protected: a valid +signature can cover a perfectly well-signed non-disposition. + +The relay does NOT verify that the referenced `e` tag actually names a real +request that mentioned this agent, and does NOT verify that the `p` tag +matches the request event's actual author. Those checks are left to +consumers (see Security Considerations) — the relay's job is structural +validity and storage, not judging whether a disposition is honest. + +## Client Behavior + +Any client recovers a channel's dispositions with: + +```json +{"kinds": [44300], "#h": [""]} +``` + +or narrow to one request's history with `#e` (both valid server-side +filters). To isolate one state (e.g. only refusals), fetch the `#h`- or +`#e`-scoped set and filter locally on each event's `disposition` tag — do +not send `#disposition` as a filter key (see "Not a query filter" above). +Clients pair a request to its disposition(s) by matching the disposition's +`e` tag to the request's own event id, and derive current state as described +in Lifecycle. + +### Target-agent binding + +The relay validates a disposition's tag *shape* (Relay Behavior above) but +does NOT verify that its signer was actually asked — that check is left +entirely to consumers (see Security Considerations). Without it, any +channel member with ordinary write access can publish an honestly-signed +kind:44300 event naming someone else's request by `e` tag, and a consumer +that groups purely by `e` tag would render it as that request's +resolution — a real cross-principal spoof, not merely the "an agent can lie +about its own work" limitation the rest of this NIP already accepts. + +**Requests name their target.** A marked request carries exactly one +`["agent", ]` tag naming the agent it is addressed to, alongside its +`["t","request"]` marker, and `p`-mentions that same agent: + +```json +{ + "kind": 9, + "content": "@bugbot triage this crash", + "tags": [ + ["h", ""], + ["t", "request"], + ["agent", ""], + ["p", ""], + ["p", ""] + ] +} +``` + +Marker and target come from the same value at the composer, so a marked +request can never lack a target and a targeted message is never unmarked. + +### Request validity + +A marked request is a valid **obligation** only if all of the following +hold. A request failing any of them is an **invalid request** with the +stated reason: + +Classification is **total**: every event lands in exactly one of +`NotRequest`, `Invalid(reason)`, `Unsupported(reason)`, or `Valid(obligation)`. +Consumers MUST NOT pre-check the marker themselves and classify second — an +earlier version required that, and Buzz's own agent harness grew a looser +predicate instead, acting on requests every reader called invalid. + +**Invalid** (malformed — a client bug): + +| Reason | Condition | +|---|---| +| `UnsupportedKind` | The event's kind is not in the v1 request-kind set. | +| `MissingChannel` | No `h` tag. | +| `MultipleChannels` | More than one `h` tag, so the scope is ambiguous. | +| `MissingAgentTarget` | No `agent` tag — nothing was addressed for action. | +| `DuplicateAgentTarget` | The same `agent` target repeated. | +| `MalformedAgentTarget` | The `agent` value is not 64 lowercase hex characters. | +| `TargetNotMentioned` | The target is not also `p`-mentioned. | + +**Unsupported** (well-formed, unrepresentable in v1): + +| Reason | Condition | +|---|---| +| `MultipleAgentTargets` | Two or more *distinct* `agent` tags, **each of them canonical and `p`-mentioned**. | + +**Precedence: malformed beats unsupported.** Every target is validated for +canonical form and `p`-mention *before* cardinality is judged. A request +naming one real agent and one garbage value is a malformed event, not a +feature this version cannot represent — classifying it as unsupported would +file a client bug under "nobody's fault" and tell the sender their perfectly +reasonable request needs a future protocol version. Where several faults +coexist, the first in the table order above is reported. + +`TargetNotMentioned` matters because `p` is what actually routes a message +to a principal: an `agent` tag without the matching `p` names a target that +will never be asked, creating an obligation nobody could ever discharge. + +`MultipleChannels` mirrors the same rule kind 44300 applies to itself: one +channel would govern authorization while the other still rode along for +generic tag matching. + +`DuplicateAgentTarget` is rejected rather than folded into one target. This +document promises exactly one `agent` tag, and an implementation that +silently accepted two made that promise false — canonical events keep +independent implementations and audit output simple. + +**The v1 request-kind set.** A marker is only meaningful on kinds this +version accepts requests on (currently kind 9 alone). Without a fixed set, +two consumers querying different kinds produce different accounting for the +same channel and both look correct — the CLI queried only kind 9 while the +desktop timeline carried several message and job kinds. Every query builder +and every classifier MUST derive its candidate universe from the same set. + +**Invalid requests are protocol faults, not agent gaps.** Consumers MUST +report them in a separate class from unanswered obligations, and MUST NOT +count them against any agent. Filing a malformed request as "the agent +failed to answer" blames an agent for a client bug and manufactures a gap +that no agent could ever close — the accounting would never come clean, and +the one number a reader cares about would be permanently wrong. + +**Why exactly one target in v1.** A request naming two agents has no single +answer to "who was obliged?" Two natural readings — either target may +discharge it, or both must — cannot be distinguished from the event alone, +and an implementation that picks one silently gets the other case wrong. An +earlier draft of this NIP gave such a request one request-wide state, which +produced a concrete defect: two agents each correctly reporting `completed` +were read as contradictory terminal claims, so *two correct answers* +rendered as a conflict. Rather than encode a guess, v1 classifies +multi-target requests as invalid. A future revision may key obligations +`(request_id, agent_pubkey)` to support them properly. + +**The binding rule.** Consumers MUST NOT treat a disposition as discharging +an obligation unless all of the following hold: + +- the request is a valid obligation (table above); +- the disposition's `e` tag equals the request's `id`; +- the disposition's `h` tag equals the request's channel; +- the disposition's `p` tag equals the request's author; +- the disposition's `pubkey` equals the obligation's target agent. + +A disposition failing any of these MUST be excluded from that obligation's +derived state entirely — not rendered as a distinguishable "foreign" or +"unverified" state, simply treated as if it did not exist, so an unbound +disposition can never make an unanswered obligation look answered. + +Binding is against the `agent` tag, deliberately **not** `p` tags. The +target is also `p`-mentioned, but `p` additionally contains humans CC'd on +the message; binding to the mention set would let any of them close the +agent's obligation with a signed `completed`. That is a cross-principal +spoof, categorically worse than the "an agent can lie about its own work" +limitation this NIP already accepts — an agent vouching for itself is at +least the party that was asked. + +### Gap detection + +A disposition alone cannot tell a reader whether *every* request got +answered — only whether the requests it already knows about did. To make gap +detection exact rather than inferred, requests self-identify with +`["t", "request"]` plus their `agent` targets (set by the composer when a +message mentions an agent). A verifier then computes: + +- requests = `{"kinds": [], "#h": [""], "#t": ["request"]}` +- answers = `{"kinds": [44300], "#h": [""]}`, paired by `e` tag + **and filtered by the binding rule above** — a disposition whose signer + isn't one of its request's `agent` targets does not close the gap + +and sorts every marked request into exactly one of six buckets: + +| Bucket | Meaning | +|---|---| +| `settled` | Valid obligation with a bound terminal claim. | +| `open` | Valid obligation, answered non-terminally (`responded`/`errored`). | +| `unanswered` | Valid obligation with no bound disposition at all. | +| `disputed` | Valid obligation with both terminal states bound. | +| `invalid_requests` | Malformed marked request, with its reason. | +| `unsupported_requests` | Well-formed marked request v1 cannot represent, with its reason. | + +Consumers SHOULD additionally report `rejected_claims`: stored dispositions +that named an obligation but did not bind. They contribute nothing to any +outcome — that is the whole point of binding — but hiding them entirely +denies an auditor sight of spoof attempts. + +Without the marker, gap detection could only fall back to noisy inference +(e.g. "any message that mentions an agent"), which miscounts plain +acknowledgments as unanswered requests. Without the binding filter, anyone +with channel write access could close a gap they were never asked about. +Without the `invalid_requests` bucket, client bugs would be laundered into +agent failures. + +**Coverage must be explicit, and it has two sides.** "Zero unanswered +requests" is bounded by the queries that produced it. Neither this NIP nor +the relay provides a completeness token, so a consumer MUST carry a coverage +record alongside its accounting: + +| Field | True only when | +|---|---| +| `requests_complete` | Every marked request in scope was fetched. | +| `dispositions_complete` | Every disposition for those requests was fetched. | + +**Both are required.** A single flag over the request set is not enough: a +consumer could paginate every request, fetch one page of dispositions, see a +`completed`, miss the later `refused` on page two, and truthfully set a +one-sided flag while reporting a disputed obligation as settled. + +A consumer may report "everything is settled" only when both coverage bits +are true **and** `open`, `unanswered`, `disputed`, `invalid_requests`, and +`unsupported_requests` are all empty. Omitting the last two from that +conjunction is the subtle error: a channel whose only problem is a malformed +request would otherwise claim a clean bill of health while carrying a +request no agent can ever discharge. + +Combined with the retention caveat under "Event", the honest reading of a +clean result is "nothing unanswered among the requests I could see," not +"nothing unanswered ever happened here." + +### Conformance + +The classification, binding, and lifecycle rules above are normative and +executable: `docs/nips/nip-ad-conformance.json` holds the shared case +corpus, and both Buzz implementations (Rust in `buzz-core`, TypeScript in +the desktop client) run it as a test. A change that makes one implementation +disagree with this document fails that suite rather than drifting silently. +Independent implementations are encouraged to run the same corpus. + +## Relationship to Other NIPs + +- **The 43xxx agent job protocol** (`KIND_JOB_REQUEST` 43001 through + `KIND_JOB_ERROR` 43006, defined in `buzz-core/src/kind.rs`) is a distinct, + explicit contract that a caller opts into for one-shot structured jobs; as + of this writing it has no publishers or consumers in the Buzz tree and is + undocumented in `docs/nips/`. This NIP does not use it and does not require + it. A disposition annotates an *ordinary conversational request* — the + request itself stays whatever kind it already was (typically a channel + message); kind 44300 is layered on top, not a replacement for the job + protocol's request/result exchange. A future NIP may formalize 43xxx and + its own relationship to dispositions; until then, treat them as unrelated. +- [NIP-AM](NIP-AM.md): the closest sibling kind by number and by "one event + per agent action" shape, but opposite on every visibility property — + encrypted vs. plaintext, owner-gated vs. channel-readable, usage accounting + vs. accountability record. +- [NIP-09](09.md): deletion semantics apply as normal; an agent or relay + policy may request removal of a disposition it published. + +## Migration from kind:9 dispositions + +Before this NIP, tooling recorded dispositions as ad hoc JSON bodies inside +ordinary `kind:9` chat messages — functional, but unfilterable and +unenforced. Existing kind:9 dispositions remain valid history and are NOT +retroactively converted, and this implementation does NOT read them: v1 +ships a **relay-first** rollout contract, not a dual-write/fallback one. + +- A relay MUST enumerate kind 44300 (accept and serve it) before any + emitter (harness or CLI) is enabled against it. A relay that does not yet + enumerate 44300 rejects it at ingest with `restricted: unknown event + kind` (the relay's default-deny for unlisted kinds); emitters do not + detect this and fall back to kind:9 — they simply fail to publish until + the relay is upgraded. Operators MUST upgrade the relay before enabling + NIP-AD emission. +- `buzz dispositions list` and the desktop accountability surface read only + kind 44300 — they do NOT union in kind:9 JSON-body history. A channel + with dispositions recorded before this NIP shipped will show that older + history as missing from these views, not merged in. +- A compatible dual-write/union-read migration (emit both kinds, union-read + both, define precedence when they disagree) is a real, larger protocol + change, and is deliberately deferred rather than half-implemented: an + earlier draft of this section described a union-read migration that was + never actually built, which an external design review correctly flagged + as a documentation/implementation mismatch. If live upstream relay-upgrade + lag becomes a real operational problem, implement the dual-write contract + properly as a follow-up rather than reintroducing a partial one here. + +## Security Considerations + +**Self-reported, not independently verified.** A disposition is signed by the +agent that emitted it, which proves *an answer with this content was +authored by this key* — it does not prove the judgment was correct, that a +`completed` disposition reflects real completed work, or that a `refused` +reason is the agent's true reason. A compromised or dishonest agent can +publish a false disposition exactly as easily as it could post a false chat +reply. Stronger guarantees require an independent reviewer or an +orchestration layer that checks the agent's actual output, not just its +disposition claim. + +**A harness cannot honestly emit `completed`, and Buzz's does not.** An ACP +harness observes only that a turn ended without technical failure (an +`EndTurn` stop reason). That covers an agent asking a clarifying question, +reporting it couldn't finish, or answering only one message of a batched +turn, exactly as readily as it covers a task genuinely done — so mapping +`EndTurn` to `completed` would publish a signed success claim with nothing +behind it, in a ledger whose whole value is that `completed` means +something. Buzz's harness therefore emits `responded` for a clean turn, +`refused` only for an explicit ACP `Refusal`, and `errored` for failure, +cancellation, and timeout. It never emits `completed`. + +**`completed` is the target agent's own signed assertion — nobody else's.** +The binding rule requires a disposition's signer to be the obligation's +target, so a requester cannot publish a bound confirmation and neither can an +independent workflow. v1 therefore defines `completed` as exactly one thing: +the agent that was asked, asserting it did the work. Third-party +confirmation ("the requester agrees this is done") is a genuinely different +claim with different authority, and would need its own event kind and its own +binding rules; an earlier draft of this section blurred the two, describing a +confirmation path that no rule here permits. + +In practice this means a managed agent reaches `completed` by publishing one +itself — in Buzz, `buzz dispositions emit --request --disposition completed`, +which refuses to sign unless the running identity is the obligation's target. +A turn that ends cleanly and settles nothing leaves an `open` obligation, +which is the honest record of what happened. + +**A batched turn cannot attribute any per-obligation outcome.** When one turn +carries several obligations, nothing it observes can say which obligation a +statement applies to, so Buzz's harness emits nothing at all for such turns. + +An earlier version carved out `errored`, reasoning that a failed turn means no +obligation in the batch received an answer. That holds only if a turn's output +is atomic — if the agent fully answers A and then B's tool call fails, +`errored` on A is exactly the overclaim already removed from `responded`, one +state further down. No such atomicity guarantee is established, and asserting +an unverified premise is how the `responded` projection arrived in the first +place. Those obligations remain `unanswered`, which is the honest record: +nothing observed knows what became of them. The agent settles them itself. + +**A refusal MUST carry a reason where one exists.** `reason` may be empty +for any state (see Content), but a refusal that explains nothing defeats +much of the point of recording it — this NIP's headline promise is that a +reader can recover *why*. The ACP runtime's own `Refusal` stop reason +carries no text, so Buzz's harness records the stable marker +`acp-runtime-refusal` rather than an empty string: it says exactly as much +as the harness knows — the runtime refused and did not say why — instead of +producing a refusal that looks unexplained by choice. + +**No binding between `p` tag and request authorship.** The relay validates +that `p` is a well-formed pubkey, not that it matches the actual author of +the `e`-tagged request. A verifier that needs this guarantee MUST fetch the +request event itself and compare authors — this NIP does not do it for you. + +**Verify signatures before auditing, or say that you did not.** A consumer +that applies every rule in this document to events it has not +cryptographically verified is a semantic verifier trusting its relay, not an +independent auditor — and the distinction only shows up in the case the +audit exists for. Buzz's `buzz dispositions list` verifies each event's id +and signature before adaptation and reports the number it rejected. A +consumer that cannot or does not verify MUST state that trust boundary +rather than presenting relay-supplied events as audited. + +**Accounting is a public-input computation.** Any channel member can store +structurally valid but unbound dispositions, so a consumer that re-scans +every disposition for every request gives a cheap writer a quadratic cost. +Group dispositions by `e` tag once; accounting should be O(requests + +dispositions). + +**Cross-principal spoofing is possible at the relay layer; consumers MUST +bind to the target agent.** The relay does not verify that a disposition's +signer was actually addressed by the request it references (Relay Behavior +above) — any channel member with ordinary write access can publish an +honestly-signed kind:44300 event naming someone else's request by `e` tag. +See "Target-agent binding" under Client Behavior for the required +consumer-side check (the disposition's `pubkey` must equal the obligation's +single `agent` target — NOT merely a `p` mention, which would let any CC'd +human close the agent's obligation). A reader that skips this check is not +merely missing a nice-to-have — it can be made to display a request as +resolved when it was never even addressed to whoever signed the +disposition. Every first-party consumer in this tree (`buzz-cli`, the +desktop app) implements this check against one shared semantic contract with +two tested implementations — a Rust verifier in `buzz-core` and a TypeScript +mirror, pinned to each other and to this document by the conformance corpora. +That is deliberately a weaker claim than "one verifier": they are two +implementations, and only the cases in the corpora are proven to agree. A +third-party reader (e.g. an external auditor) MUST implement the check too, +or its accountability claims are not trustworthy. + +**The binding check is authorization to *count* a disposition, not proof of +honesty.** It establishes that the signer is the agent that was asked. It +does not establish that the signer did the work, or that its `completed` is +truthful. Those remain the self-reporting limits described above. + +**Metadata leakage is the point.** Unlike NIP-AM, there is no metadata to +leak here beyond what is already implied by the conversation: dispositions +are plaintext and readable by the channel by design, so this is a feature, +not a consideration to mitigate. Note this is channel-scoped, not public: +the conversation's existing audience, no wider. + +**Availability, not correctness, is what a gap check proves.** "Zero +unanswered requests" (see Gap detection) proves every valid obligation +received a bound signed response — it does not prove the responses were +good, honest, or complete. Treat it as a liveness/accountability signal, +not a quality signal. Note also that a `responded`-only channel has zero +*unanswered* obligations while having zero *resolved* ones: "answered" and +"settled" are different questions, and this NIP deliberately keeps them +apart rather than letting a reply pass for a result. diff --git a/docs/nips/nip-ad-conformance.json b/docs/nips/nip-ad-conformance.json new file mode 100644 index 00000000000..c1cc98f97b1 --- /dev/null +++ b/docs/nips/nip-ad-conformance.json @@ -0,0 +1,1581 @@ +{ + "$comment": [ + "NIP-AD cross-language conformance corpus (hand-written, expressive cases).", + "", + "Both the Rust verifier (crates/buzz-core/src/disposition.rs) and the", + "TypeScript mirror (desktop/src/shared/lib/disposition.ts) run every case", + "here. They previously drifted -- different candidate sets, different", + "binding rules, different conflict semantics -- and the drift was invisible", + "because each side tested itself with its own fixtures.", + "", + "This file pins the two IMPLEMENTATIONS to each other. It does not pin", + "either to the spec: a hand-written transition table in NIP-AD.md once", + "contradicted both implementations and every test still passed. The", + "normative table is now generated (scripts/gen-nip-ad-corpus.mjs), and the", + "exhaustive lifecycle enumeration lives in nip-ad-lifecycle-exhaustive.json", + "with expectations computed from declarative rules rather than from either", + "implementation.", + "", + "`requestKinds` is normative here, not read from either implementation: both runners previously defaulted the event kind to their OWN REQUEST_KINDS[0], so a Rust/TS divergence on the request-kind set would pass both suites.", + "Adding a case here obliges both implementations. That is the point." + ], + "constants": { + "agent": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "otherAgent": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "human": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "requester": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "channel": "36411e44-0e2d-4cfe-bd6e-567eb169db9f", + "otherChannel": "99999999-0000-0000-0000-000000000000", + "requestId": "1111111111111111111111111111111111111111111111111111111111111111", + "otherRequestId": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "requestClassification": [ + { + "name": "canonical single-target request is a valid obligation", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$agent" + ], + [ + "p", + "$human" + ] + ], + "expect": { + "kind": "valid", + "targetAgent": "$agent" + } + }, + { + "name": "no agent target is invalid, never an unanswered obligation", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "p", + "$human" + ] + ], + "expect": { + "kind": "invalid", + "reason": "missing_agent_target" + } + }, + { + "name": "two distinct targets are unsupported in v1", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "agent", + "$otherAgent" + ], + [ + "p", + "$agent" + ], + [ + "p", + "$otherAgent" + ] + ], + "expect": { + "kind": "unsupported", + "reason": "multiple_agent_targets" + } + }, + { + "name": "the same target repeated is rejected as non-canonical", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$agent" + ] + ], + "expect": { + "kind": "invalid", + "reason": "duplicate_agent_target" + } + }, + { + "name": "uppercase target is malformed, not silently accepted", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$AGENT_UPPER" + ], + [ + "p", + "$AGENT_UPPER" + ] + ], + "expect": { + "kind": "invalid", + "reason": "malformed_agent_target" + } + }, + { + "name": "short target is malformed", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "abc" + ], + [ + "p", + "abc" + ] + ], + "expect": { + "kind": "invalid", + "reason": "malformed_agent_target" + } + }, + { + "name": "target that is not p-mentioned is invalid", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$human" + ] + ], + "expect": { + "kind": "invalid", + "reason": "target_not_mentioned" + } + }, + { + "name": "no channel is invalid", + "tags": [ + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$agent" + ] + ], + "expect": { + "kind": "invalid", + "reason": "missing_channel" + } + }, + { + "name": "an unmarked event is not a request at all", + "tags": [ + [ + "h", + "$channel" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$agent" + ] + ], + "expect": { + "kind": "not_request" + } + }, + { + "name": "a marker on a kind v1 does not accept is invalid", + "kind": 44300, + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$agent" + ] + ], + "expect": { + "kind": "invalid", + "reason": "unsupported_kind" + } + }, + { + "name": "two channel tags are invalid, not first-wins", + "tags": [ + [ + "h", + "$channel" + ], + [ + "h", + "$otherChannel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "p", + "$agent" + ] + ], + "expect": { + "kind": "invalid", + "reason": "multiple_channels" + } + }, + { + "name": "a multi-target request with one malformed target is MALFORMED, not unsupported", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "agent", + "abc" + ], + [ + "p", + "$agent" + ], + [ + "p", + "abc" + ] + ], + "expect": { + "kind": "invalid", + "reason": "malformed_agent_target" + } + }, + { + "name": "a multi-target request with one unmentioned target is MALFORMED, not unsupported", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "agent", + "$otherAgent" + ], + [ + "p", + "$agent" + ] + ], + "expect": { + "kind": "invalid", + "reason": "target_not_mentioned" + } + }, + { + "name": "only well-formed, reachable, distinct targets are unsupported", + "tags": [ + [ + "h", + "$channel" + ], + [ + "t", + "request" + ], + [ + "agent", + "$agent" + ], + [ + "agent", + "$otherAgent" + ], + [ + "p", + "$agent" + ], + [ + "p", + "$otherAgent" + ] + ], + "expect": { + "kind": "unsupported", + "reason": "multiple_agent_targets" + } + } + ], + "binding": [ + { + "name": "the target agent binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": true, + "state": "completed" + } + }, + { + "name": "a merely p-mentioned human does not bind", + "signer": "$human", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "not_target_agent" + } + }, + { + "name": "another agent does not bind", + "signer": "$otherAgent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "not_target_agent" + } + }, + { + "name": "the requester cannot resolve their own request", + "signer": "$requester", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "not_target_agent" + } + }, + { + "name": "a different request id does not bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$otherRequestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "request_mismatch" + } + }, + { + "name": "a different channel does not bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$otherChannel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "channel_mismatch" + } + }, + { + "name": "a different requester does not bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$human" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"completed\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "requester_mismatch" + } + }, + { + "name": "an unknown state does not bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "maybe-later" + ] + ], + "content": "{\"disposition\":\"maybe-later\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "unknown_state" + } + }, + { + "name": "content disagreeing with the tag does not bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\":\"refused\",\"reason\":\"\"}", + "expect": { + "bound": false, + "reason": "content_state_mismatch" + } + }, + { + "name": "content request_id disagreeing with the e tag does not bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"request_id\": \"$otherRequestId\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "content_request_id_mismatch" + } + }, + { + "name": "responded binds and is non-terminal", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "responded" + ] + ], + "content": "{\"disposition\":\"responded\",\"reason\":\"\"}", + "expect": { + "bound": true, + "state": "responded" + } + }, + { + "name": "content that is null never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "null", + "expect": { + "bound": false, + "reason": "content_not_object" + } + }, + { + "name": "content that is a number never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "42", + "expect": { + "bound": false, + "reason": "content_not_object" + } + }, + { + "name": "content that is a string never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "\"hi\"", + "expect": { + "bound": false, + "reason": "content_not_object" + } + }, + { + "name": "content that is an array never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "[]", + "expect": { + "bound": false, + "reason": "content_not_object" + } + }, + { + "name": "content that is unparseable bytes never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{not json", + "expect": { + "bound": false, + "reason": "content_not_object" + } + }, + { + "name": "an event of the wrong kind never binds, however well-formed its tags", + "signer": "$agent", + "kind": 9, + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "wrong_kind" + } + }, + { + "name": "two `e` tags never bind (first-tag selection was the drift)", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ], + [ + "e", + "$otherRequestId" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "request_id_cardinality" + } + }, + { + "name": "two `h` tags never bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ], + [ + "h", + "$otherChannel" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "channel_cardinality" + } + }, + { + "name": "two `p` tags never bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ], + [ + "p", + "$human" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "requester_cardinality" + } + }, + { + "name": "two `disposition` tags never bind", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ], + [ + "disposition", + "refused" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "state_cardinality" + } + }, + { + "name": "a missing `e` tag never binds", + "signer": "$agent", + "tags": [ + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "request_id_cardinality" + } + }, + { + "name": "a missing `h` tag never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "channel_cardinality" + } + }, + { + "name": "a non-canonical `e` tag never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "ABC" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "malformed_request_id" + } + }, + { + "name": "a non-canonical `p` tag never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "nope" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\"}", + "expect": { + "bound": false, + "reason": "malformed_requester" + } + }, + { + "name": "content without the REQUIRED `reason` never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\"}", + "expect": { + "bound": false, + "reason": "missing_reason" + } + }, + { + "name": "a non-string `reason` never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": 42}", + "expect": { + "bound": false, + "reason": "reason_not_string" + } + }, + { + "name": "a non-string `request_id` never binds", + "signer": "$agent", + "tags": [ + [ + "e", + "$requestId" + ], + [ + "h", + "$channel" + ], + [ + "p", + "$requester" + ], + [ + "disposition", + "completed" + ] + ], + "content": "{\"disposition\": \"completed\", \"reason\": \"\", \"request_id\": 42}", + "expect": { + "bound": false, + "reason": "request_id_not_string" + } + } + ], + "$lifecycleComment": [ + "Sequences are (id, createdAt, state) triples, all signed by the target", + "agent and otherwise canonical. Expected: the derived latest state, the", + "anomaly set, and whether the obligation counts as resolved.", + "", + "Every sequence through length 2 is covered, plus the length-3 repair path", + "and the same-second and duplicate-id edges." + ], + "lifecycle": [ + { + "name": "nothing bound is unanswered", + "events": [], + "expect": { + "outcome": { + "kind": "unanswered" + }, + "warnings": [], + "latestObservation": null, + "resolved": false + } + }, + { + "name": "completed alone resolves", + "events": [ + [ + "d1", + 100, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [], + "latestObservation": "completed", + "resolved": true + } + }, + { + "name": "refused alone resolves", + "events": [ + [ + "d1", + 100, + "refused" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "warnings": [], + "latestObservation": "refused", + "resolved": true + } + }, + { + "name": "responded alone is open, not resolved", + "events": [ + [ + "d1", + 100, + "responded" + ] + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "warnings": [], + "latestObservation": "responded", + "resolved": false + } + }, + { + "name": "errored alone is open, not resolved", + "events": [ + [ + "d1", + 100, + "errored" + ] + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "warnings": [], + "latestObservation": "errored", + "resolved": false + } + }, + { + "name": "errored then completed is a clean repair", + "events": [ + [ + "d1", + 100, + "errored" + ], + [ + "d2", + 200, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [], + "latestObservation": "completed", + "resolved": true + } + }, + { + "name": "responded then completed is a clean repair", + "events": [ + [ + "d1", + 100, + "responded" + ], + [ + "d2", + 200, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [], + "latestObservation": "completed", + "resolved": true + } + }, + { + "name": "responded then refused is a clean outcome", + "events": [ + [ + "d1", + 100, + "responded" + ], + [ + "d2", + 200, + "refused" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "warnings": [], + "latestObservation": "refused", + "resolved": true + } + }, + { + "name": "repeated non-terminals are not anomalous", + "events": [ + [ + "d1", + 100, + "errored" + ], + [ + "d2", + 200, + "responded" + ], + [ + "d3", + 300, + "errored" + ] + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "warnings": [], + "latestObservation": "errored", + "resolved": false + } + }, + { + "name": "completed then refused is opposing terminal", + "events": [ + [ + "d1", + 100, + "completed" + ], + [ + "d2", + 200, + "refused" + ] + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "warnings": [], + "latestObservation": "refused", + "resolved": false + } + }, + { + "name": "refused then completed is opposing terminal", + "events": [ + [ + "d1", + 100, + "refused" + ], + [ + "d2", + 200, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "warnings": [], + "latestObservation": "completed", + "resolved": false + } + }, + { + "name": "completed twice is duplicate terminal, not opposing", + "events": [ + [ + "d1", + 100, + "completed" + ], + [ + "d2", + 200, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [ + "duplicate_terminal" + ], + "latestObservation": "completed", + "resolved": true + } + }, + { + "name": "refused twice is duplicate terminal", + "events": [ + [ + "d1", + 100, + "refused" + ], + [ + "d2", + 200, + "refused" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "warnings": [ + "duplicate_terminal" + ], + "latestObservation": "refused", + "resolved": true + } + }, + { + "name": "completed then errored is a post-terminal write", + "events": [ + [ + "d1", + 100, + "completed" + ], + [ + "d2", + 200, + "errored" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [ + "ordered_after_terminal" + ], + "latestObservation": "errored", + "resolved": true + } + }, + { + "name": "refused then responded is a post-terminal write", + "events": [ + [ + "d1", + 100, + "refused" + ], + [ + "d2", + 200, + "responded" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "warnings": [ + "ordered_after_terminal" + ], + "latestObservation": "responded", + "resolved": true + } + }, + { + "name": "the identical event delivered twice is not a disputed outcome", + "events": [ + [ + "dup", + 100, + "completed" + ], + [ + "dup", + 100, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [], + "latestObservation": "completed", + "resolved": true + } + }, + { + "name": "same-second tie breaks by lexicographically greatest id", + "events": [ + [ + "zzz", + 100, + "responded" + ], + [ + "aaa", + 100, + "completed" + ] + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "warnings": [ + "ordered_after_terminal" + ], + "latestObservation": "responded", + "resolved": true + } + } + ], + "requestKinds": [ + 9 + ] +} diff --git a/docs/nips/nip-ad-lifecycle-exhaustive.json b/docs/nips/nip-ad-lifecycle-exhaustive.json new file mode 100644 index 00000000000..1ed425743f1 --- /dev/null +++ b/docs/nips/nip-ad-lifecycle-exhaustive.json @@ -0,0 +1,6199 @@ +{ + "$comment": [ + "GENERATED FILE -- do not edit by hand.", + "Regenerate with: node scripts/gen-nip-ad-corpus.mjs", + "", + "Every bound-disposition history up to length 4, with expectations", + "computed from declarative rules in the generator rather than from", + "either implementation. Both the Rust verifier and the TypeScript", + "mirror run all of these.", + "", + "Histories are already ordered: the runner assigns increasing", + "created_at values, so index order is sort order. Ordering itself is", + "covered by the hand-written cases in nip-ad-conformance.json." + ], + "caseCount": 341, + "cases": [ + { + "history": [], + "expect": { + "outcome": { + "kind": "unanswered" + }, + "latestObservation": null, + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "completed", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "completed", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "refused", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "refused", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "responded", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "responded", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "completed", + "errored", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "completed", + "errored", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "completed", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "completed", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "refused", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "refused", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "responded", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "responded", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "refused", + "errored", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "refused", + "errored", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "completed", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "completed", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "responded", + "refused", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "refused", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "responded", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "responded", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "responded", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "responded", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "responded", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "responded", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "responded", + "errored", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "responded", + "errored", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "completed", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "completed", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": false + } + }, + { + "history": [ + "errored", + "refused", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal", + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "refused", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "responded", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "responded", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "responded", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "responded", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "responded", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "responded", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "completed", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "completed", + "refused" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "completed", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "completed", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "refused", + "completed" + ], + "expect": { + "outcome": { + "kind": "disputed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "refused", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [ + "duplicate_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "refused", + "responded" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "responded", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "refused", + "errored" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "errored", + "warnings": [ + "ordered_after_terminal" + ], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "responded", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "responded", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "responded", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "responded", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "errored", + "completed" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "completed" + }, + "latestObservation": "completed", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "errored", + "refused" + ], + "expect": { + "outcome": { + "kind": "settled", + "state": "refused" + }, + "latestObservation": "refused", + "warnings": [], + "resolved": true + } + }, + { + "history": [ + "errored", + "errored", + "errored", + "responded" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "responded" + }, + "latestObservation": "responded", + "warnings": [], + "resolved": false + } + }, + { + "history": [ + "errored", + "errored", + "errored", + "errored" + ], + "expect": { + "outcome": { + "kind": "open", + "state": "errored" + }, + "latestObservation": "errored", + "warnings": [], + "resolved": false + } + } + ] +} diff --git a/scripts/gen-nip-ad-corpus.mjs b/scripts/gen-nip-ad-corpus.mjs new file mode 100644 index 00000000000..6ae81295973 --- /dev/null +++ b/scripts/gen-nip-ad-corpus.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +/** + * Generate the exhaustive NIP-AD lifecycle conformance corpus. + * + * Enumerates every bound-disposition sequence up to length 4 (340 histories) + * and computes each expected result from the declarative rules below — NOT by + * calling either implementation. That independence is the point: a corpus + * generated from one implementation only proves the other agrees with it, + * which is exactly how a spec/implementation contradiction survived three + * review rounds. Here the rules are stated a third time, in the plainest form + * they have, and all three must agree. + * + * Also generates the normative transition table embedded in NIP-AD.md, so the + * spec cannot drift from the behavior either. + * + * Usage: node scripts/gen-nip-ad-corpus.mjs [--check] + * --check exits nonzero if the committed files are stale (used by CI). + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const CORPUS = join(ROOT, "docs/nips/nip-ad-lifecycle-exhaustive.json"); + +const STATES = ["completed", "refused", "responded", "errored"]; +const TERMINAL = new Set(["completed", "refused"]); + +// --------------------------------------------------------------------------- +// The rules, stated declaratively. This is the third independent statement of +// the lifecycle (Rust, TypeScript, here) and the one the other two are checked +// against. +// --------------------------------------------------------------------------- + +/** Terminal-absorbing outcome. Order is the bound, sorted history. */ +function expectedOutcome(history) { + const hasCompleted = history.includes("completed"); + const hasRefused = history.includes("refused"); + const firstTerminal = history.findIndex((s) => TERMINAL.has(s)); + + // Both terminal types claimed: no settled answer exists. + if (hasCompleted && hasRefused) { + return { kind: "disputed" }; + } + // A terminal claim absorbs everything ordered after it. + if (firstTerminal !== -1) { + return { kind: "settled", state: history[firstTerminal] }; + } + // Nothing terminal: open if anything bound at all, else a real gap. + if (history.length > 0) { + return { kind: "open", state: history[history.length - 1] }; + } + return { kind: "unanswered" }; +} + +/** + * Diagnostics that do not change the outcome. Deliberately non-overlapping: + * a terminal following a terminal is already fully described by + * `duplicate_terminal` (same state) or by a disputed outcome (opposing + * states), so `ordered_after_terminal` is scoped to non-terminal followers. + */ +function expectedWarnings(history) { + const warnings = []; + // "Duplicate" means the SAME terminal state twice. Counting any two + // terminals would fire on `completed → refused`, which is a contradiction + // (already `disputed`), not a duplicate. + const duplicated = [...TERMINAL].some( + (state) => history.filter((s) => s === state).length > 1, + ); + if (duplicated) { + warnings.push("duplicate_terminal"); + } + const firstTerminal = history.findIndex((s) => TERMINAL.has(s)); + if ( + firstTerminal !== -1 && + history.slice(firstTerminal + 1).some((s) => !TERMINAL.has(s)) + ) { + warnings.push("ordered_after_terminal"); + } + return warnings; +} + +/** The last event's state, regardless of absorption. */ +function expectedLatestObservation(history) { + return history.length > 0 ? history[history.length - 1] : null; +} + +/** Settled histories are resolved. Disputed ones are not. */ +function expectedResolved(history) { + return expectedOutcome(history).kind === "settled"; +} + +// --------------------------------------------------------------------------- +// Enumeration +// --------------------------------------------------------------------------- + +function sequencesUpTo(maxLen) { + const out = [[]]; + let frontier = [[]]; + for (let len = 1; len <= maxLen; len += 1) { + const next = []; + for (const seq of frontier) { + for (const state of STATES) { + next.push([...seq, state]); + } + } + out.push(...next); + frontier = next; + } + return out; +} + +function buildCorpus() { + const cases = sequencesUpTo(4).map((history) => ({ + history, + expect: { + outcome: expectedOutcome(history), + latestObservation: expectedLatestObservation(history), + warnings: expectedWarnings(history), + resolved: expectedResolved(history), + }, + })); + + return { + $comment: [ + "GENERATED FILE -- do not edit by hand.", + "Regenerate with: node scripts/gen-nip-ad-corpus.mjs", + "", + "Every bound-disposition history up to length 4, with expectations", + "computed from declarative rules in the generator rather than from", + "either implementation. Both the Rust verifier and the TypeScript", + "mirror run all of these.", + "", + "Histories are already ordered: the runner assigns increasing", + "created_at values, so index order is sort order. Ordering itself is", + "covered by the hand-written cases in nip-ad-conformance.json.", + ], + caseCount: cases.length, + cases, + }; +} + +// --------------------------------------------------------------------------- +// The normative transition table in NIP-AD.md, generated from the same rules. +// --------------------------------------------------------------------------- + +function describe(history) { + return history.length === 0 ? "(nothing bound)" : history.join(" → "); +} + +function renderOutcome(outcome) { + switch (outcome.kind) { + case "settled": + return `**settled** (\`${outcome.state}\`)`; + case "open": + return `open (\`${outcome.state}\`)`; + case "disputed": + return "**disputed**"; + default: + return "unanswered"; + } +} + +/** + * The representative histories the spec documents. Chosen to cover every + * distinct (outcome kind, warning set) pair the rules can produce, so the + * table is complete in behavior even though it is not the full 340. + */ +function tableRows() { + const seen = new Set(); + const rows = []; + for (const history of sequencesUpTo(3)) { + const outcome = expectedOutcome(history); + const warnings = expectedWarnings(history); + const key = `${outcome.kind}:${outcome.state ?? ""}:${warnings.join(",")}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + rows.push({ history, outcome, warnings }); + } + return rows; +} + +function renderTable() { + const lines = [ + "| History (ordered) | Effective outcome | Resolved? | Warnings |", + "|---|---|---|---|", + ]; + for (const { history, outcome, warnings } of tableRows()) { + lines.push( + `| ${describe(history)} | ${renderOutcome(outcome)} | ${ + outcome.kind === "settled" ? "yes" : "no" + } | ${warnings.length ? warnings.map((w) => `\`${w}\``).join(", ") : "—"} |`, + ); + } + return lines.join("\n"); +} + +const NIP = join(ROOT, "docs/nips/NIP-AD.md"); +const BEGIN = ""; +const END = ""; + +function spliceTable(markdown, table) { + const start = markdown.indexOf(BEGIN); + const end = markdown.indexOf(END); + if (start === -1 || end === -1) { + throw new Error( + `NIP-AD.md is missing the ${BEGIN} / ${END} markers — the generated ` + + "transition table has nowhere to go.", + ); + } + return `${markdown.slice(0, start + BEGIN.length)}\n${table}\n${markdown.slice(end)}`; +} + +// --------------------------------------------------------------------------- + +const check = process.argv.includes("--check"); +const corpus = `${JSON.stringify(buildCorpus(), null, 2)}\n`; +const nipBefore = readFileSync(NIP, "utf8"); +const nipAfter = spliceTable(nipBefore, renderTable()); + +if (check) { + const stale = []; + if (readFileSync(CORPUS, "utf8") !== corpus) { + stale.push("docs/nips/nip-ad-lifecycle-exhaustive.json"); + } + if (nipBefore !== nipAfter) { + stale.push("docs/nips/NIP-AD.md (transition table)"); + } + if (stale.length > 0) { + console.error( + `Stale generated NIP-AD artifacts:\n ${stale.join("\n ")}\n\n` + + "The spec's transition table and the exhaustive corpus are generated " + + "from one rule set so they cannot drift from each other.\n" + + "Run: node scripts/gen-nip-ad-corpus.mjs", + ); + process.exit(1); + } + console.log("NIP-AD generated artifacts are up to date."); +} else { + writeFileSync(CORPUS, corpus); + writeFileSync(NIP, nipAfter); + console.log( + `Wrote ${JSON.parse(corpus).caseCount} lifecycle cases and the NIP-AD transition table.`, + ); +}