diff --git a/README.md b/README.md index 79229d03d..25f112584 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Covenant sits below agent applications and above the host operating system. It owns the state, authority, and accountability concerns that recur across agent frameworks — scoped capabilities, durable memory, runtime isolation, append-only audit, and commit-scoped provenance — so individual frameworks can stop reinventing them. -**Status.** Local control plane is real and live-tested (24 Rust crates, ~193k lines, 2537 source-discovered Rust tests including 387 live boundary tests). Production-grade sandboxing for hostile agent code, networked multi-peer operation, and daemon-driven on-chain settlement are roadmap. See [BUILT.md](./BUILT.md) for the explicit honesty boundary. +**Status.** Local control plane is real and live-tested (25 Rust crates, ~197k lines, 2605 source-discovered Rust tests including 387 live boundary tests). Production-grade sandboxing for hostile agent code, networked multi-peer operation, and daemon-driven on-chain settlement are roadmap. See [BUILT.md](./BUILT.md) for the explicit honesty boundary. - **Web:** [opencovenant.org](https://opencovenant.org) diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index 887703c2a..35663cae3 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1358,6 +1358,23 @@ dependencies = [ "wiremock", ] +[[package]] +name = "covenant-said-bridge" +version = "0.0.0" +dependencies = [ + "hex", + "parking_lot", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", +] + [[package]] name = "covenant-sap-bridge" version = "0.0.0" @@ -1527,6 +1544,7 @@ dependencies = [ "covenant-permissions", "covenant-router", "covenant-runtime", + "covenant-said-bridge", "covenant-sap-bridge", "covenant-settlement", "covenant-types", @@ -2616,6 +2634,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "histogram" version = "0.6.9" diff --git a/agent-os/Cargo.toml b/agent-os/Cargo.toml index 450c1a8b5..6e8524850 100644 --- a/agent-os/Cargo.toml +++ b/agent-os/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/covenant-x402", "crates/covenant-hyre", "crates/covenant-sap-bridge", + "crates/covenant-said-bridge", "crates/covenant-stake-keeper", "crates/covenantd", "crates/covenant", diff --git a/agent-os/README.md b/agent-os/README.md index 7827847f9..3ac46977c 100644 --- a/agent-os/README.md +++ b/agent-os/README.md @@ -131,7 +131,7 @@ The Linux host, `runsc`, rootfs, and CI adoption contract is maintained internal | State and tools | `covenant-memory`, `covenant-tools`, `covenant-llm`, `covenant-mcp`, `covenant-a2a` | | Compositor | `covenant-tui` | | Payments | `covenant-x402`, `covenant-hyre` | -| Settlement | `covenant-settlement`, `covenant-stake-keeper`, `covenant-sap-bridge`, `programs/settlement`, `programs/stake` | +| Settlement | `covenant-settlement`, `covenant-stake-keeper`, `covenant-sap-bridge`, `covenant-said-bridge`, `programs/settlement`, `programs/stake` | ## Operating Model diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index e87fc451b..51c01ee3d 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -762,6 +762,45 @@ pub enum Request { #[serde(default)] expires_at_unix: Option, }, + SaidStatus, + SaidLookup { + wallet: String, + }, + SaidAnchor { + start_audit_index: u64, + end_audit_index: u64, + merkle_root_hex: String, + #[serde(default)] + live: bool, + }, + SaidAnchorStatus { + #[serde(default = "default_recent_limit")] + recent_limit: usize, + }, + SaidInbox { + chain: String, + address: String, + }, + SaidFreeTier { + address: String, + }, + SaidSend { + source_chain: String, + source_address: String, + target_chain: String, + target_address: String, + payload_json: String, + }, + SaidRegisterOnChain { + metadata_uri: String, + }, + SaidGetVerified, + SaidValidateWork { + agent: String, + task_hash_hex: String, + passed: bool, + evidence_uri: String, + }, } fn default_recent_limit() -> usize { @@ -1016,11 +1055,103 @@ pub enum Response { agent_pda: String, signature: String, }, + SaidStatus { + enabled: bool, + cluster: String, + program_id: String, + rpc_url: String, + api_base_url: String, + paid_gates: String, + has_signer: bool, + }, + SaidAgent { + wallet: String, + pda: Option, + owner: Option, + name: Option, + description: Option, + metadata_uri: Option, + is_verified: bool, + sponsored: bool, + reputation_score: f64, + feedback_count: u64, + activity_count: u64, + registered_at: Option, + }, + SaidAnchored { + anchor_index: u64, + start_seq: u64, + end_seq: u64, + merkle_root_hex: String, + tx_sig: String, + slot: u64, + fixture: bool, + }, + SaidAnchorStatus { + next_index: u64, + last_confirmed_index: Option, + pending: u64, + recent: Vec, + }, + SaidInbox { + chain: String, + address: String, + messages: Vec, + }, + SaidFreeTier { + address: String, + used: u32, + remaining: u32, + limit: u32, + paid_price: Option, + payment_chains: Vec, + }, + SaidSent { + message_id: String, + free_tier_remaining: Option, + delivered_at: Option, + }, + SaidOnChainRegistered { + agent_pda: String, + owner: String, + signature: String, + }, + SaidVerified { + signature: String, + slot: u64, + }, + SaidValidationPosted { + validation_pda: String, + validator: String, + signature: String, + }, Error { message: String, }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SaidInboxMessage { + pub id: String, + pub source_chain: String, + pub source_address: String, + pub target_chain: String, + pub target_address: String, + pub payload: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SaidAnchorRow { + pub anchor_index: u64, + pub start_seq: u64, + pub end_seq: u64, + pub merkle_root_hex: String, + pub tx_sig: String, + pub slot: u64, + pub submitted_at_ms: i64, +} + #[derive(Debug, thiserror::Error)] pub enum IpcError { #[error("io: {0}")] diff --git a/agent-os/crates/covenant-said-bridge/Cargo.toml b/agent-os/crates/covenant-said-bridge/Cargo.toml new file mode 100644 index 000000000..65b54ebf6 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "covenant-said-bridge" +version = "0.0.0" +edition = "2021" +license = "Apache-2.0" +authors = ["Covenant contributors"] +repository = "https://github.com/open-covenant/covenant" +description = "Opt-in bridge between Covenant and SAID Protocol (Solana Agent Identity Standard)." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +reqwest = { workspace = true } +rusqlite = { workspace = true } +parking_lot = { workspace = true } +hex = "0.4" +url = "2" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/agent-os/crates/covenant-said-bridge/src/agent_card.rs b/agent-os/crates/covenant-said-bridge/src/agent_card.rs new file mode 100644 index 000000000..99af6b7de --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/agent_card.rs @@ -0,0 +1,90 @@ +//! Public-API reads against `api.saidprotocol.com`. Lookup hits +//! `GET /api/agents/:wallet`; SAID indexes agents from on-chain +//! `register_agent`, so there is no off-chain register call. + +use serde::{Deserialize, Serialize}; + +use crate::client::SaidBridge; +use crate::rest; +use crate::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLookup { + pub wallet: String, + #[serde(default)] + pub pda: Option, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub metadata_uri: Option, + #[serde(default)] + pub is_verified: bool, + #[serde(default)] + pub sponsored: bool, + #[serde(default)] + pub reputation_score: f64, + #[serde(default)] + pub feedback_count: u64, + #[serde(default)] + pub activity_count: u64, + #[serde(default)] + pub registered_at: Option, +} + +impl SaidBridge { + pub async fn lookup(&self, wallet: &str) -> Result { + self.require_enabled()?; + let client = rest::build_client(self.config().rest_timeout)?; + let path = format!("/api/agents/{wallet}"); + rest::get_json(&client, &self.config().api_base_url, &path).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_defaults_sparse_record() { + // SAID agent records are sparse; only `wallet` is guaranteed, so every + // other field must fall back to its default instead of failing decode. + let agent: AgentLookup = + serde_json::from_value(serde_json::json!({ "wallet": "Wal111" })).unwrap(); + assert_eq!(agent.wallet, "Wal111"); + assert!(agent.pda.is_none()); + assert!(agent.metadata_uri.is_none()); + assert!(agent.registered_at.is_none()); + assert!(!agent.is_verified); + assert!(!agent.sponsored); + assert!(agent.reputation_score.abs() < f64::EPSILON); + assert_eq!(agent.feedback_count, 0); + assert_eq!(agent.activity_count, 0); + } + + #[test] + fn lookup_maps_camel_case_keys() { + let agent: AgentLookup = serde_json::from_value(serde_json::json!({ + "wallet": "Wal111", + "metadataUri": "ipfs://meta", + "isVerified": true, + "sponsored": true, + "reputationScore": 4.5, + "feedbackCount": 12, + "activityCount": 99, + "registeredAt": "2026-01-02T03:04:05Z" + })) + .unwrap(); + assert_eq!(agent.metadata_uri.as_deref(), Some("ipfs://meta")); + assert_eq!(agent.registered_at.as_deref(), Some("2026-01-02T03:04:05Z")); + assert!(agent.is_verified); + assert!(agent.sponsored); + assert_eq!(agent.feedback_count, 12); + assert_eq!(agent.activity_count, 99); + assert!((agent.reputation_score - 4.5).abs() < f64::EPSILON); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/anchor.rs b/agent-os/crates/covenant-said-bridge/src/anchor.rs new file mode 100644 index 000000000..71e70f471 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/anchor.rs @@ -0,0 +1,387 @@ +//! Anchor pipeline. +//! +//! `Fixture` mode appends the payload to `anchor_pending.jsonl` and +//! never spends SOL. `Live` mode invokes the worker's `submit-anchor` +//! command and requires `COVENANT_SAID_ALLOW_PAID_ANCHOR=1`. +//! +//! `start_seq` and `end_seq` are audit-chain indices, not settlement +//! batch IDs. + +use std::path::PathBuf; +use std::time::SystemTime; + +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; + +use crate::client::SaidBridge; +use crate::cursor::{AnchorCursor, PendingAnchor}; +use crate::{worker, BridgeError, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorRequest { + pub start_audit_index: u64, + pub end_audit_index: u64, + pub merkle_root_hex: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorSubmission { + pub anchor_index: u64, + pub start_seq: u64, + pub end_seq: u64, + pub merkle_root_hex: String, + pub tx_sig: String, + pub slot: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnchorMode { + /// Write the would-be payload to `anchor_pending.jsonl`. No SOL spent. + Fixture, + /// Drive the TS worker's `submit-anchor` command on devnet or mainnet. + Live, +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn validate_root_hex(hex: &str) -> Result<()> { + if hex.len() != 64 { + return Err(BridgeError::Invalid(format!( + "merkle_root_hex must be 64 chars, got {}", + hex.len() + ))); + } + if !hex + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + return Err(BridgeError::Invalid( + "merkle_root_hex must be lowercase ASCII hex".into(), + )); + } + Ok(()) +} + +fn validate_range(req: &AnchorRequest) -> Result<()> { + if req.end_audit_index < req.start_audit_index { + return Err(BridgeError::Invalid(format!( + "end_audit_index {} < start_audit_index {}", + req.end_audit_index, req.start_audit_index + ))); + } + validate_root_hex(&req.merkle_root_hex) +} + +impl SaidBridge { + pub async fn anchor( + &self, + cursor: &AnchorCursor, + fixture_path: &PathBuf, + mode: AnchorMode, + request: AnchorRequest, + ) -> Result { + validate_range(&request)?; + match mode { + AnchorMode::Fixture => { + self.require_enabled()?; + self.anchor_fixture(cursor, fixture_path, request).await + } + AnchorMode::Live => { + self.require_paid("submit_anchor", self.config().paid.anchor)?; + self.anchor_live(cursor, request).await + } + } + } + + async fn anchor_fixture( + &self, + cursor: &AnchorCursor, + fixture_path: &PathBuf, + request: AnchorRequest, + ) -> Result { + let anchor_index = cursor.next_index()?; + let pending = PendingAnchor { + anchor_index, + start_audit_index: request.start_audit_index, + end_audit_index: request.end_audit_index, + merkle_root_hex: request.merkle_root_hex.clone(), + }; + cursor.claim(&pending)?; + + if let Some(parent) = fixture_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + BridgeError::Invalid(format!("mkdir {}: {e}", parent.display())) + })?; + } + } + + let line = serde_json::to_string(&serde_json::json!({ + "anchor_index": anchor_index, + "start_seq": request.start_audit_index, + "end_seq": request.end_audit_index, + "merkle_root_hex": request.merkle_root_hex, + "stamped_at_ms": now_ms(), + })) + .map_err(|e| BridgeError::Invalid(e.to_string()))?; + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(fixture_path) + .await + .map_err(|e| BridgeError::Invalid(format!("open {}: {e}", fixture_path.display())))?; + file.write_all(line.as_bytes()) + .await + .map_err(|e| BridgeError::Invalid(format!("write {}: {e}", fixture_path.display())))?; + file.write_all(b"\n") + .await + .map_err(|e| BridgeError::Invalid(format!("write {}: {e}", fixture_path.display())))?; + file.flush() + .await + .map_err(|e| BridgeError::Invalid(format!("flush {}: {e}", fixture_path.display())))?; + + let tx_sig = format!("fixture:{anchor_index}"); + cursor.confirm(anchor_index, &tx_sig, 0, now_ms())?; + + Ok(AnchorSubmission { + anchor_index, + start_seq: request.start_audit_index, + end_seq: request.end_audit_index, + merkle_root_hex: request.merkle_root_hex, + tx_sig, + slot: 0, + }) + } + + async fn anchor_live( + &self, + cursor: &AnchorCursor, + request: AnchorRequest, + ) -> Result { + let anchor_index = cursor.next_index()?; + let pending = PendingAnchor { + anchor_index, + start_audit_index: request.start_audit_index, + end_audit_index: request.end_audit_index, + merkle_root_hex: request.merkle_root_hex.clone(), + }; + cursor.claim(&pending)?; + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct WorkerPayload<'a> { + anchor_index: u64, + start_seq: u64, + end_seq: u64, + merkle_root_hex: &'a str, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WorkerResult { + tx_sig: String, + #[serde(default)] + slot: u64, + } + + let payload = WorkerPayload { + anchor_index, + start_seq: request.start_audit_index, + end_seq: request.end_audit_index, + merkle_root_hex: &request.merkle_root_hex, + }; + let result: WorkerResult = worker::invoke(self.config(), "submit-anchor", &payload).await?; + + cursor.confirm(anchor_index, &result.tx_sig, result.slot, now_ms())?; + + Ok(AnchorSubmission { + anchor_index, + start_seq: request.start_audit_index, + end_seq: request.end_audit_index, + merkle_root_hex: request.merkle_root_hex, + tx_sig: result.tx_sig, + slot: result.slot, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Cluster, Config}; + use tempfile::tempdir; + + fn bridge_enabled() -> SaidBridge { + let mut cfg = Config::disabled(Cluster::Devnet); + cfg.enabled = true; + SaidBridge::new(cfg).unwrap() + } + + /// Enabled bridge with the anchor paid gate open and a shell-stub + /// worker that drains stdin and prints `envelope_json` verbatim. + fn bridge_with_anchor_worker(envelope_json: &str) -> SaidBridge { + let mut cfg = Config::disabled(Cluster::Devnet); + cfg.enabled = true; + cfg.paid.anchor = true; + let script = format!("cat >/dev/null; printf '%s' '{envelope_json}'"); + cfg.worker_command = vec!["sh".into(), "-c".into(), script]; + SaidBridge::new(cfg).unwrap() + } + + #[tokio::test] + async fn fixture_anchor_advances_cursor_and_writes_file() { + let dir = tempdir().unwrap(); + let fixture_path = dir.path().join("anchor_pending.jsonl"); + let cursor = AnchorCursor::open(dir.path().join("cursor.db")).unwrap(); + let bridge = bridge_enabled(); + + let request = AnchorRequest { + start_audit_index: 0, + end_audit_index: 7, + merkle_root_hex: "ab".repeat(32), + }; + + let result = bridge + .anchor(&cursor, &fixture_path, AnchorMode::Fixture, request.clone()) + .await + .unwrap(); + assert_eq!(result.anchor_index, 0); + assert!(result.tx_sig.starts_with("fixture:")); + + let next = bridge + .anchor(&cursor, &fixture_path, AnchorMode::Fixture, request) + .await + .unwrap(); + assert_eq!(next.anchor_index, 1); + + let contents = tokio::fs::read_to_string(&fixture_path).await.unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 2); + } + + #[tokio::test] + async fn anchor_live_requires_paid_gate() { + let dir = tempdir().unwrap(); + let fixture_path = dir.path().join("ignored.jsonl"); + let cursor = AnchorCursor::in_memory().unwrap(); + let bridge = bridge_enabled(); + let request = AnchorRequest { + start_audit_index: 0, + end_audit_index: 7, + merkle_root_hex: "ab".repeat(32), + }; + let err = bridge + .anchor(&cursor, &fixture_path, AnchorMode::Live, request) + .await + .unwrap_err(); + assert!(matches!( + err, + BridgeError::PaidGateClosed { + instruction: "submit_anchor" + } + )); + } + + #[tokio::test] + async fn anchor_live_maps_worker_success_and_confirms_cursor() { + let dir = tempdir().unwrap(); + let fixture_path = dir.path().join("ignored.jsonl"); + let cursor = AnchorCursor::in_memory().unwrap(); + let bridge = + bridge_with_anchor_worker(r#"{"ok":true,"data":{"txSig":"livesig","slot":777}}"#); + let request = AnchorRequest { + start_audit_index: 3, + end_audit_index: 9, + merkle_root_hex: "ab".repeat(32), + }; + + let result = bridge + .anchor(&cursor, &fixture_path, AnchorMode::Live, request) + .await + .unwrap(); + assert_eq!(result.anchor_index, 0); + assert_eq!(result.start_seq, 3); + assert_eq!(result.end_seq, 9); + assert_eq!(result.tx_sig, "livesig"); + assert_eq!(result.slot, 777); + + // The worker-success path must persist the confirmation, not just + // return it: the slot leaves pending and the cursor advances. + assert_eq!(cursor.last_confirmed_index().unwrap(), Some(0)); + assert!(cursor.pending().unwrap().is_empty()); + assert_eq!(cursor.next_index().unwrap(), 1); + let recent = cursor.recent(10).unwrap(); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].tx_sig, "livesig"); + assert_eq!(recent[0].slot, 777); + } + + #[tokio::test] + async fn anchor_live_worker_failure_leaves_claim_pending() { + let dir = tempdir().unwrap(); + let fixture_path = dir.path().join("ignored.jsonl"); + let cursor = AnchorCursor::in_memory().unwrap(); + let bridge = + bridge_with_anchor_worker(r#"{"ok":false,"error":"rpc down","name":"SendError"}"#); + let request = AnchorRequest { + start_audit_index: 0, + end_audit_index: 4, + merkle_root_hex: "ab".repeat(32), + }; + + let err = bridge + .anchor(&cursor, &fixture_path, AnchorMode::Live, request) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Upstream { .. })); + + // Claim-before-submit means a failed submit keeps the index + // reserved (pending, unconfirmed) so a retry cannot silently + // reuse it against a stale on-chain last_anchor_index. + assert_eq!(cursor.last_confirmed_index().unwrap(), None); + let pending = cursor.pending().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].anchor_index, 0); + } + + #[test] + fn validate_range_rejects_inverted_or_bad_hex() { + let bad = AnchorRequest { + start_audit_index: 10, + end_audit_index: 5, + merkle_root_hex: "ab".repeat(32), + }; + assert!(matches!(validate_range(&bad), Err(BridgeError::Invalid(_)))); + + let bad_hex = AnchorRequest { + start_audit_index: 0, + end_audit_index: 5, + merkle_root_hex: "zz".repeat(32), + }; + assert!(matches!( + validate_range(&bad_hex), + Err(BridgeError::Invalid(_)) + )); + } + + #[test] + fn validate_range_rejects_wrong_length_hex() { + let short = AnchorRequest { + start_audit_index: 0, + end_audit_index: 5, + merkle_root_hex: "ab".repeat(16), + }; + assert!(matches!( + validate_range(&short), + Err(BridgeError::Invalid(m)) if m.contains("64 chars") + )); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/client.rs b/agent-os/crates/covenant-said-bridge/src/client.rs new file mode 100644 index 000000000..9d81e7df2 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/client.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use crate::config::Config; +use crate::{BridgeError, Result}; + +#[derive(Clone)] +pub struct SaidBridge { + inner: Arc, +} + +impl SaidBridge { + pub fn new(config: Config) -> Result { + Ok(Self { + inner: Arc::new(config), + }) + } + + pub fn config(&self) -> &Config { + &self.inner + } + + pub(crate) fn require_enabled(&self) -> Result<()> { + if self.inner.enabled { + Ok(()) + } else { + Err(BridgeError::Disabled) + } + } + + #[allow(dead_code)] + pub(crate) fn require_paid(&self, instruction: &'static str, allowed: bool) -> Result<()> { + self.require_enabled()?; + if allowed { + Ok(()) + } else { + Err(BridgeError::PaidGateClosed { instruction }) + } + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/config.rs b/agent-os/crates/covenant-said-bridge/src/config.rs new file mode 100644 index 000000000..8cb3bf04f --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/config.rs @@ -0,0 +1,278 @@ +use std::collections::HashMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Cluster { + Devnet, + Localnet, + Mainnet, +} + +impl Cluster { + pub fn as_str(self) -> &'static str { + match self { + Cluster::Devnet => "devnet", + Cluster::Localnet => "localnet", + Cluster::Mainnet => "mainnet", + } + } + + fn upper(self) -> &'static str { + match self { + Cluster::Devnet => "DEVNET", + Cluster::Localnet => "LOCALNET", + Cluster::Mainnet => "MAINNET", + } + } +} + +impl std::str::FromStr for Cluster { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "devnet" => Ok(Cluster::Devnet), + "localnet" => Ok(Cluster::Localnet), + "mainnet" | "mainnet-beta" => Ok(Cluster::Mainnet), + other => Err(format!("unknown cluster: {other}")), + } + } +} + +pub const DEFAULT_SAID_MAINNET_PROGRAM_ID: &str = "5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G"; +pub const DEFAULT_SAID_DEVNET_PROGRAM_ID: &str = "ESPreFucjVwtDmZbhtL3JLJ9VxCethNEYtosMQhkcurv"; +pub const DEFAULT_SAID_API_BASE_URL: &str = "https://api.saidprotocol.com"; + +pub const DEFAULT_WORKER_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_REST_TIMEOUT: Duration = Duration::from_secs(15); + +fn parse_bool(value: Option<&str>) -> bool { + match value.map(str::trim).map(str::to_ascii_lowercase) { + Some(s) => matches!(s.as_str(), "1" | "true" | "yes"), + None => false, + } +} + +fn default_program_id(cluster: Cluster) -> &'static str { + match cluster { + Cluster::Mainnet => DEFAULT_SAID_MAINNET_PROGRAM_ID, + Cluster::Devnet | Cluster::Localnet => DEFAULT_SAID_DEVNET_PROGRAM_ID, + } +} + +fn default_rpc_url(cluster: Cluster) -> &'static str { + match cluster { + Cluster::Mainnet => "https://api.mainnet-beta.solana.com", + Cluster::Devnet => "https://api.devnet.solana.com", + Cluster::Localnet => "http://127.0.0.1:8899", + } +} + +fn default_worker_command() -> Vec { + vec!["covenant-said-worker".to_owned()] +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct PaidGates { + pub register: bool, + pub verify: bool, + pub anchor: bool, + pub validate_work: bool, +} + +impl PaidGates { + pub fn any(&self) -> bool { + self.register || self.verify || self.anchor || self.validate_work + } + + pub fn summary(&self) -> String { + let mut on = Vec::new(); + if self.register { + on.push("register"); + } + if self.verify { + on.push("verify"); + } + if self.anchor { + on.push("anchor"); + } + if self.validate_work { + on.push("validate_work"); + } + if on.is_empty() { + "none".into() + } else { + on.join(",") + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub enabled: bool, + pub cluster: Cluster, + pub program_id: String, + pub rpc_url: String, + pub api_base_url: String, + pub paid: PaidGates, + pub worker_command: Vec, + pub worker_timeout: Duration, + pub rest_timeout: Duration, +} + +impl Config { + pub fn disabled(cluster: Cluster) -> Self { + Self { + enabled: false, + cluster, + program_id: String::new(), + rpc_url: String::new(), + api_base_url: String::new(), + paid: PaidGates::default(), + worker_command: default_worker_command(), + worker_timeout: DEFAULT_WORKER_TIMEOUT, + rest_timeout: DEFAULT_REST_TIMEOUT, + } + } + + pub fn from_env(env: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + let map: HashMap = env + .into_iter() + .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string())) + .collect(); + let get = |key: &str| map.get(key).map(String::as_str); + + let cluster = get("COVENANT_SOLANA_CLUSTER") + .and_then(|s| s.parse::().ok()) + .unwrap_or(Cluster::Devnet); + let upper = cluster.upper(); + + let enabled = parse_bool( + get(&format!("COVENANT_SAID_{upper}_ENABLED")).or_else(|| get("COVENANT_SAID_ENABLED")), + ); + + let program_id = get(&format!("COVENANT_SAID_{upper}_PROGRAM_ID")) + .or_else(|| get("COVENANT_SAID_PROGRAM_ID")) + .map(str::to_owned) + .unwrap_or_else(|| default_program_id(cluster).to_owned()); + + let rpc_url = get(&format!("COVENANT_SAID_{upper}_RPC_URL")) + .or_else(|| get("COVENANT_SAID_RPC_URL")) + .map(str::to_owned) + .unwrap_or_else(|| default_rpc_url(cluster).to_owned()); + + let api_base_url = get("COVENANT_SAID_API_BASE_URL") + .map(str::to_owned) + .unwrap_or_else(|| DEFAULT_SAID_API_BASE_URL.to_owned()); + + let paid = PaidGates { + register: parse_bool(get("COVENANT_SAID_ALLOW_PAID_REGISTER")), + verify: parse_bool(get("COVENANT_SAID_ALLOW_PAID_VERIFY")), + anchor: parse_bool(get("COVENANT_SAID_ALLOW_PAID_ANCHOR")), + validate_work: parse_bool(get("COVENANT_SAID_ALLOW_PAID_VALIDATE")), + }; + + let worker_command = get("COVENANT_SAID_WORKER_CMD") + .map(|s| s.split_whitespace().map(str::to_owned).collect::>()) + .filter(|parts| !parts.is_empty()) + .unwrap_or_else(default_worker_command); + + let worker_timeout = get("COVENANT_SAID_WORKER_TIMEOUT_SECS") + .and_then(|s| s.trim().parse::().ok()) + .filter(|secs| *secs > 0) + .map(Duration::from_secs) + .unwrap_or(DEFAULT_WORKER_TIMEOUT); + + let rest_timeout = get("COVENANT_SAID_REST_TIMEOUT_SECS") + .and_then(|s| s.trim().parse::().ok()) + .filter(|secs| *secs > 0) + .map(Duration::from_secs) + .unwrap_or(DEFAULT_REST_TIMEOUT); + + Self { + enabled, + cluster, + program_id, + rpc_url, + api_base_url, + paid, + worker_command, + worker_timeout, + rest_timeout, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn disabled_by_default() { + let cfg = Config::from_env(env(&[])); + assert!(!cfg.enabled); + assert!(!cfg.paid.any()); + assert_eq!(cfg.cluster, Cluster::Devnet); + assert_eq!(cfg.program_id, DEFAULT_SAID_DEVNET_PROGRAM_ID); + } + + #[test] + fn mainnet_default_program_id() { + let cfg = Config::from_env(env(&[("COVENANT_SOLANA_CLUSTER", "mainnet")])); + assert_eq!(cfg.program_id, DEFAULT_SAID_MAINNET_PROGRAM_ID); + } + + #[test] + fn cluster_specific_overrides_global_program_id() { + let cfg = Config::from_env(env(&[ + ("COVENANT_SOLANA_CLUSTER", "devnet"), + ("COVENANT_SAID_ENABLED", "true"), + ("COVENANT_SAID_PROGRAM_ID", "global-id"), + ("COVENANT_SAID_DEVNET_PROGRAM_ID", "devnet-id"), + ])); + assert!(cfg.enabled); + assert_eq!(cfg.program_id, "devnet-id"); + } + + #[test] + fn paid_gates_individually() { + let cfg = Config::from_env(env(&[ + ("COVENANT_SAID_ALLOW_PAID_ANCHOR", "1"), + ("COVENANT_SAID_ALLOW_PAID_VERIFY", "true"), + ])); + assert!(cfg.paid.anchor); + assert!(cfg.paid.verify); + assert!(!cfg.paid.register); + assert!(!cfg.paid.validate_work); + assert_eq!(cfg.paid.summary(), "verify,anchor"); + } + + #[test] + fn rejects_unknown_cluster() { + let err = "mainnent".parse::().unwrap_err(); + assert!(err.contains("mainnent")); + } + + #[test] + fn api_base_overridable() { + let cfg = Config::from_env(env(&[( + "COVENANT_SAID_API_BASE_URL", + "https://staging.saidprotocol.test", + )])); + assert_eq!(cfg.api_base_url, "https://staging.saidprotocol.test"); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/cursor.rs b/agent-os/crates/covenant-said-bridge/src/cursor.rs new file mode 100644 index 000000000..961fb2f34 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/cursor.rs @@ -0,0 +1,252 @@ +//! SQLite-backed anchor cursor. `submit_anchor` requires +//! `anchor_index = AgentIdentity.last_anchor_index + 1`, so the cursor +//! persists every claim before submit and the confirmation after. + +use std::path::{Path, PathBuf}; + +use parking_lot::Mutex; +use rusqlite::{params, Connection}; + +use crate::{BridgeError, Result}; + +pub struct AnchorCursor { + conn: Mutex, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingAnchor { + pub anchor_index: u64, + pub start_audit_index: u64, + pub end_audit_index: u64, + pub merkle_root_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfirmedAnchor { + pub anchor_index: u64, + pub start_audit_index: u64, + pub end_audit_index: u64, + pub merkle_root_hex: String, + pub tx_sig: String, + pub slot: u64, + pub submitted_at_ms: i64, +} + +impl AnchorCursor { + pub fn open(path: impl AsRef) -> Result { + if let Some(parent) = path.as_ref().parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + BridgeError::Invalid(format!("mkdir {}: {e}", parent.display())) + })?; + } + } + let conn = Connection::open(path.as_ref()) + .map_err(|e| BridgeError::Invalid(format!("open cursor db: {e}")))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS said_anchor_cursor ( + anchor_index INTEGER PRIMARY KEY, + start_audit_index INTEGER NOT NULL, + end_audit_index INTEGER NOT NULL, + merkle_root TEXT NOT NULL, + tx_sig TEXT, + slot INTEGER, + submitted_at_ms INTEGER + );", + ) + .map_err(|e| BridgeError::Invalid(format!("init schema: {e}")))?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn in_memory() -> Result { + Self::open(PathBuf::from(":memory:")) + } + + /// The next anchor index to claim. Returns 0 when the cursor is empty. + pub fn next_index(&self) -> Result { + let max: Option = self + .conn + .lock() + .query_row( + "SELECT MAX(anchor_index) FROM said_anchor_cursor", + [], + |row| row.get(0), + ) + .map_err(|e| BridgeError::Invalid(format!("read max: {e}")))?; + Ok(max.map(|v| (v as u64) + 1).unwrap_or(0)) + } + + /// The highest confirmed anchor index, or None if no anchors are + /// confirmed yet (regardless of pending rows). + pub fn last_confirmed_index(&self) -> Result> { + let max: Option = self + .conn + .lock() + .query_row( + "SELECT MAX(anchor_index) FROM said_anchor_cursor WHERE tx_sig IS NOT NULL", + [], + |row| row.get(0), + ) + .map_err(|e| BridgeError::Invalid(format!("read confirmed: {e}")))?; + Ok(max.map(|v| v as u64)) + } + + /// Insert a claimed slot before submitting. Conflicts on the + /// `anchor_index` PK so a duplicate claim is impossible. + pub fn claim(&self, anchor: &PendingAnchor) -> Result<()> { + self.conn + .lock() + .execute( + "INSERT INTO said_anchor_cursor (anchor_index, start_audit_index, end_audit_index, merkle_root) VALUES (?1, ?2, ?3, ?4)", + params![anchor.anchor_index as i64, anchor.start_audit_index as i64, anchor.end_audit_index as i64, anchor.merkle_root_hex], + ) + .map(|_| ()) + .map_err(|e| BridgeError::Invalid(format!("claim slot {}: {e}", anchor.anchor_index))) + } + + pub fn confirm( + &self, + anchor_index: u64, + tx_sig: &str, + slot: u64, + submitted_at_ms: i64, + ) -> Result<()> { + let rows = self + .conn + .lock() + .execute( + "UPDATE said_anchor_cursor SET tx_sig = ?1, slot = ?2, submitted_at_ms = ?3 WHERE anchor_index = ?4", + params![tx_sig, slot as i64, submitted_at_ms, anchor_index as i64], + ) + .map_err(|e| BridgeError::Invalid(format!("confirm {anchor_index}: {e}")))?; + if rows == 0 { + return Err(BridgeError::Invalid(format!( + "confirm: no row at anchor_index {anchor_index}" + ))); + } + Ok(()) + } + + pub fn pending(&self) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn + .prepare( + "SELECT anchor_index, start_audit_index, end_audit_index, merkle_root FROM said_anchor_cursor WHERE tx_sig IS NULL ORDER BY anchor_index", + ) + .map_err(|e| BridgeError::Invalid(format!("prepare pending: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(PendingAnchor { + anchor_index: row.get::<_, i64>(0)? as u64, + start_audit_index: row.get::<_, i64>(1)? as u64, + end_audit_index: row.get::<_, i64>(2)? as u64, + merkle_root_hex: row.get(3)?, + }) + }) + .map_err(|e| BridgeError::Invalid(format!("query pending: {e}")))?; + rows.collect::, _>>() + .map_err(|e| BridgeError::Invalid(format!("collect pending: {e}"))) + } + + pub fn recent(&self, limit: usize) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn + .prepare( + "SELECT anchor_index, start_audit_index, end_audit_index, merkle_root, tx_sig, slot, submitted_at_ms FROM said_anchor_cursor WHERE tx_sig IS NOT NULL ORDER BY anchor_index DESC LIMIT ?1", + ) + .map_err(|e| BridgeError::Invalid(format!("prepare recent: {e}")))?; + let rows = stmt + .query_map(params![limit as i64], |row| { + Ok(ConfirmedAnchor { + anchor_index: row.get::<_, i64>(0)? as u64, + start_audit_index: row.get::<_, i64>(1)? as u64, + end_audit_index: row.get::<_, i64>(2)? as u64, + merkle_root_hex: row.get(3)?, + tx_sig: row.get(4)?, + slot: row.get::<_, i64>(5)? as u64, + submitted_at_ms: row.get(6)?, + }) + }) + .map_err(|e| BridgeError::Invalid(format!("query recent: {e}")))?; + rows.collect::, _>>() + .map_err(|e| BridgeError::Invalid(format!("collect recent: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_cursor_starts_at_zero() { + let c = AnchorCursor::in_memory().unwrap(); + assert_eq!(c.next_index().unwrap(), 0); + assert_eq!(c.last_confirmed_index().unwrap(), None); + assert!(c.pending().unwrap().is_empty()); + } + + #[test] + fn claim_then_confirm_advances_cursor() { + let c = AnchorCursor::in_memory().unwrap(); + let a = PendingAnchor { + anchor_index: 0, + start_audit_index: 0, + end_audit_index: 10, + merkle_root_hex: "ab".repeat(32), + }; + c.claim(&a).unwrap(); + assert_eq!(c.next_index().unwrap(), 1); + assert_eq!(c.last_confirmed_index().unwrap(), None); + assert_eq!(c.pending().unwrap().len(), 1); + + c.confirm(0, "SIG", 12345, 1_700_000_000_000).unwrap(); + assert_eq!(c.last_confirmed_index().unwrap(), Some(0)); + assert!(c.pending().unwrap().is_empty()); + let recent = c.recent(10).unwrap(); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].tx_sig, "SIG"); + assert_eq!(recent[0].slot, 12345); + } + + #[test] + fn duplicate_claim_rejected() { + let c = AnchorCursor::in_memory().unwrap(); + let a = PendingAnchor { + anchor_index: 0, + start_audit_index: 0, + end_audit_index: 10, + merkle_root_hex: "00".repeat(32), + }; + c.claim(&a).unwrap(); + let err = c.claim(&a).unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(_))); + } + + #[test] + fn confirm_missing_slot_errors() { + let c = AnchorCursor::in_memory().unwrap(); + let err = c.confirm(42, "SIG", 1, 1).unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(m) if m.contains("no row"))); + } + + #[test] + fn pending_only_lists_unconfirmed() { + let c = AnchorCursor::in_memory().unwrap(); + for i in 0..3 { + c.claim(&PendingAnchor { + anchor_index: i, + start_audit_index: i * 10, + end_audit_index: i * 10 + 9, + merkle_root_hex: "cd".repeat(32), + }) + .unwrap(); + } + c.confirm(0, "S0", 1, 1).unwrap(); + c.confirm(2, "S2", 3, 3).unwrap(); + let pending = c.pending().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].anchor_index, 1); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/instructions.rs b/agent-os/crates/covenant-said-bridge/src/instructions.rs new file mode 100644 index 000000000..2f1163522 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/instructions.rs @@ -0,0 +1,230 @@ +//! Worker-driven SAID instructions. The worker owns the SDK and the +//! funding key; this module owns the gating and input validation. + +use serde::{Deserialize, Serialize}; + +use crate::client::SaidBridge; +use crate::{worker, BridgeError, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignatureResult { + pub signature: String, + #[serde(default)] + pub slot: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterAgentInput { + pub metadata_uri: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterAgentResult { + pub agent_pda: String, + pub owner: String, + pub signature: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateWorkInput { + pub agent: String, + pub task_hash_hex: String, + pub passed: bool, + pub evidence_uri: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidationResult { + pub validation_pda: String, + pub validator: String, + pub signature: String, +} + +fn validate_hex32(label: &str, hex: &str) -> Result<()> { + if hex.len() != 64 { + return Err(BridgeError::Invalid(format!( + "{label} must be 64 hex chars, got {}", + hex.len() + ))); + } + if !hex + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + return Err(BridgeError::Invalid(format!( + "{label} must be lowercase ASCII hex" + ))); + } + Ok(()) +} + +impl SaidBridge { + pub async fn register_on_chain( + &self, + input: &RegisterAgentInput, + ) -> Result { + self.require_paid("register_agent", self.config().paid.register)?; + if input.metadata_uri.trim().is_empty() { + return Err(BridgeError::Invalid( + "metadata_uri must not be empty".into(), + )); + } + worker::invoke(self.config(), "register-agent", input).await + } + + pub async fn get_verified(&self) -> Result { + self.require_paid("get_verified", self.config().paid.verify)?; + worker::invoke(self.config(), "get-verified", &serde_json::json!({})).await + } + + pub async fn validate_work(&self, input: &ValidateWorkInput) -> Result { + self.require_paid("validate_work", self.config().paid.validate_work)?; + validate_hex32("task_hash_hex", &input.task_hash_hex)?; + if input.evidence_uri.trim().is_empty() { + return Err(BridgeError::Invalid( + "evidence_uri must not be empty".into(), + )); + } + worker::invoke(self.config(), "validate-work", input).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Cluster, Config}; + + fn bridge_with(paid: crate::config::PaidGates) -> SaidBridge { + let mut cfg = Config::disabled(Cluster::Devnet); + cfg.enabled = true; + cfg.paid = paid; + SaidBridge::new(cfg).unwrap() + } + + #[tokio::test] + async fn register_requires_paid_gate() { + let b = bridge_with(crate::config::PaidGates::default()); + let err = b + .register_on_chain(&RegisterAgentInput { + metadata_uri: "https://example.test/agent.json".into(), + }) + .await + .unwrap_err(); + assert!(matches!( + err, + BridgeError::PaidGateClosed { + instruction: "register_agent" + } + )); + } + + #[tokio::test] + async fn get_verified_requires_paid_gate() { + let b = bridge_with(crate::config::PaidGates::default()); + let err = b.get_verified().await.unwrap_err(); + assert!(matches!( + err, + BridgeError::PaidGateClosed { + instruction: "get_verified" + } + )); + } + + #[tokio::test] + async fn validate_work_requires_paid_gate() { + let b = bridge_with(crate::config::PaidGates::default()); + let err = b + .validate_work(&ValidateWorkInput { + agent: "pda".into(), + task_hash_hex: "ab".repeat(32), + passed: true, + evidence_uri: "https://x".into(), + }) + .await + .unwrap_err(); + assert!(matches!( + err, + BridgeError::PaidGateClosed { + instruction: "validate_work" + } + )); + } + + #[tokio::test] + async fn validate_work_rejects_bad_hex() { + let paid = crate::config::PaidGates { + validate_work: true, + ..Default::default() + }; + let b = bridge_with(paid); + let err = b + .validate_work(&ValidateWorkInput { + agent: "pda".into(), + task_hash_hex: "ZZ".repeat(32), + passed: true, + evidence_uri: "https://x".into(), + }) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(m) if m.contains("lowercase ASCII hex"))); + } + + #[tokio::test] + async fn validate_work_rejects_short_hex() { + let paid = crate::config::PaidGates { + validate_work: true, + ..Default::default() + }; + let b = bridge_with(paid); + let err = b + .validate_work(&ValidateWorkInput { + agent: "pda".into(), + task_hash_hex: "ab".repeat(16), + passed: true, + evidence_uri: "https://x".into(), + }) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(m) if m.contains("64 hex chars"))); + } + + #[tokio::test] + async fn validate_work_rejects_empty_evidence_uri() { + let paid = crate::config::PaidGates { + validate_work: true, + ..Default::default() + }; + let b = bridge_with(paid); + let err = b + .validate_work(&ValidateWorkInput { + agent: "pda".into(), + task_hash_hex: "ab".repeat(32), + passed: true, + evidence_uri: " ".into(), + }) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(m) if m.contains("evidence_uri"))); + } + + #[tokio::test] + async fn register_rejects_empty_metadata_uri() { + let paid = crate::config::PaidGates { + register: true, + ..Default::default() + }; + let b = bridge_with(paid); + let err = b + .register_on_chain(&RegisterAgentInput { + metadata_uri: " ".into(), + }) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(m) if m.contains("metadata_uri"))); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/lib.rs b/agent-os/crates/covenant-said-bridge/src/lib.rs new file mode 100644 index 000000000..565b6d089 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/lib.rs @@ -0,0 +1,53 @@ +//! Bridge between Covenant and SAID Protocol on Solana. +//! +//! SAID program: `5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G` (mainnet), +//! `ESPreFucjVwtDmZbhtL3JLJ9VxCethNEYtosMQhkcurv` (devnet). +//! +//! Off-chain register, lookup, and cross-chain messaging hit +//! `api.saidprotocol.com` in-process over REST. The four on-chain +//! instructions (`register_agent`, `get_verified`, `submit_anchor`, +//! `validate_work`) shell out to the TypeScript worker at +//! `@covenant/said-bridge` and each one is gated behind its own +//! `COVENANT_SAID_ALLOW_PAID_*` flag. All gates default off. + +#![deny(unsafe_code)] + +pub mod agent_card; +pub mod anchor; +pub mod client; +pub mod config; +pub mod cursor; +pub mod instructions; +pub mod rest; +pub mod worker; +pub mod xchain; + +pub use client::SaidBridge; +pub use config::{ + Cluster, Config, PaidGates, DEFAULT_SAID_API_BASE_URL, DEFAULT_SAID_DEVNET_PROGRAM_ID, + DEFAULT_SAID_MAINNET_PROGRAM_ID, +}; + +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + #[error("said bridge is disabled")] + Disabled, + #[error("paid instruction {instruction} is gated off")] + PaidGateClosed { instruction: &'static str }, + #[error("rest: {0}")] + Rest(String), + #[error("http {status}: {body}")] + Http { status: u16, body: String }, + #[error("{name}: {message}")] + Upstream { name: String, message: String }, + #[error("decode: {0}")] + Decode(String), + #[error("invalid input: {0}")] + Invalid(String), + #[error("worker: {0}")] + Worker(String), + #[error("worker timed out after {secs}s")] + Timeout { secs: u64 }, +} + +pub type Result = std::result::Result; diff --git a/agent-os/crates/covenant-said-bridge/src/rest.rs b/agent-os/crates/covenant-said-bridge/src/rest.rs new file mode 100644 index 000000000..aa581c867 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/rest.rs @@ -0,0 +1,94 @@ +//! REST client for `api.saidprotocol.com`. Off-chain and xchain only; +//! on-chain instructions go through the worker module. + +use std::time::Duration; + +use reqwest::StatusCode; +use serde::de::DeserializeOwned; +use serde::Serialize; +use url::Url; + +use crate::{BridgeError, Result}; + +pub(crate) fn build_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .user_agent(concat!("covenant-said-bridge/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| BridgeError::Rest(format!("build client: {e}"))) +} + +fn join(base: &str, path: &str) -> Result { + let base = Url::parse(base).map_err(|e| BridgeError::Invalid(format!("bad api base: {e}")))?; + base.join(path) + .map_err(|e| BridgeError::Invalid(format!("bad path {path}: {e}"))) +} + +pub(crate) async fn post_json( + client: &reqwest::Client, + base: &str, + path: &str, + payload: &P, +) -> Result +where + P: Serialize, + T: DeserializeOwned, +{ + let url = join(base, path)?; + let response = client + .post(url) + .json(payload) + .send() + .await + .map_err(|e| BridgeError::Rest(e.to_string()))?; + decode(response).await +} + +pub(crate) async fn get_json(client: &reqwest::Client, base: &str, path: &str) -> Result +where + T: DeserializeOwned, +{ + let url = join(base, path)?; + let response = client + .get(url) + .send() + .await + .map_err(|e| BridgeError::Rest(e.to_string()))?; + decode(response).await +} + +async fn decode(response: reqwest::Response) -> Result { + let status = response.status(); + if status == StatusCode::PAYMENT_REQUIRED { + let body = response.text().await.unwrap_or_default(); + return Err(BridgeError::Http { status: 402, body }); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(BridgeError::Http { + status: status.as_u16(), + body, + }); + } + response + .json::() + .await + .map_err(|e| BridgeError::Decode(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_rejects_bad_base() { + let err = join("not a url", "/api/agents").unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(_))); + } + + #[test] + fn join_resolves_relative() { + let url = join("https://api.saidprotocol.com", "/api/agents/abc").unwrap(); + assert_eq!(url.as_str(), "https://api.saidprotocol.com/api/agents/abc"); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/worker.rs b/agent-os/crates/covenant-said-bridge/src/worker.rs new file mode 100644 index 000000000..b1aece537 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/worker.rs @@ -0,0 +1,135 @@ +//! Subprocess transport to `@covenant/said-bridge`. JSON envelope: +//! +//! ```json +//! { "ok": true, "data": } +//! { "ok": false, "error": "", "name": "" } +//! ``` +//! +//! Worker config comes from the inherited env. Signer: +//! `COVENANT_SAID_KEYPAIR`. + +#![allow(dead_code)] + +use std::process::Stdio; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use crate::config::Config; +use crate::{BridgeError, Result}; + +#[derive(serde::Deserialize)] +struct Envelope { + ok: bool, + #[serde(default)] + data: serde_json::Value, + #[serde(default)] + error: Option, + #[serde(default)] + name: Option, +} + +pub(crate) async fn invoke(config: &Config, command: &str, payload: &P) -> Result +where + P: Serialize, + T: DeserializeOwned, +{ + let mut parts = config.worker_command.iter(); + let program = parts + .next() + .ok_or_else(|| BridgeError::Invalid("worker command is empty".into()))?; + + let mut cmd = Command::new(program); + cmd.args(parts) + .arg(command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let body = serde_json::to_vec(payload).map_err(|e| BridgeError::Invalid(e.to_string()))?; + + let mut child = cmd + .spawn() + .map_err(|e| BridgeError::Worker(format!("spawn {program}: {e}")))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(&body) + .await + .map_err(|e| BridgeError::Worker(format!("write stdin: {e}")))?; + stdin + .shutdown() + .await + .map_err(|e| BridgeError::Worker(format!("close stdin: {e}")))?; + } + + let output = match tokio::time::timeout(config.worker_timeout, child.wait_with_output()).await { + Ok(Ok(out)) => out, + Ok(Err(e)) => return Err(BridgeError::Worker(format!("wait: {e}"))), + Err(_) => { + return Err(BridgeError::Timeout { + secs: config.worker_timeout.as_secs(), + }); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .map(str::trim) + .unwrap_or(""); + + if line.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BridgeError::Worker(format!( + "worker produced no output (exit {}, stderr: {})", + output.status, + stderr.trim() + ))); + } + + let env: Envelope = + serde_json::from_str(line).map_err(|e| BridgeError::Decode(format!("{e}: {line}")))?; + + if env.ok { + serde_json::from_value(env.data).map_err(|e| BridgeError::Decode(e.to_string())) + } else { + let message = env + .error + .unwrap_or_else(|| "worker reported an error".into()); + match env.name { + Some(name) if !name.is_empty() && name != "Error" => { + Err(BridgeError::Upstream { name, message }) + } + _ => Err(BridgeError::Rest(message)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Cluster; + + #[tokio::test] + async fn invoke_rejects_empty_worker_command() { + let mut config = Config::disabled(Cluster::Devnet); + config.worker_command.clear(); + let err = invoke::( + &config, + "status", + &serde_json::json!({}), + ) + .await + .unwrap_err(); + assert!( + matches!(&err, BridgeError::Invalid(m) if m.contains("worker command is empty")), + "empty worker_command must surface as Invalid: {err:?}" + ); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/xchain.rs b/agent-os/crates/covenant-said-bridge/src/xchain.rs new file mode 100644 index 000000000..201eaa7f6 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/xchain.rs @@ -0,0 +1,117 @@ +//! Cross-chain inbox poll, free-tier check, and message send. Free tier +//! is 10/day per agent. Past that SAID returns 402; the caller settles +//! via `covenant-x402-signer`. + +use serde::{Deserialize, Serialize}; + +use crate::client::SaidBridge; +use crate::rest; +use crate::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InboxMessage { + pub id: String, + pub source_chain: String, + pub source_address: String, + pub target_chain: String, + pub target_address: String, + #[serde(default)] + pub payload: Option, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Inbox { + #[serde(default)] + pub chain: Option, + #[serde(default)] + pub address: Option, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendRequest { + pub source_chain: String, + pub source_address: String, + pub target_chain: String, + pub target_address: String, + pub payload: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendReceipt { + pub message_id: String, + #[serde(default)] + pub free_tier_remaining: Option, + #[serde(default)] + pub delivered_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreeTierStatus { + pub address: String, + #[serde(default)] + pub used: u32, + pub remaining: u32, + #[serde(default)] + pub limit: u32, + #[serde(default)] + pub paid_price: Option, + #[serde(default)] + pub payment_chains: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentChain { + pub name: String, + pub network: String, +} + +impl SaidBridge { + pub async fn xchain_inbox(&self, chain: &str, address: &str) -> Result { + self.require_enabled()?; + let client = rest::build_client(self.config().rest_timeout)?; + let path = format!("/xchain/inbox/{chain}/{address}"); + rest::get_json(&client, &self.config().api_base_url, &path).await + } + + pub async fn xchain_free_tier(&self, address: &str) -> Result { + self.require_enabled()?; + let client = rest::build_client(self.config().rest_timeout)?; + let path = format!("/xchain/free-tier/{address}"); + rest::get_json(&client, &self.config().api_base_url, &path).await + } + + pub async fn xchain_send(&self, req: &SendRequest) -> Result { + self.require_enabled()?; + let client = rest::build_client(self.config().rest_timeout)?; + rest::post_json(&client, &self.config().api_base_url, "/xchain/message", req).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_request_serializes_camel_case() { + let req = SendRequest { + source_chain: "solana".into(), + source_address: "AdChc".into(), + target_chain: "base".into(), + target_address: "0xabc".into(), + payload: serde_json::json!({ "hello": "world" }), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["sourceChain"], "solana"); + assert_eq!(json["targetAddress"], "0xabc"); + } +} diff --git a/agent-os/crates/covenant-said-bridge/tests/rest_roundtrip.rs b/agent-os/crates/covenant-said-bridge/tests/rest_roundtrip.rs new file mode 100644 index 000000000..030a30741 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/tests/rest_roundtrip.rs @@ -0,0 +1,277 @@ +//! End-to-end coverage of the REST surface (off-chain `lookup` and the +//! cross-chain inbox/free-tier/send calls) against a one-shot in-process +//! HTTP server. Exercises the status-code branches in `rest::decode` +//! (success, 402, other errors, undecodable body) and confirms each call +//! reaches the right method/path and maps the JSON envelope onto its typed +//! result — without a mock framework or touching the network. + +use covenant_said_bridge::config::Cluster; +use covenant_said_bridge::xchain::SendRequest; +use covenant_said_bridge::{BridgeError, Config, SaidBridge}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; + +struct Request { + method: String, + path: String, + body: String, +} + +/// One-shot HTTP server: binds an ephemeral loopback port, accepts a single +/// connection, replies with `status` (a full status line like "404 Not +/// Found") and `body`, then hands back the parsed request so the test can +/// assert what the bridge actually sent. Returns the base URL to point the +/// bridge at and the join handle carrying the captured request. +async fn serve_once(status: &'static str, body: &'static str) -> (String, JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let request = read_request(&mut stream).await; + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.expect("write"); + stream.flush().await.expect("flush"); + request + }); + (format!("http://{addr}"), handle) +} + +async fn read_request(stream: &mut TcpStream) -> Request { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + let header_end = loop { + let n = stream.read(&mut chunk).await.expect("read headers"); + if n == 0 { + break buf.len(); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + }; + let head = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let mut request_line = head.lines().next().unwrap_or_default().split_whitespace(); + let method = request_line.next().unwrap_or_default().to_owned(); + let path = request_line.next().unwrap_or_default().to_owned(); + let content_length = head + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap_or(0)) + }) + .unwrap_or(0); + let mut body = buf[header_end..].to_vec(); + while body.len() < content_length { + let n = stream.read(&mut chunk).await.expect("read body"); + if n == 0 { + break; + } + body.extend_from_slice(&chunk[..n]); + } + Request { + method, + path, + body: String::from_utf8_lossy(&body).into_owned(), + } +} + +fn enabled_bridge(api_base_url: String) -> SaidBridge { + let mut config = Config::disabled(Cluster::Devnet); + config.enabled = true; + config.api_base_url = api_base_url; + SaidBridge::new(config).expect("bridge") +} + +fn sample_send() -> SendRequest { + SendRequest { + source_chain: "solana".into(), + source_address: "Addr1".into(), + target_chain: "base".into(), + target_address: "0xabc".into(), + payload: serde_json::json!({ "ping": 1 }), + } +} + +#[tokio::test] +async fn lookup_maps_success_envelope() { + let (base, server) = serve_once( + "200 OK", + r#"{"wallet":"Wallet111","owner":"Owner222","name":"scout","isVerified":true,"reputationScore":4.5,"feedbackCount":12,"activityCount":99,"metadataUri":"https://example.test/a.json","registeredAt":"2026-01-01T00:00:00Z"}"#, + ) + .await; + let agent = enabled_bridge(base) + .lookup("Wallet111") + .await + .expect("lookup"); + let req = server.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/api/agents/Wallet111"); + assert_eq!(agent.wallet, "Wallet111"); + assert_eq!(agent.owner.as_deref(), Some("Owner222")); + assert!(agent.is_verified); + assert!((agent.reputation_score - 4.5).abs() < 1e-9); + assert_eq!(agent.feedback_count, 12); + assert_eq!(agent.activity_count, 99); + assert_eq!( + agent.metadata_uri.as_deref(), + Some("https://example.test/a.json") + ); +} + +#[tokio::test] +async fn lookup_surfaces_http_error_with_body() { + let (base, server) = serve_once("404 Not Found", r#"{"error":"agent not found"}"#).await; + let err = enabled_bridge(base) + .lookup("Missing") + .await + .expect_err("should 404"); + server.await.expect("server"); + + match err { + BridgeError::Http { status, body } => { + assert_eq!(status, 404); + assert!(body.contains("agent not found"), "body: {body}"); + } + other => panic!("expected Http, got {other:?}"), + } +} + +#[tokio::test] +async fn lookup_invalid_json_surfaces_decode() { + let (base, server) = serve_once("200 OK", "not json").await; + let err = enabled_bridge(base) + .lookup("Wallet111") + .await + .expect_err("should fail to decode"); + server.await.expect("server"); + + assert!( + matches!(err, BridgeError::Decode(_)), + "expected Decode, got {err:?}" + ); +} + +#[tokio::test] +async fn xchain_inbox_maps_messages() { + let (base, server) = serve_once( + "200 OK", + r#"{"chain":"solana","address":"Addr1","messages":[{"id":"m1","sourceChain":"base","sourceAddress":"0xabc","targetChain":"solana","targetAddress":"Addr1","payload":{"hello":"world"},"createdAt":1700000000}]}"#, + ) + .await; + let inbox = enabled_bridge(base) + .xchain_inbox("solana", "Addr1") + .await + .expect("inbox"); + let req = server.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/xchain/inbox/solana/Addr1"); + assert_eq!(inbox.messages.len(), 1); + let msg = &inbox.messages[0]; + assert_eq!(msg.id, "m1"); + assert_eq!(msg.source_chain, "base"); + assert_eq!(msg.target_address, "Addr1"); + assert_eq!(msg.created_at, Some(1700000000)); +} + +#[tokio::test] +async fn xchain_free_tier_maps_status() { + let (base, server) = serve_once( + "200 OK", + r#"{"address":"Addr1","used":3,"remaining":7,"limit":10,"paidPrice":"0.01","paymentChains":[{"name":"base","network":"mainnet"}]}"#, + ) + .await; + let status = enabled_bridge(base) + .xchain_free_tier("Addr1") + .await + .expect("free tier"); + let req = server.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/xchain/free-tier/Addr1"); + assert_eq!(status.used, 3); + assert_eq!(status.remaining, 7); + assert_eq!(status.limit, 10); + assert_eq!(status.paid_price.as_deref(), Some("0.01")); + assert_eq!(status.payment_chains.len(), 1); + assert_eq!(status.payment_chains[0].name, "base"); +} + +#[tokio::test] +async fn xchain_send_posts_camel_case_and_maps_receipt() { + let (base, server) = serve_once( + "200 OK", + r#"{"messageId":"msg-1","freeTierRemaining":9,"deliveredAt":1700000001}"#, + ) + .await; + let receipt = enabled_bridge(base) + .xchain_send(&sample_send()) + .await + .expect("send"); + let req = server.await.expect("server"); + + assert_eq!(req.method, "POST"); + assert_eq!(req.path, "/xchain/message"); + assert!( + req.body.contains(r#""sourceChain":"solana""#), + "body: {}", + req.body + ); + assert!( + req.body.contains(r#""targetAddress":"0xabc""#), + "body: {}", + req.body + ); + assert_eq!(receipt.message_id, "msg-1"); + assert_eq!(receipt.free_tier_remaining, Some(9)); + assert_eq!(receipt.delivered_at, Some(1700000001)); +} + +#[tokio::test] +async fn xchain_send_payment_required_surfaces_402() { + // Free tier is 10/day; past that SAID answers the POST with 402 so the + // caller can settle via x402 instead of silently dropping the message. + let (base, server) = + serve_once("402 Payment Required", r#"{"error":"free tier exhausted"}"#).await; + let err = enabled_bridge(base) + .xchain_send(&sample_send()) + .await + .expect_err("should be 402"); + server.await.expect("server"); + + match err { + BridgeError::Http { status, body } => { + assert_eq!(status, 402); + assert!(body.contains("free tier exhausted"), "body: {body}"); + } + other => panic!("expected Http 402, got {other:?}"), + } +} + +#[tokio::test] +async fn rest_calls_require_enabled_before_network() { + // The bridge ships disabled; a REST call on a disabled bridge must fail + // closed with Disabled before any socket is opened. + let bridge = SaidBridge::new(Config::disabled(Cluster::Devnet)).expect("bridge"); + + let err = bridge.lookup("Wallet111").await.expect_err("disabled"); + assert!( + matches!(err, BridgeError::Disabled), + "expected Disabled, got {err:?}" + ); + + let err = bridge + .xchain_send(&sample_send()) + .await + .expect_err("disabled"); + assert!( + matches!(err, BridgeError::Disabled), + "expected Disabled, got {err:?}" + ); +} diff --git a/agent-os/crates/covenant-said-bridge/tests/worker_roundtrip.rs b/agent-os/crates/covenant-said-bridge/tests/worker_roundtrip.rs new file mode 100644 index 000000000..ed50c3ae1 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/tests/worker_roundtrip.rs @@ -0,0 +1,205 @@ +//! End-to-end test of the Rust → worker JSON protocol, using a shell +//! stub in place of the real `@covenant/said-bridge` TypeScript worker. +//! Verifies the success and error envelopes map onto the typed bridge +//! surface, and that a hung or silently-exiting worker surfaces a typed +//! error, without spawning the real worker or touching the network. + +use std::time::Duration; + +use covenant_said_bridge::config::{DEFAULT_REST_TIMEOUT, DEFAULT_WORKER_TIMEOUT}; +use covenant_said_bridge::instructions::{RegisterAgentInput, ValidateWorkInput}; +use covenant_said_bridge::{ + BridgeError, Cluster, Config, PaidGates, SaidBridge, DEFAULT_SAID_API_BASE_URL, + DEFAULT_SAID_DEVNET_PROGRAM_ID, +}; + +/// Enabled bridge with every paid gate open so the instruction reaches +/// the worker instead of tripping the gate. `worker_command` is whatever +/// the caller wants to stand in for the real subprocess. +fn bridge_with_worker(worker_command: Vec, worker_timeout: Duration) -> SaidBridge { + let config = Config { + enabled: true, + cluster: Cluster::Devnet, + program_id: DEFAULT_SAID_DEVNET_PROGRAM_ID.into(), + rpc_url: "https://api.devnet.solana.com".into(), + api_base_url: DEFAULT_SAID_API_BASE_URL.into(), + paid: PaidGates { + register: true, + verify: true, + anchor: true, + validate_work: true, + }, + worker_command, + worker_timeout, + rest_timeout: DEFAULT_REST_TIMEOUT, + }; + SaidBridge::new(config).expect("bridge") +} + +/// Worker stub that drains stdin and prints `envelope_json` verbatim on +/// stdout, the shape `worker::invoke` parses from the last non-empty line. +fn bridge_with_stub(envelope_json: &str) -> SaidBridge { + let script = format!("cat >/dev/null; printf '%s' '{envelope_json}'"); + bridge_with_worker( + vec!["sh".into(), "-c".into(), script], + DEFAULT_WORKER_TIMEOUT, + ) +} + +#[tokio::test] +async fn register_on_chain_maps_success_envelope() { + let bridge = bridge_with_stub( + r#"{"ok":true,"data":{"agentPda":"Agent111","owner":"Own222","signature":"sig333"}}"#, + ); + let res = bridge + .register_on_chain(&RegisterAgentInput { + metadata_uri: "https://example.test/agent.json".into(), + }) + .await + .expect("register"); + assert_eq!(res.agent_pda, "Agent111"); + assert_eq!(res.owner, "Own222"); + assert_eq!(res.signature, "sig333"); +} + +#[tokio::test] +async fn get_verified_maps_success_envelope() { + let bridge = bridge_with_stub(r#"{"ok":true,"data":{"signature":"sigV","slot":42}}"#); + let res = bridge.get_verified().await.expect("verify"); + assert_eq!(res.signature, "sigV"); + assert_eq!(res.slot, 42); +} + +#[tokio::test] +async fn get_verified_defaults_missing_slot_to_zero() { + let bridge = bridge_with_stub(r#"{"ok":true,"data":{"signature":"sigV"}}"#); + let res = bridge.get_verified().await.expect("verify"); + assert_eq!(res.signature, "sigV"); + assert_eq!(res.slot, 0); +} + +#[tokio::test] +async fn validate_work_maps_success_envelope() { + let bridge = bridge_with_stub( + r#"{"ok":true,"data":{"validationPda":"Val111","validator":"Ver222","signature":"sigW"}}"#, + ); + let res = bridge + .validate_work(&ValidateWorkInput { + agent: "AgentPda".into(), + task_hash_hex: "ab".repeat(32), + passed: true, + evidence_uri: "https://example.test/evidence.json".into(), + }) + .await + .expect("validate-work"); + assert_eq!(res.validation_pda, "Val111"); + assert_eq!(res.validator, "Ver222"); + assert_eq!(res.signature, "sigW"); +} + +#[tokio::test] +async fn error_envelope_with_named_error_becomes_upstream() { + // A meaningful upstream error name (a send/confirm failure or an + // on-chain program error) is preserved on Upstream so reconciliation + // can branch on failure class instead of string-matching a message. + let bridge = + bridge_with_stub(r#"{"ok":false,"error":"insufficient funds","name":"InsufficientFunds"}"#); + let err = bridge.get_verified().await.expect_err("should fail"); + match err { + BridgeError::Upstream { name, message } => { + assert_eq!(name, "InsufficientFunds"); + assert_eq!(message, "insufficient funds"); + } + other => panic!("expected Upstream, got {other:?}"), + } +} + +#[tokio::test] +async fn error_envelope_with_generic_name_stays_rest() { + // JS's default Error.name ("Error") and a missing name carry no class + // signal, so they collapse to Rest rather than a spurious Upstream. + for envelope in [ + r#"{"ok":false,"error":"boom","name":"Error"}"#, + r#"{"ok":false,"error":"boom"}"#, + ] { + let bridge = bridge_with_stub(envelope); + let err = bridge.get_verified().await.expect_err("should fail"); + match err { + BridgeError::Rest(msg) => assert_eq!(msg, "boom", "envelope {envelope}"), + other => panic!("expected Rest for {envelope}, got {other:?}"), + } + } +} + +#[tokio::test] +async fn error_envelope_without_message_uses_default_text() { + let bridge = bridge_with_stub(r#"{"ok":false}"#); + let err = bridge.get_verified().await.expect_err("should fail"); + match err { + BridgeError::Rest(msg) => assert_eq!(msg, "worker reported an error"), + other => panic!("expected Rest, got {other:?}"), + } +} + +#[tokio::test] +async fn worker_timeout_surfaces_timeout_error() { + // A worker that never returns must surface as Timeout, not hang the + // daemon; kill_on_drop reaps the sleeping child as the future drops. + let bridge = bridge_with_worker( + vec!["sh".into(), "-c".into(), "sleep 30".into()], + Duration::from_millis(200), + ); + let err = bridge.get_verified().await.expect_err("should time out"); + assert!( + matches!(err, BridgeError::Timeout { .. }), + "expected Timeout, got {err:?}" + ); +} + +#[tokio::test] +async fn worker_silent_exit_surfaces_worker_error() { + // A worker that drains stdin and exits cleanly with no stdout — a + // crashed or misbuilt worker — must surface as Worker, not a decode + // panic on an empty line. + let bridge = bridge_with_worker( + vec!["sh".into(), "-c".into(), "cat >/dev/null; exit 0".into()], + DEFAULT_WORKER_TIMEOUT, + ); + let err = bridge.get_verified().await.expect_err("should fail"); + assert!( + matches!(err, BridgeError::Worker(ref msg) if msg.contains("no output")), + "expected Worker(no output), got {err:?}" + ); +} + +#[tokio::test] +async fn worker_non_json_output_surfaces_decode() { + // A misbuilt worker that prints a plain-text line (a stack trace or a + // log message) where an envelope is expected must surface as Decode, + // not panic or mis-route to another error class. + let bridge = bridge_with_stub("worker crashed: TypeError"); + let err = bridge + .get_verified() + .await + .expect_err("should fail to decode"); + assert!( + matches!(err, BridgeError::Decode(_)), + "expected Decode, got {err:?}" + ); +} + +#[tokio::test] +async fn worker_ok_envelope_with_wrong_data_shape_surfaces_decode() { + // ok:true but the data does not match the typed result — here a numeric + // signature where a string is required — must surface as Decode rather + // than a silent default or a panic. + let bridge = bridge_with_stub(r#"{"ok":true,"data":{"signature":123}}"#); + let err = bridge + .get_verified() + .await + .expect_err("should fail to decode"); + assert!( + matches!(err, BridgeError::Decode(_)), + "expected Decode, got {err:?}" + ); +} diff --git a/agent-os/crates/covenant/src/main.rs b/agent-os/crates/covenant/src/main.rs index e34ca99ec..39385093c 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2168,6 +2168,36 @@ fn print_usage() { eprintln!( " covenant sap attest-agent --agent-pda --root [--type --expires] [--json] cross-party attestation via the verifier" ); + eprintln!( + " covenant said status [--json] resolved SAID bridge config and paid gates" + ); + eprintln!( + " covenant said lookup --wallet [--json] fetch a SAID agent's identity, verification, reputation" + ); + eprintln!( + " covenant said anchor --start --end --root [--live] [--json] anchor an audit slice (fixture by default)" + ); + eprintln!( + " covenant said anchor-status [--recent ] [--json] cursor snapshot: next index, last confirmed, recent rows" + ); + eprintln!( + " covenant said inbox --address [--chain solana] [--json] poll pending xchain messages" + ); + eprintln!( + " covenant said free-tier --address [--json] check remaining free-tier xchain quota" + ); + eprintln!( + " covenant said send --source-address --target-chain --target-address --payload [--json] send an xchain message" + ); + eprintln!( + " covenant said register --metadata-uri [--json] call SAID register_agent (paid gate REGISTER)" + ); + eprintln!( + " covenant said verify [--json] buy the SAID verification badge (paid gate VERIFY)" + ); + eprintln!( + " covenant said validate-work --agent --task-hash {{--passed|--failed}} --evidence [--json] post a SAID validate_work record (paid gate VALIDATE)" + ); } struct MemoryReadJsonArgs { @@ -5045,6 +5075,675 @@ async fn main() -> Result<()> { } } } + "said" => { + if args.len() < 2 { + print_usage(); + std::process::exit(2); + } + match args[1].as_str() { + "status" => { + let mut as_json = false; + for arg in &args[2..] { + match arg.as_str() { + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + } + write_frame(&mut stream, &Request::SaidStatus).await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidStatus { + enabled, + cluster, + program_id, + rpc_url, + api_base_url, + paid_gates, + has_signer, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_status", + "enabled": enabled, + "cluster": cluster, + "program_id": program_id, + "rpc_url": rpc_url, + "api_base_url": api_base_url, + "paid_gates": paid_gates, + "has_signer": has_signer, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("enabled: {enabled}"); + println!("cluster: {cluster}"); + println!("program_id: {program_id}"); + println!("rpc_url: {rpc_url}"); + println!("api_base_url: {api_base_url}"); + println!("paid_gates: {paid_gates}"); + println!("has_signer: {has_signer}"); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "register" => { + let mut metadata_uri: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--metadata-uri" => { + i += 1; + metadata_uri = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--metadata-uri needs a URL") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let metadata_uri = metadata_uri.ok_or_else(|| { + anyhow::anyhow!("covenant said register requires --metadata-uri ") + })?; + write_frame( + &mut stream, + &Request::SaidRegisterOnChain { metadata_uri }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidOnChainRegistered { + agent_pda, + owner, + signature, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_registered", + "agent_pda": agent_pda, + "owner": owner, + "signature": signature, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("agent_pda: {agent_pda}"); + println!("owner: {owner}"); + println!("signature: {signature}"); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "verify" => { + let mut as_json = false; + for arg in &args[2..] { + match arg.as_str() { + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + } + write_frame(&mut stream, &Request::SaidGetVerified).await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidVerified { signature, slot } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_verified", + "signature": signature, + "slot": slot, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("signature: {signature}"); + println!("slot: {slot}"); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "validate-work" => { + let mut agent: Option = None; + let mut task_hash_hex: Option = None; + let mut passed: Option = None; + let mut evidence_uri: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--agent" => { + i += 1; + agent = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--agent needs a SAID agent PDA") + })?); + } + "--task-hash" => { + i += 1; + task_hash_hex = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--task-hash needs 64 hex chars") + })?); + } + "--passed" => passed = Some(true), + "--failed" => passed = Some(false), + "--evidence" => { + i += 1; + evidence_uri = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--evidence needs a URI") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let agent = agent.ok_or_else(|| { + anyhow::anyhow!("covenant said validate-work requires --agent") + })?; + let task_hash_hex = task_hash_hex.ok_or_else(|| { + anyhow::anyhow!("covenant said validate-work requires --task-hash") + })?; + let passed = passed.ok_or_else(|| { + anyhow::anyhow!("covenant said validate-work requires --passed or --failed") + })?; + let evidence_uri = evidence_uri.ok_or_else(|| { + anyhow::anyhow!("covenant said validate-work requires --evidence") + })?; + write_frame( + &mut stream, + &Request::SaidValidateWork { + agent, + task_hash_hex, + passed, + evidence_uri, + }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidValidationPosted { + validation_pda, + validator, + signature, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_validation_posted", + "validation_pda": validation_pda, + "validator": validator, + "signature": signature, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("validation_pda: {validation_pda}"); + println!("validator: {validator}"); + println!("signature: {signature}"); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "lookup" => { + let mut wallet: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--wallet" => { + i += 1; + wallet = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--wallet needs an address") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let wallet = wallet.ok_or_else(|| { + anyhow::anyhow!("covenant said lookup requires --wallet ") + })?; + write_frame(&mut stream, &Request::SaidLookup { wallet }).await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidAgent { + wallet, + pda, + owner, + name, + description, + metadata_uri, + is_verified, + sponsored, + reputation_score, + feedback_count, + activity_count, + registered_at, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_agent", + "wallet": wallet, + "pda": pda, + "owner": owner, + "name": name, + "description": description, + "metadata_uri": metadata_uri, + "is_verified": is_verified, + "sponsored": sponsored, + "reputation_score": reputation_score, + "feedback_count": feedback_count, + "activity_count": activity_count, + "registered_at": registered_at, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("wallet: {wallet}"); + println!("pda: {}", pda.unwrap_or_default()); + println!("owner: {}", owner.unwrap_or_default()); + println!("name: {}", name.unwrap_or_default()); + println!("description: {}", description.unwrap_or_default()); + println!("metadata_uri: {}", metadata_uri.unwrap_or_default()); + println!("is_verified: {is_verified}"); + println!("sponsored: {sponsored}"); + println!("reputation_score: {reputation_score}"); + println!("feedback_count: {feedback_count}"); + println!("activity_count: {activity_count}"); + println!("registered_at: {}", registered_at.unwrap_or_default()); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "anchor" => { + let mut start: Option = None; + let mut end: Option = None; + let mut root: Option = None; + let mut live = false; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--start" => { + i += 1; + start = Some( + args.get(i) + .ok_or_else(|| anyhow::anyhow!("--start needs a value"))? + .parse() + .context("--start parse")?, + ); + } + "--end" => { + i += 1; + end = Some( + args.get(i) + .ok_or_else(|| anyhow::anyhow!("--end needs a value"))? + .parse() + .context("--end parse")?, + ); + } + "--root" => { + i += 1; + root = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--root needs a 64-char hex value") + })?); + } + "--live" => live = true, + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let start = start + .ok_or_else(|| anyhow::anyhow!("covenant said anchor requires --start"))?; + let end = end + .ok_or_else(|| anyhow::anyhow!("covenant said anchor requires --end"))?; + let root = root.ok_or_else(|| { + anyhow::anyhow!("covenant said anchor requires --root ") + })?; + write_frame( + &mut stream, + &Request::SaidAnchor { + start_audit_index: start, + end_audit_index: end, + merkle_root_hex: root, + live, + }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidAnchored { + anchor_index, + start_seq, + end_seq, + merkle_root_hex, + tx_sig, + slot, + fixture, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_anchored", + "anchor_index": anchor_index, + "start_seq": start_seq, + "end_seq": end_seq, + "merkle_root_hex": merkle_root_hex, + "tx_sig": tx_sig, + "slot": slot, + "fixture": fixture, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("anchor_index: {anchor_index}"); + println!("start_seq: {start_seq}"); + println!("end_seq: {end_seq}"); + println!("merkle_root_hex: {merkle_root_hex}"); + println!("tx_sig: {tx_sig}"); + println!("slot: {slot}"); + println!("fixture: {fixture}"); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "inbox" => { + let mut chain = "solana".to_string(); + let mut address: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--chain" => { + i += 1; + chain = args + .get(i) + .cloned() + .ok_or_else(|| anyhow::anyhow!("--chain needs a value"))?; + } + "--address" | "--wallet" => { + i += 1; + address = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--address needs a value") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let address = address.ok_or_else(|| { + anyhow::anyhow!("covenant said inbox requires --address ") + })?; + write_frame(&mut stream, &Request::SaidInbox { chain, address }).await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidInbox { chain, address, messages } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_inbox", + "chain": chain, + "address": address, + "messages": messages, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("chain: {chain}"); + println!("address: {address}"); + println!("messages: {}", messages.len()); + for m in &messages { + println!( + " {} from {}:{} -> {}:{}", + m.id, + m.source_chain, + m.source_address, + m.target_chain, + m.target_address + ); + } + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "free-tier" => { + let mut address: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--address" | "--wallet" => { + i += 1; + address = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--address needs a value") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let address = address.ok_or_else(|| { + anyhow::anyhow!("covenant said free-tier requires --address ") + })?; + write_frame(&mut stream, &Request::SaidFreeTier { address }).await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidFreeTier { + address, + used, + remaining, + limit, + paid_price, + payment_chains, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_free_tier", + "address": address, + "used": used, + "remaining": remaining, + "limit": limit, + "paid_price": paid_price, + "payment_chains": payment_chains, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("address: {address}"); + println!("used: {used}"); + println!("remaining: {remaining}"); + println!("limit: {limit}"); + println!("paid_price: {}", paid_price.unwrap_or_default()); + println!("payment_chains: {}", payment_chains.join(",")); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "send" => { + let mut source_chain = "solana".to_string(); + let mut source_address: Option = None; + let mut target_chain: Option = None; + let mut target_address: Option = None; + let mut payload_json: Option = None; + let mut payload_file: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--source-chain" => { + i += 1; + source_chain = args + .get(i) + .cloned() + .ok_or_else(|| anyhow::anyhow!("--source-chain needs a value"))?; + } + "--source-address" | "--from" => { + i += 1; + source_address = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--source-address needs a value") + })?); + } + "--target-chain" | "--to-chain" => { + i += 1; + target_chain = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--target-chain needs a value") + })?); + } + "--target-address" | "--to" => { + i += 1; + target_address = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--target-address needs a value") + })?); + } + "--payload" => { + i += 1; + payload_json = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--payload needs a JSON string") + })?); + } + "--payload-file" => { + i += 1; + payload_file = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--payload-file needs a path") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let source_address = source_address.ok_or_else(|| { + anyhow::anyhow!("covenant said send requires --source-address") + })?; + let target_chain = target_chain.ok_or_else(|| { + anyhow::anyhow!("covenant said send requires --target-chain") + })?; + let target_address = target_address.ok_or_else(|| { + anyhow::anyhow!("covenant said send requires --target-address") + })?; + let payload_json = match (payload_json, payload_file) { + (Some(s), _) => s, + (None, Some(path)) => std::fs::read_to_string(&path) + .with_context(|| format!("read payload from {path}"))?, + (None, None) => bail!("covenant said send requires --payload or --payload-file"), + }; + let _: serde_json::Value = serde_json::from_str(&payload_json) + .context("payload must be valid JSON")?; + write_frame( + &mut stream, + &Request::SaidSend { + source_chain, + source_address, + target_chain, + target_address, + payload_json, + }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidSent { message_id, free_tier_remaining, delivered_at } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_sent", + "message_id": message_id, + "free_tier_remaining": free_tier_remaining, + "delivered_at": delivered_at, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("message_id: {message_id}"); + println!( + "free_tier_remaining: {}", + free_tier_remaining + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".into()) + ); + println!( + "delivered_at: {}", + delivered_at + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".into()) + ); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + "anchor-status" => { + let mut recent_limit: usize = 10; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--recent" => { + i += 1; + recent_limit = args + .get(i) + .ok_or_else(|| anyhow::anyhow!("--recent needs a value"))? + .parse() + .context("--recent parse")?; + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + write_frame( + &mut stream, + &Request::SaidAnchorStatus { recent_limit }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidAnchorStatus { + next_index, + last_confirmed_index, + pending, + recent, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_anchor_status", + "next_index": next_index, + "last_confirmed_index": last_confirmed_index, + "pending": pending, + "recent": recent, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("next_index: {next_index}"); + println!( + "last_confirmed_index: {}", + last_confirmed_index + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".into()) + ); + println!("pending: {pending}"); + for r in &recent { + println!( + " #{} seq[{}..{}] root={} sig={} slot={} at={}ms", + r.anchor_index, + r.start_seq, + r.end_seq, + r.merkle_root_hex, + r.tx_sig, + r.slot, + r.submitted_at_ms, + ); + } + } + } + Response::Error { message } => bail!("daemon error: {message}"), + other => bail!("unexpected response: {other:?}"), + } + } + other => { + eprintln!("covenant said: unknown subcommand '{other}'"); + print_usage(); + std::process::exit(2); + } + } + } other => { eprintln!("covenant: unknown command '{other}'"); print_usage(); diff --git a/agent-os/crates/covenantd/Cargo.toml b/agent-os/crates/covenantd/Cargo.toml index 79382504a..63311757b 100644 --- a/agent-os/crates/covenantd/Cargo.toml +++ b/agent-os/crates/covenantd/Cargo.toml @@ -27,6 +27,7 @@ covenant-permissions = { path = "../covenant-permissions" } covenant-router = { path = "../covenant-router" } covenant-runtime = { path = "../covenant-runtime" } covenant-sap-bridge = { path = "../covenant-sap-bridge" } +covenant-said-bridge = { path = "../covenant-said-bridge" } covenant-settlement = { path = "../covenant-settlement" } # Default features only — the solana signer + its dep tree stay out of # the daemon build until the funding-key path lands behind its own diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index d44a41eeb..e003fdd9c 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -53,6 +53,7 @@ use covenant_permissions::{ use covenant_router::{AgentCard, Router}; use covenant_runtime::{AgentResult, Runner}; use covenant_sap_bridge::{Config as SapBridgeConfig, SapBridge}; +use covenant_said_bridge::{Config as SaidBridgeConfig, SaidBridge}; use covenant_settlement::{ build_receipt_batch, derive_batch_id, intent_dispatch_credits, memory_write_credits, ChainConfirmation, Settlement, @@ -210,6 +211,10 @@ pub fn sap_bridge_config_from_env() -> SapBridgeConfig { SapBridgeConfig::from_env(std::env::vars()) } +pub fn said_bridge_config_from_env() -> SaidBridgeConfig { + SaidBridgeConfig::from_env(std::env::vars()) +} + /// Config for the autonomous SAP audit-root anchoring driver. /// /// Default OFF. When enabled (`COVENANT_SAP_AUTO_ATTEST=1`), the daemon @@ -1067,6 +1072,7 @@ pub struct Server { /// surface `BridgeDisabledError` when it's absent or /// `enabled = false`. sap_bridge: Option, + said_bridge: Option, /// Outcomes of in-flight async (hermes) dispatches, keyed by intent id. /// `std::sync::Mutex` (not the tokio one used elsewhere) because the /// critical section is a trivial map mutation never held across `.await`. @@ -1112,6 +1118,7 @@ impl Server { x402_dispatch: None, hyre: None, sap_bridge: None, + said_bridge: None, intent_outcomes: Arc::new(std::sync::Mutex::new(OutcomeStore::default())), } } @@ -1217,6 +1224,350 @@ impl Server { self.sap_bridge.as_ref() } + pub fn with_said_bridge(mut self, bridge: SaidBridge) -> Self { + self.said_bridge = Some(bridge); + self + } + + pub fn said_bridge(&self) -> Option<&SaidBridge> { + self.said_bridge.as_ref() + } + + pub(crate) fn said_status(&self) -> Response { + match self.said_bridge.as_ref() { + Some(bridge) => { + let cfg = bridge.config(); + Response::SaidStatus { + enabled: cfg.enabled, + cluster: cfg.cluster.as_str().to_owned(), + program_id: cfg.program_id.clone(), + rpc_url: cfg.rpc_url.clone(), + api_base_url: cfg.api_base_url.clone(), + paid_gates: cfg.paid.summary(), + has_signer: std::env::var("COVENANT_SAID_KEYPAIR") + .map(|v| !v.trim().is_empty()) + .unwrap_or(false), + } + } + None => Response::SaidStatus { + enabled: false, + cluster: String::new(), + program_id: String::new(), + rpc_url: String::new(), + api_base_url: String::new(), + paid_gates: "none".into(), + has_signer: false, + }, + } + } + + fn said_paths(&self) -> std::result::Result<(std::path::PathBuf, std::path::PathBuf), String> { + let home = self + .home + .as_ref() + .ok_or_else(|| "daemon home is not set; cannot resolve $COVENANT_HOME/said".to_string())?; + let dir = home.join("said"); + Ok((dir.join("cursor.db"), dir.join("anchor_pending.jsonl"))) + } + + pub(crate) async fn said_anchor( + &self, + start_audit_index: u64, + end_audit_index: u64, + merkle_root_hex: String, + live: bool, + ) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + let (cursor_path, fixture_path) = match self.said_paths() { + Ok(p) => p, + Err(e) => return Response::Error { message: e }, + }; + let cursor = match covenant_said_bridge::cursor::AnchorCursor::open(&cursor_path) { + Ok(c) => c, + Err(e) => { + return Response::Error { + message: format!("said anchor cursor open: {e}"), + } + } + }; + let request = covenant_said_bridge::anchor::AnchorRequest { + start_audit_index, + end_audit_index, + merkle_root_hex, + }; + let mode = if live { + covenant_said_bridge::anchor::AnchorMode::Live + } else { + covenant_said_bridge::anchor::AnchorMode::Fixture + }; + match bridge.anchor(&cursor, &fixture_path, mode, request).await { + Ok(s) => Response::SaidAnchored { + anchor_index: s.anchor_index, + start_seq: s.start_seq, + end_seq: s.end_seq, + merkle_root_hex: s.merkle_root_hex, + tx_sig: s.tx_sig, + slot: s.slot, + fixture: matches!(mode, covenant_said_bridge::anchor::AnchorMode::Fixture), + }, + Err(e) => Response::Error { + message: format!("said anchor: {e}"), + }, + } + } + + pub(crate) fn said_anchor_status(&self, recent_limit: usize) -> Response { + let (cursor_path, _) = match self.said_paths() { + Ok(p) => p, + Err(e) => return Response::Error { message: e }, + }; + let cursor = match covenant_said_bridge::cursor::AnchorCursor::open(&cursor_path) { + Ok(c) => c, + Err(e) => { + return Response::Error { + message: format!("said anchor cursor open: {e}"), + } + } + }; + let next_index = match cursor.next_index() { + Ok(v) => v, + Err(e) => { + return Response::Error { + message: format!("said cursor: {e}"), + } + } + }; + let last_confirmed_index = match cursor.last_confirmed_index() { + Ok(v) => v, + Err(e) => { + return Response::Error { + message: format!("said cursor: {e}"), + } + } + }; + let pending = match cursor.pending() { + Ok(v) => v.len() as u64, + Err(e) => { + return Response::Error { + message: format!("said cursor: {e}"), + } + } + }; + let recent = match cursor.recent(recent_limit) { + Ok(rows) => rows + .into_iter() + .map(|r| covenant_ipc::SaidAnchorRow { + anchor_index: r.anchor_index, + start_seq: r.start_audit_index, + end_seq: r.end_audit_index, + merkle_root_hex: r.merkle_root_hex, + tx_sig: r.tx_sig, + slot: r.slot, + submitted_at_ms: r.submitted_at_ms, + }) + .collect(), + Err(e) => { + return Response::Error { + message: format!("said cursor: {e}"), + } + } + }; + Response::SaidAnchorStatus { + next_index, + last_confirmed_index, + pending, + recent, + } + } + + pub(crate) async fn said_inbox(&self, chain: String, address: String) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + match bridge.xchain_inbox(&chain, &address).await { + Ok(inbox) => Response::SaidInbox { + chain: inbox.chain.unwrap_or(chain), + address: inbox.address.unwrap_or(address), + messages: inbox + .messages + .into_iter() + .map(|m| covenant_ipc::SaidInboxMessage { + id: m.id, + source_chain: m.source_chain, + source_address: m.source_address, + target_chain: m.target_chain, + target_address: m.target_address, + payload: m.payload, + created_at: m.created_at, + }) + .collect(), + }, + Err(e) => Response::Error { + message: format!("said inbox: {e}"), + }, + } + } + + pub(crate) async fn said_free_tier(&self, address: String) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + match bridge.xchain_free_tier(&address).await { + Ok(ft) => Response::SaidFreeTier { + address: ft.address, + used: ft.used, + remaining: ft.remaining, + limit: ft.limit, + paid_price: ft.paid_price, + payment_chains: ft.payment_chains.into_iter().map(|c| c.name).collect(), + }, + Err(e) => Response::Error { + message: format!("said free-tier: {e}"), + }, + } + } + + pub(crate) async fn said_send( + &self, + source_chain: String, + source_address: String, + target_chain: String, + target_address: String, + payload_json: String, + ) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + let payload: serde_json::Value = match serde_json::from_str(&payload_json) { + Ok(v) => v, + Err(e) => { + return Response::Error { + message: format!("invalid payload JSON: {e}"), + } + } + }; + let req = covenant_said_bridge::xchain::SendRequest { + source_chain, + source_address, + target_chain, + target_address, + payload, + }; + match bridge.xchain_send(&req).await { + Ok(r) => Response::SaidSent { + message_id: r.message_id, + free_tier_remaining: r.free_tier_remaining, + delivered_at: r.delivered_at, + }, + Err(e) => Response::Error { + message: format!("said send: {e}"), + }, + } + } + + pub(crate) async fn said_register_on_chain(&self, metadata_uri: String) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + let input = covenant_said_bridge::instructions::RegisterAgentInput { metadata_uri }; + match bridge.register_on_chain(&input).await { + Ok(r) => Response::SaidOnChainRegistered { + agent_pda: r.agent_pda, + owner: r.owner, + signature: r.signature, + }, + Err(e) => Response::Error { + message: format!("said register: {e}"), + }, + } + } + + pub(crate) async fn said_get_verified(&self) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + match bridge.get_verified().await { + Ok(r) => Response::SaidVerified { + signature: r.signature, + slot: r.slot, + }, + Err(e) => Response::Error { + message: format!("said get_verified: {e}"), + }, + } + } + + pub(crate) async fn said_validate_work( + &self, + agent: String, + task_hash_hex: String, + passed: bool, + evidence_uri: String, + ) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + let input = covenant_said_bridge::instructions::ValidateWorkInput { + agent, + task_hash_hex, + passed, + evidence_uri, + }; + match bridge.validate_work(&input).await { + Ok(r) => Response::SaidValidationPosted { + validation_pda: r.validation_pda, + validator: r.validator, + signature: r.signature, + }, + Err(e) => Response::Error { + message: format!("said validate_work: {e}"), + }, + } + } + + pub(crate) async fn said_lookup(&self, wallet: String) -> Response { + let Some(bridge) = self.said_bridge.as_ref() else { + return Response::Error { + message: "said bridge is not wired into this daemon".into(), + }; + }; + match bridge.lookup(&wallet).await { + Ok(a) => Response::SaidAgent { + wallet: a.wallet, + pda: a.pda, + owner: a.owner, + name: a.name, + description: a.description, + metadata_uri: a.metadata_uri, + is_verified: a.is_verified, + sponsored: a.sponsored, + reputation_score: a.reputation_score, + feedback_count: a.feedback_count, + activity_count: a.activity_count, + registered_at: a.registered_at, + }, + Err(e) => Response::Error { + message: format!("said lookup: {e}"), + }, + } + } + /// Resolve the SAP bridge status. Returns a disabled snapshot when /// no bridge was wired in at boot — handlers must never panic on /// `sap_bridge().is_none()`. @@ -2091,6 +2442,49 @@ impl Server { ) .await } + Request::SaidStatus => self.said_status(), + Request::SaidLookup { wallet } => self.said_lookup(wallet).await, + Request::SaidAnchor { + start_audit_index, + end_audit_index, + merkle_root_hex, + live, + } => { + self.said_anchor(start_audit_index, end_audit_index, merkle_root_hex, live) + .await + } + Request::SaidAnchorStatus { recent_limit } => self.said_anchor_status(recent_limit), + Request::SaidInbox { chain, address } => self.said_inbox(chain, address).await, + Request::SaidFreeTier { address } => self.said_free_tier(address).await, + Request::SaidSend { + source_chain, + source_address, + target_chain, + target_address, + payload_json, + } => { + self.said_send( + source_chain, + source_address, + target_chain, + target_address, + payload_json, + ) + .await + } + Request::SaidRegisterOnChain { metadata_uri } => { + self.said_register_on_chain(metadata_uri).await + } + Request::SaidGetVerified => self.said_get_verified().await, + Request::SaidValidateWork { + agent, + task_hash_hex, + passed, + evidence_uri, + } => { + self.said_validate_work(agent, task_hash_hex, passed, evidence_uri) + .await + } Request::FlushReceipts { limit } => self.flush_receipts(limit, peer).await, Request::ReceiptBatches { limit } => self.receipt_batches(limit, peer).await, Request::PayX402 { diff --git a/agent-os/crates/covenantd/src/main.rs b/agent-os/crates/covenantd/src/main.rs index cd3064ebc..3b917bd53 100644 --- a/agent-os/crates/covenantd/src/main.rs +++ b/agent-os/crates/covenantd/src/main.rs @@ -289,6 +289,19 @@ async fn main() -> Result<()> { "sap bridge ready" ); + let said_config = covenantd::said_bridge_config_from_env(); + let said_bridge = covenant_said_bridge::SaidBridge::new(said_config.clone()) + .context("build SAID bridge")?; + info!( + enabled = said_config.enabled, + cluster = said_config.cluster.as_str(), + program_id = %said_config.program_id, + rpc_url = %redact_url(&said_config.rpc_url), + api_base_url = %said_config.api_base_url, + paid_gates = %said_config.paid.summary(), + "said bridge ready" + ); + let server = covenantd::Server::new( router, runner, @@ -307,7 +320,8 @@ async fn main() -> Result<()> { .with_home(home.clone()) .with_budget_checkpoints(budget_checkpoints) .with_subprocess_tracker(subprocess_tracker) - .with_sap_bridge(sap_bridge); + .with_sap_bridge(sap_bridge) + .with_said_bridge(said_bridge); let server = match x402_dispatch_config_from_env() { Some(cfg) => { diff --git a/agent-os/crates/covenantd/tests/said_dispatch.rs b/agent-os/crates/covenantd/tests/said_dispatch.rs new file mode 100644 index 000000000..33209adbd --- /dev/null +++ b/agent-os/crates/covenantd/tests/said_dispatch.rs @@ -0,0 +1,824 @@ +//! Covenantd-level dispatch coverage for the SAID bridge verbs. The +//! offline arms — the unconfigured status snapshot, the "bridge not +//! wired" guard shared by every bridge-backed verb, the anchor-status +//! cursor read, and the fixture-mode anchor round-trip (claim, JSONL +//! write, confirm, status reflection) — run without any socket. The +//! REST envelope mappings (lookup, inbox, free-tier, send) drive a +//! one-shot loopback listener so both the success decode and the +//! verb-prefixed `http ` error mapping are exercised without +//! the public SAID API. Paid on-chain verbs are covered at the +//! closed-gate rejection and, via a shell-stub worker, the success +//! field-copy onto each `Response::Said*` variant; the real worker +//! subprocess and live REST against api.saidprotocol.com stay +//! operator-gated and live in the `covenant-said-bridge` crate tests. + +use covenant_identity::LocalIdentity; +use covenant_ipc::{Request, Response}; +use covenant_llm::MockEmbedder; +use covenant_memory::InMemoryStore; +use covenant_router::Router; +use covenant_runtime::MockRunner; +use covenant_said_bridge::config::{DEFAULT_REST_TIMEOUT, DEFAULT_WORKER_TIMEOUT}; +use covenant_said_bridge::{ + Cluster, Config, PaidGates, SaidBridge, DEFAULT_SAID_API_BASE_URL, + DEFAULT_SAID_DEVNET_PROGRAM_ID, +}; +use covenant_settlement::InMemorySettlement; +use covenant_types::AgentId; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; + +fn server(home: Option, bridge: Option) -> covenantd::Server { + let router = Arc::new(Router::from_cards(vec![])); + let runner = Arc::new(MockRunner::new("stub")); + let memory = Arc::new(InMemoryStore::new()); + let settlement = Arc::new(InMemorySettlement::new()); + let audit = Arc::new(covenant_audit::InMemoryAuditLog::new()); + let capabilities = Arc::new(covenant_permissions::InMemoryCapabilityStore::new()); + let embedder = Arc::new(MockEmbedder::new(64)); + let identity = Arc::new(LocalIdentity::generate("user@local")); + let ignore = Arc::new(covenant_memory::IgnoreSet::default()); + let tools = Arc::new(covenant_mcp::ToolRegistry::default()); + let mailbox: Arc = Arc::new(covenant_a2a::InMemoryMailbox::new()); + let peers: Arc = + Arc::new(covenant_peer_auth::InMemoryPeerRegistry::new()); + let budget: Arc = + Arc::new(covenant_budget::InMemoryLedger::new()); + + let mut server = covenantd::Server::new( + router, + runner, + memory, + settlement, + audit, + capabilities, + embedder, + identity, + ignore, + tools, + mailbox, + peers, + budget, + ); + if let Some(home) = home { + server = server.with_home(home); + } + if let Some(bridge) = bridge { + server = server.with_said_bridge(bridge); + } + server +} + +fn peer() -> AgentId { + let id = LocalIdentity::generate("peer@local"); + AgentId::new(id.display(), id.pubkey_bytes()) +} + +fn devnet_bridge(enabled: bool) -> SaidBridge { + let mut env = vec![("COVENANT_SOLANA_CLUSTER", "devnet")]; + if enabled { + env.push(("COVENANT_SAID_ENABLED", "1")); + } + SaidBridge::new(Config::from_env(env)).unwrap() +} + +/// Enabled bridge with every paid gate open and the worker subprocess +/// replaced by a shell stub that drains stdin and prints `envelope_json` +/// verbatim — the success shape `worker::invoke` parses. Mirrors the +/// crate's `worker_roundtrip` stub so the paid on-chain success arms are +/// exercised at the covenantd dispatch boundary without a real worker, +/// signer, or paid transaction. `Config::from_env` can't carry the +/// space-laden `sh -c` command (it splits `COVENANT_SAID_WORKER_CMD` on +/// whitespace), so the config is built as a literal. +fn worker_stub_bridge(envelope_json: &str) -> SaidBridge { + let script = format!("cat >/dev/null; printf '%s' '{envelope_json}'"); + let config = Config { + enabled: true, + cluster: Cluster::Devnet, + program_id: DEFAULT_SAID_DEVNET_PROGRAM_ID.into(), + rpc_url: "https://api.devnet.solana.com".into(), + api_base_url: DEFAULT_SAID_API_BASE_URL.into(), + paid: PaidGates { + register: true, + verify: true, + anchor: true, + validate_work: true, + }, + worker_command: vec!["sh".into(), "-c".into(), script], + worker_timeout: DEFAULT_WORKER_TIMEOUT, + rest_timeout: DEFAULT_REST_TIMEOUT, + }; + SaidBridge::new(config).unwrap() +} + +const NOT_WIRED: &str = "said bridge is not wired into this daemon"; + +#[tokio::test] +async fn said_status_without_bridge_reports_disabled_snapshot() { + let server = server(None, None); + let resp = server.respond(Request::SaidStatus, &peer()).await; + assert_eq!( + resp, + Response::SaidStatus { + enabled: false, + cluster: String::new(), + program_id: String::new(), + rpc_url: String::new(), + api_base_url: String::new(), + paid_gates: "none".into(), + has_signer: false, + } + ); +} + +#[tokio::test] +async fn bridge_backed_verbs_without_bridge_report_not_wired() { + let server = server(None, None); + let peer = peer(); + let requests = [ + Request::SaidLookup { + wallet: "wallet".into(), + }, + Request::SaidAnchor { + start_audit_index: 0, + end_audit_index: 1, + merkle_root_hex: "ab".repeat(32), + live: false, + }, + Request::SaidInbox { + chain: "solana".into(), + address: "addr".into(), + }, + Request::SaidFreeTier { + address: "addr".into(), + }, + Request::SaidSend { + source_chain: "solana".into(), + source_address: "from".into(), + target_chain: "base".into(), + target_address: "to".into(), + payload_json: "{}".into(), + }, + Request::SaidRegisterOnChain { + metadata_uri: "ipfs://card".into(), + }, + Request::SaidGetVerified, + Request::SaidValidateWork { + agent: "agent".into(), + task_hash_hex: "00".repeat(32), + passed: true, + evidence_uri: "ipfs://evidence".into(), + }, + ]; + + for req in requests { + let resp = server.respond(req, &peer).await; + assert_eq!( + resp, + Response::Error { + message: NOT_WIRED.into(), + } + ); + } +} + +#[tokio::test] +async fn said_anchor_status_without_home_errors() { + let server = server(None, None); + let resp = server + .respond(Request::SaidAnchorStatus { recent_limit: 10 }, &peer()) + .await; + assert_eq!( + resp, + Response::Error { + message: "daemon home is not set; cannot resolve $COVENANT_HOME/said".into(), + } + ); +} + +#[tokio::test] +async fn said_anchor_status_with_home_reports_empty_cursor() { + let dir = tempdir().unwrap(); + let server = server(Some(dir.path().to_path_buf()), None); + let resp = server + .respond(Request::SaidAnchorStatus { recent_limit: 10 }, &peer()) + .await; + assert_eq!( + resp, + Response::SaidAnchorStatus { + next_index: 0, + last_confirmed_index: None, + pending: 0, + recent: vec![], + } + ); + assert!(dir.path().join("said").join("cursor.db").exists()); +} + +#[tokio::test] +async fn said_status_with_bridge_reflects_config() { + let config = Config::from_env([ + ("COVENANT_SOLANA_CLUSTER", "devnet"), + ("COVENANT_SAID_ENABLED", "1"), + ( + "COVENANT_SAID_PROGRAM_ID", + "Prog1111111111111111111111111111111111111111", + ), + ("COVENANT_SAID_RPC_URL", "https://rpc.example"), + ("COVENANT_SAID_API_BASE_URL", "https://api.example"), + ("COVENANT_SAID_ALLOW_PAID_ANCHOR", "1"), + ]); + let bridge = SaidBridge::new(config).unwrap(); + let server = server(None, Some(bridge)); + + // `has_signer` reads the process-global COVENANT_SAID_KEYPAIR env, so + // it is intentionally left unchecked to keep the test env-independent. + match server.respond(Request::SaidStatus, &peer()).await { + Response::SaidStatus { + enabled, + cluster, + program_id, + rpc_url, + api_base_url, + paid_gates, + has_signer: _, + } => { + assert!(enabled); + assert_eq!(cluster, "devnet"); + assert_eq!(program_id, "Prog1111111111111111111111111111111111111111"); + assert_eq!(rpc_url, "https://rpc.example"); + assert_eq!(api_base_url, "https://api.example"); + assert_eq!(paid_gates, "anchor"); + } + other => panic!("expected SaidStatus, got {other:?}"), + } +} + +#[tokio::test] +async fn said_anchor_fixture_mode_confirms_and_reports() { + let dir = tempdir().unwrap(); + let server = server(Some(dir.path().to_path_buf()), Some(devnet_bridge(true))); + let peer = peer(); + + let resp = server + .respond( + Request::SaidAnchor { + start_audit_index: 0, + end_audit_index: 7, + merkle_root_hex: "ab".repeat(32), + live: false, + }, + &peer, + ) + .await; + assert_eq!( + resp, + Response::SaidAnchored { + anchor_index: 0, + start_seq: 0, + end_seq: 7, + merkle_root_hex: "ab".repeat(32), + tx_sig: "fixture:0".into(), + slot: 0, + fixture: true, + } + ); + + let pending = dir.path().join("said").join("anchor_pending.jsonl"); + let lines = std::fs::read_to_string(&pending).unwrap(); + assert_eq!(lines.lines().count(), 1); + + match server + .respond(Request::SaidAnchorStatus { recent_limit: 10 }, &peer) + .await + { + Response::SaidAnchorStatus { + next_index, + last_confirmed_index, + pending, + recent, + } => { + assert_eq!(next_index, 1); + assert_eq!(last_confirmed_index, Some(0)); + assert_eq!(pending, 0); + assert_eq!(recent.len(), 1); + let row = &recent[0]; + assert_eq!(row.anchor_index, 0); + assert_eq!(row.start_seq, 0); + assert_eq!(row.end_seq, 7); + assert_eq!(row.merkle_root_hex, "ab".repeat(32)); + assert_eq!(row.tx_sig, "fixture:0"); + assert_eq!(row.slot, 0); + } + other => panic!("expected SaidAnchorStatus, got {other:?}"), + } +} + +#[tokio::test] +async fn said_anchor_disabled_bridge_reports_disabled() { + let dir = tempdir().unwrap(); + let server = server(Some(dir.path().to_path_buf()), Some(devnet_bridge(false))); + let resp = server + .respond( + Request::SaidAnchor { + start_audit_index: 0, + end_audit_index: 7, + merkle_root_hex: "ab".repeat(32), + live: false, + }, + &peer(), + ) + .await; + assert_eq!( + resp, + Response::Error { + message: "said anchor: said bridge is disabled".into(), + } + ); +} + +#[tokio::test] +async fn said_anchor_inverted_range_is_rejected() { + let dir = tempdir().unwrap(); + let server = server(Some(dir.path().to_path_buf()), Some(devnet_bridge(true))); + let resp = server + .respond( + Request::SaidAnchor { + start_audit_index: 5, + end_audit_index: 1, + merkle_root_hex: "ab".repeat(32), + live: false, + }, + &peer(), + ) + .await; + match resp { + Response::Error { message } => { + assert!( + message.starts_with("said anchor: invalid input:"), + "{message}" + ); + assert!(message.contains("start_audit_index"), "{message}"); + } + other => panic!("expected Error, got {other:?}"), + } +} + +struct Captured { + method: String, + path: String, + body: String, +} + +/// One-shot loopback HTTP server: binds an ephemeral port, answers a single +/// request with `status` + `body`, and hands back the parsed request so a +/// test can assert what the dispatch layer drove over REST. Mirrors the +/// `covenant-said-bridge` crate harness so dispatch tests stay socket-real +/// without a mock framework or the public SAID API. +async fn serve_once(status: &'static str, body: &'static str) -> (String, JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let captured = read_request(&mut stream).await; + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.expect("write"); + stream.flush().await.expect("flush"); + captured + }); + (format!("http://{addr}"), handle) +} + +async fn read_request(stream: &mut TcpStream) -> Captured { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + let header_end = loop { + let n = stream.read(&mut chunk).await.expect("read headers"); + if n == 0 { + break buf.len(); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + }; + let head = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let mut request_line = head.lines().next().unwrap_or_default().split_whitespace(); + let method = request_line.next().unwrap_or_default().to_owned(); + let path = request_line.next().unwrap_or_default().to_owned(); + let content_length = head + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap_or(0)) + }) + .unwrap_or(0); + let mut body = buf[header_end..].to_vec(); + while body.len() < content_length { + let n = stream.read(&mut chunk).await.expect("read body"); + if n == 0 { + break; + } + body.extend_from_slice(&chunk[..n]); + } + Captured { + method, + path, + body: String::from_utf8_lossy(&body).into_owned(), + } +} + +fn bridge_at(base: &str) -> SaidBridge { + SaidBridge::new(Config::from_env([ + ("COVENANT_SOLANA_CLUSTER", "devnet"), + ("COVENANT_SAID_ENABLED", "1"), + ("COVENANT_SAID_API_BASE_URL", base), + ])) + .unwrap() +} + +#[tokio::test] +async fn said_lookup_maps_agent_onto_response() { + let (base, http) = serve_once( + "200 OK", + r#"{"wallet":"Wallet111","owner":"Owner222","name":"scout","isVerified":true,"reputationScore":4.5,"feedbackCount":12,"activityCount":99,"metadataUri":"https://example.test/a.json","registeredAt":"2026-01-01T00:00:00Z"}"#, + ) + .await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidLookup { + wallet: "Wallet111".into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/api/agents/Wallet111"); + assert_eq!( + resp, + Response::SaidAgent { + wallet: "Wallet111".into(), + pda: None, + owner: Some("Owner222".into()), + name: Some("scout".into()), + description: None, + metadata_uri: Some("https://example.test/a.json".into()), + is_verified: true, + sponsored: false, + reputation_score: 4.5, + feedback_count: 12, + activity_count: 99, + registered_at: Some("2026-01-01T00:00:00Z".into()), + } + ); +} + +#[tokio::test] +async fn said_inbox_maps_messages_onto_response() { + let (base, http) = serve_once( + "200 OK", + r#"{"chain":"solana","address":"Addr1","messages":[{"id":"m1","sourceChain":"base","sourceAddress":"0xabc","targetChain":"solana","targetAddress":"Addr1","payload":{"hello":"world"},"createdAt":1700000000}]}"#, + ) + .await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidInbox { + chain: "solana".into(), + address: "Addr1".into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/xchain/inbox/solana/Addr1"); + match resp { + Response::SaidInbox { + chain, + address, + messages, + } => { + assert_eq!(chain, "solana"); + assert_eq!(address, "Addr1"); + assert_eq!(messages.len(), 1); + let msg = &messages[0]; + assert_eq!(msg.id, "m1"); + assert_eq!(msg.source_chain, "base"); + assert_eq!(msg.target_address, "Addr1"); + assert_eq!(msg.created_at, Some(1700000000)); + } + other => panic!("expected SaidInbox, got {other:?}"), + } +} + +#[tokio::test] +async fn said_free_tier_maps_status_onto_response() { + let (base, http) = serve_once( + "200 OK", + r#"{"address":"Addr1","used":3,"remaining":7,"limit":10,"paidPrice":"0.01","paymentChains":[{"name":"base","network":"mainnet"}]}"#, + ) + .await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidFreeTier { + address: "Addr1".into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/xchain/free-tier/Addr1"); + assert_eq!( + resp, + Response::SaidFreeTier { + address: "Addr1".into(), + used: 3, + remaining: 7, + limit: 10, + paid_price: Some("0.01".into()), + payment_chains: vec!["base".into()], + } + ); +} + +#[tokio::test] +async fn said_send_posts_payload_and_maps_receipt() { + let (base, http) = serve_once( + "200 OK", + r#"{"messageId":"msg-1","freeTierRemaining":9,"deliveredAt":1700000001}"#, + ) + .await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidSend { + source_chain: "solana".into(), + source_address: "Addr1".into(), + target_chain: "base".into(), + target_address: "0xabc".into(), + payload_json: r#"{"ping":1}"#.into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "POST"); + assert_eq!(req.path, "/xchain/message"); + assert!( + req.body.contains(r#""sourceChain":"solana""#), + "body: {}", + req.body + ); + assert_eq!( + resp, + Response::SaidSent { + message_id: "msg-1".into(), + free_tier_remaining: Some(9), + delivered_at: Some(1700000001), + } + ); +} + +#[tokio::test] +async fn said_send_invalid_payload_json_is_rejected_before_network() { + let server = server(None, Some(devnet_bridge(true))); + let resp = server + .respond( + Request::SaidSend { + source_chain: "solana".into(), + source_address: "Addr1".into(), + target_chain: "base".into(), + target_address: "0xabc".into(), + payload_json: "{not json".into(), + }, + &peer(), + ) + .await; + match resp { + Response::Error { message } => { + assert!(message.starts_with("invalid payload JSON:"), "{message}"); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[tokio::test] +async fn said_lookup_http_error_maps_to_verb_prefixed_error() { + let (base, http) = serve_once("404 Not Found", r#"{"error":"no such agent"}"#).await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidLookup { + wallet: "Missing111".into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/api/agents/Missing111"); + assert_eq!( + resp, + Response::Error { + message: r#"said lookup: http 404: {"error":"no such agent"}"#.into(), + } + ); +} + +#[tokio::test] +async fn said_inbox_http_error_maps_to_verb_prefixed_error() { + let (base, http) = serve_once("404 Not Found", r#"{"error":"unknown chain"}"#).await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidInbox { + chain: "dogecoin".into(), + address: "Addr1".into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/xchain/inbox/dogecoin/Addr1"); + assert_eq!( + resp, + Response::Error { + message: r#"said inbox: http 404: {"error":"unknown chain"}"#.into(), + } + ); +} + +#[tokio::test] +async fn said_free_tier_payment_required_maps_to_verb_prefixed_error() { + let (base, http) = + serve_once("402 Payment Required", r#"{"error":"free tier exhausted"}"#).await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidFreeTier { + address: "Addr1".into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/xchain/free-tier/Addr1"); + assert_eq!( + resp, + Response::Error { + message: r#"said free-tier: http 402: {"error":"free tier exhausted"}"#.into(), + } + ); +} + +#[tokio::test] +async fn said_send_http_error_maps_to_verb_prefixed_error() { + let (base, http) = serve_once("500 Internal Server Error", r#"{"error":"relay down"}"#).await; + let server = server(None, Some(bridge_at(&base))); + let resp = server + .respond( + Request::SaidSend { + source_chain: "solana".into(), + source_address: "Addr1".into(), + target_chain: "base".into(), + target_address: "0xabc".into(), + payload_json: r#"{"ping":1}"#.into(), + }, + &peer(), + ) + .await; + let req = http.await.expect("server"); + + assert_eq!(req.method, "POST"); + assert_eq!(req.path, "/xchain/message"); + assert_eq!( + resp, + Response::Error { + message: r#"said send: http 500: {"error":"relay down"}"#.into(), + } + ); +} + +#[tokio::test] +async fn paid_verbs_with_closed_gate_report_gated_error() { + let server = server(None, Some(devnet_bridge(true))); + let peer = peer(); + let cases = [ + ( + Request::SaidRegisterOnChain { + metadata_uri: "ipfs://card".into(), + }, + "said register: paid instruction register_agent is gated off", + ), + ( + Request::SaidGetVerified, + "said get_verified: paid instruction get_verified is gated off", + ), + ( + Request::SaidValidateWork { + agent: "agent".into(), + task_hash_hex: "00".repeat(32), + passed: true, + evidence_uri: "ipfs://evidence".into(), + }, + "said validate_work: paid instruction validate_work is gated off", + ), + ]; + + for (req, expected) in cases { + let resp = server.respond(req, &peer).await; + assert_eq!( + resp, + Response::Error { + message: expected.into(), + } + ); + } +} + +#[tokio::test] +async fn said_register_on_chain_maps_success_onto_response() { + let bridge = worker_stub_bridge( + r#"{"ok":true,"data":{"agentPda":"Agent111","owner":"Own222","signature":"sig333"}}"#, + ); + let server = server(None, Some(bridge)); + let resp = server + .respond( + Request::SaidRegisterOnChain { + metadata_uri: "ipfs://card".into(), + }, + &peer(), + ) + .await; + assert_eq!( + resp, + Response::SaidOnChainRegistered { + agent_pda: "Agent111".into(), + owner: "Own222".into(), + signature: "sig333".into(), + } + ); +} + +#[tokio::test] +async fn said_get_verified_maps_success_onto_response() { + let bridge = worker_stub_bridge(r#"{"ok":true,"data":{"signature":"sigV","slot":42}}"#); + let server = server(None, Some(bridge)); + let resp = server.respond(Request::SaidGetVerified, &peer()).await; + assert_eq!( + resp, + Response::SaidVerified { + signature: "sigV".into(), + slot: 42, + } + ); +} + +#[tokio::test] +async fn said_validate_work_maps_success_onto_response() { + let bridge = worker_stub_bridge( + r#"{"ok":true,"data":{"validationPda":"Val111","validator":"Ver222","signature":"sigW"}}"#, + ); + let server = server(None, Some(bridge)); + let resp = server + .respond( + Request::SaidValidateWork { + agent: "AgentPda".into(), + task_hash_hex: "ab".repeat(32), + passed: true, + evidence_uri: "ipfs://evidence".into(), + }, + &peer(), + ) + .await; + assert_eq!( + resp, + Response::SaidValidationPosted { + validation_pda: "Val111".into(), + validator: "Ver222".into(), + signature: "sigW".into(), + } + ); +} diff --git a/agent-os/scripts/validate-memory-compaction-plan-outcome-type-level-pin-line-refs.mjs b/agent-os/scripts/validate-memory-compaction-plan-outcome-type-level-pin-line-refs.mjs index de8e0e162..7cc4dee50 100644 --- a/agent-os/scripts/validate-memory-compaction-plan-outcome-type-level-pin-line-refs.mjs +++ b/agent-os/scripts/validate-memory-compaction-plan-outcome-type-level-pin-line-refs.mjs @@ -84,7 +84,7 @@ const targets = [ field: "expected_receipt_changes", selector: 'value["expected_receipt_changes"].is_object(),', docsRegex: - /- `expected_receipt_changes` \(object\): a forward-compatibility placeholder pinned by the schema test at `main\.rs:7691` \(`memory_compaction_plan_json_pins_expected_receipt_changes_schema`\)\. The block has exactly three keys today and is currently a no-claim stub; consumers must validate the inner shape rather than dispatch directly to apply-mode logic\. Pinned as a structured object by `main\.rs:(\d+)-(\d+)` — never a string blob\./, + /- `expected_receipt_changes` \(object\): a forward-compatibility placeholder pinned by the schema test at `main\.rs:8390` \(`memory_compaction_plan_json_pins_expected_receipt_changes_schema`\)\. The block has exactly three keys today and is currently a no-claim stub; consumers must validate the inner shape rather than dispatch directly to apply-mode logic\. Pinned as a structured object by `main\.rs:(\d+)-(\d+)` — never a string blob\./, docsLabel: "memory_compaction_plan.expected_receipt_changes type-level pin citation", docsTemplate: diff --git a/docs/ipc-and-http-gateway.md b/docs/ipc-and-http-gateway.md index d781a14d6..defe88155 100644 --- a/docs/ipc-and-http-gateway.md +++ b/docs/ipc-and-http-gateway.md @@ -79,7 +79,7 @@ Per-verb fields layer on top, asymmetrically: - `verb: "stake"` — adds `agent_key` (base58), `amount` (u64), `lock_until` (u64). Both values are echoed verbatim from the CLI arguments and serialize to the on-chain `stake` instruction. - `verb: "buy-credits"` — adds `owner` (base58 COVNT owner pubkey), `amount_covnt` (u64). The value is echoed verbatim from `--amount-covnt` and serializes to the on-chain `buy_credits` instruction. -The verb-source-of-truth lives in the CLI emitters: `register_agent_confirmed_json` and `register_agent_timeout_json` at `agent-os/crates/covenant/src/main.rs:679` and `:696`, `stake_confirmed_json` and `stake_timeout_json` at `:879` and `:900`, `buy_credits_confirmed_json` and `buy_credits_timeout_json` at `:1187` and `:1206`. Six unit tests at `main.rs:10061`, `:10082`, `:10473`, `:10492`, `:10847`, `:10864` pin the kind strings, and six sibling `*_pins_top_level_schema` tests at `main.rs:10100`, `:10150`, `:10511`, `:10573`, `:10881`, `:10937` assert the full documented top-level key set so an undocumented field added to any helper fails review. +The verb-source-of-truth lives in the CLI emitters: `register_agent_confirmed_json` and `register_agent_timeout_json` at `agent-os/crates/covenant/src/main.rs:679` and `:696`, `stake_confirmed_json` and `stake_timeout_json` at `:879` and `:900`, `buy_credits_confirmed_json` and `buy_credits_timeout_json` at `:1187` and `:1206`. Six unit tests at `main.rs:10760`, `:10781`, `:11172`, `:11191`, `:11546`, `:11563` pin the kind strings, and six sibling `*_pins_top_level_schema` tests at `main.rs:10799`, `:10849`, `:11210`, `:11272`, `:11580`, `:11636` assert the full documented top-level key set so an undocumented field added to any helper fails review. ## CLI Read Envelopes @@ -94,8 +94,8 @@ In addition to the per-envelope `*_pins_top_level_schema` unit tests, the docs/e `covenant chain status --json` emits: -- `kind`: literal string `"chain_status"`. Pinned at the value level by `main.rs:8142` (asserts `value["kind"].as_str() == Some("chain_status")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `status`: a structured `covenant_ipc::ChainStatus` object with the following fields. The top-level object has exactly two keys (`kind` and `status`); the inner `status` is pinned by the schema test at `main.rs:8143-8146` to be a JSON object, never a string blob. +- `kind`: literal string `"chain_status"`. Pinned at the value level by `main.rs:8841` (asserts `value["kind"].as_str() == Some("chain_status")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `status`: a structured `covenant_ipc::ChainStatus` object with the following fields. The top-level object has exactly two keys (`kind` and `status`); the inner `status` is pinned by the schema test at `main.rs:8842-8845` to be a JSON object, never a string blob. The inner `ChainStatus` shape, defined at `agent-os/crates/covenant-ipc/src/lib.rs:42`: @@ -108,17 +108,17 @@ The inner `ChainStatus` shape, defined at `agent-os/crates/covenant-ipc/src/lib. - `ready` (bool) — true when every required config field is present. - `missing` (array of strings) — names of the absent config fields when `ready` is false; an empty array when `ready` is true. -The envelope source-of-truth lives at `chain_status_json` in `agent-os/crates/covenant/src/main.rs:5475`. Two unit tests at `main.rs:8104` (`chain_status_json_renders_stable_shape`) and `main.rs:8126` (`chain_status_json_pins_top_level_schema`) enforce the top-level key set verbatim; the second test's failure message names this document as the forcing function for docs/emitter drift. +The envelope source-of-truth lives at `chain_status_json` in `agent-os/crates/covenant/src/main.rs:6174`. Two unit tests at `main.rs:8803` (`chain_status_json_renders_stable_shape`) and `main.rs:8825` (`chain_status_json_pins_top_level_schema`) enforce the top-level key set verbatim; the second test's failure message names this document as the forcing function for docs/emitter drift. `covenant verify --json` emits a cross-check report comparing the audit log against memory and receipt rows. Envelope shape: -- `kind`: literal string `"verify_report"`. Pinned at the value level by `main.rs:8214` (asserts `value["kind"].as_str() == Some("verify_report")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `window` (u64): the audit-window record count echoed back from the `--window` argument. Pinned as u64 by `main.rs:8215-8218` — never a string. -- `checks` (array of `VerifyCheck`): per-check results, see below. Pinned as an array by `main.rs:8223-8226` — never null or a string. -- `drift` (array of `VerifyDrift`): correlation gaps, see below. Pinned as an array by `main.rs:8227` — never null or a string blob. -- `orphans_total` (u64): total number of unmatched rows the checks discovered. Pinned as u64 by `main.rs:8219-8222` — never a string-of-integer. +- `kind`: literal string `"verify_report"`. Pinned at the value level by `main.rs:8913` (asserts `value["kind"].as_str() == Some("verify_report")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `window` (u64): the audit-window record count echoed back from the `--window` argument. Pinned as u64 by `main.rs:8914-8917` — never a string. +- `checks` (array of `VerifyCheck`): per-check results, see below. Pinned as an array by `main.rs:8922-8925` — never null or a string. +- `drift` (array of `VerifyDrift`): correlation gaps, see below. Pinned as an array by `main.rs:8926` — never null or a string blob. +- `orphans_total` (u64): total number of unmatched rows the checks discovered. Pinned as u64 by `main.rs:8918-8921` — never a string-of-integer. -Top-level keys are pinned to exactly these five by the test at `agent-os/crates/covenant/src/main.rs:8198` (`verify_report_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these five by the test at `agent-os/crates/covenant/src/main.rs:8897` (`verify_report_json_pins_top_level_schema`). `VerifyCheck` shape, defined at `agent-os/crates/covenant-ipc/src/lib.rs:26`: @@ -133,12 +133,12 @@ Top-level keys are pinned to exactly these five by the test at `agent-os/crates/ - `message` (string) — drift description. - `repair` (string) — operator-facing remediation hint. -The envelope source-of-truth lives at `verify_report_json` in `agent-os/crates/covenant/src/main.rs:5482`. The shape-pinning test at `main.rs:8198-8244` covers both the populated and empty cases (`assert_shape` runs against a one-check, one-drift report and an all-empty report). +The envelope source-of-truth lives at `verify_report_json` in `agent-os/crates/covenant/src/main.rs:6181`. The shape-pinning test at `main.rs:8897-8943` covers both the populated and empty cases (`assert_shape` runs against a one-check, one-drift report and an all-empty report). `covenant tools list --json` emits the registered MCP-style tool catalog. Envelope shape: -- `kind`: literal string `"tool_list"` (singular `tool_list`, not `tools_list`; consumers routing on `kind` must match the literal exactly). Pinned at the value level by `main.rs:7960` (asserts `value["kind"].as_str() == Some("tool_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `tools` (array of `ToolSpec`): the registered tools the daemon advertises via `tools/list`. The array is empty when no tools are registered; the unsuffixed CLI prints `(no tools registered)` for that case at `main.rs:3805`. Pinned as an array by `main.rs:7961-7964` — never null or a string blob. +- `kind`: literal string `"tool_list"` (singular `tool_list`, not `tools_list`; consumers routing on `kind` must match the literal exactly). Pinned at the value level by `main.rs:8659` (asserts `value["kind"].as_str() == Some("tool_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `tools` (array of `ToolSpec`): the registered tools the daemon advertises via `tools/list`. The array is empty when no tools are registered; the unsuffixed CLI prints `(no tools registered)` for that case at `main.rs:3835`. Pinned as an array by `main.rs:8660-8663` — never null or a string blob. The inner `ToolSpec` shape, defined at `agent-os/crates/covenant-mcp/src/lib.rs:27`: @@ -148,29 +148,29 @@ The inner `ToolSpec` shape, defined at `agent-os/crates/covenant-mcp/src/lib.rs: `ToolSpec` carries `#[serde(rename_all = "camelCase")]` (`covenant-mcp/src/lib.rs:26`) so the Rust field `input_schema` serializes on the wire as `inputSchema`. The naming matches the MCP wire format; JSON consumers must deserialize using `inputSchema`, not `input_schema`. -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7944` (`tool_list_json_pins_top_level_schema`), which exercises both a populated single-tool case and an empty list. +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:8643` (`tool_list_json_pins_top_level_schema`), which exercises both a populated single-tool case and an empty list. -The envelope source-of-truth lives at `tool_list_json` in `agent-os/crates/covenant/src/main.rs:5447`. Two unit tests at `main.rs:7920` (`tool_list_json_renders_stable_shape`) and `main.rs:7944` cover both cases. The CLI verb is wired at `main.rs:3119-3145`; without `--json`, the same response prints one line per tool in the form `` at `main.rs:3808`. +The envelope source-of-truth lives at `tool_list_json` in `agent-os/crates/covenant/src/main.rs:6146`. Two unit tests at `main.rs:8619` (`tool_list_json_renders_stable_shape`) and `main.rs:8643` cover both cases. The CLI verb is wired at `main.rs:3149-3175`; without `--json`, the same response prints one line per tool in the form `` at `main.rs:3838`. `covenant tools call [--args ] --json` emits the tool invocation result. Envelope shape: -- `kind`: literal string `"tool_result"` (singular, not `tools_result`; consumers routing on `kind` must match the literal exactly). Pinned at the value level by `main.rs:8020` (asserts `value["kind"].as_str() == Some("tool_result")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `name` (string): the tool name echoed back from the CLI argument. Pinned as a string by `main.rs:8021` — never an object or array. -- `content` (array of `Content`): the tool's output blocks. Each element is a tagged-enum object whose `type` discriminator selects the variant — `{type: "text", text: }` for textual output or `{type: "json", value: }` for structured output. The variants are defined at `agent-os/crates/covenant-mcp/src/lib.rs:39` with `#[serde(tag = "type", rename_all = "camelCase")]`; v0 ships text and json variants only. The array is empty when the tool produced no output blocks; the unsuffixed CLI prints each block sequentially at `main.rs:3186-3192`. Pinned as an array by `main.rs:8022-8025` — never null or a string. -- `is_error` (boolean): `true` when the tool itself raised; pinned as a JSON boolean by the schema test (`main.rs:8026-8029`) — never `0`/`1` or a string. JSON consumers must branch on this boolean, not on the presence/absence of content. `is_error=true` paired with non-empty `content` describes a partial-success outcome with an error indicator. +- `kind`: literal string `"tool_result"` (singular, not `tools_result`; consumers routing on `kind` must match the literal exactly). Pinned at the value level by `main.rs:8719` (asserts `value["kind"].as_str() == Some("tool_result")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `name` (string): the tool name echoed back from the CLI argument. Pinned as a string by `main.rs:8720` — never an object or array. +- `content` (array of `Content`): the tool's output blocks. Each element is a tagged-enum object whose `type` discriminator selects the variant — `{type: "text", text: }` for textual output or `{type: "json", value: }` for structured output. The variants are defined at `agent-os/crates/covenant-mcp/src/lib.rs:39` with `#[serde(tag = "type", rename_all = "camelCase")]`; v0 ships text and json variants only. The array is empty when the tool produced no output blocks; the unsuffixed CLI prints each block sequentially at `main.rs:3216-3222`. Pinned as an array by `main.rs:8721-8724` — never null or a string. +- `is_error` (boolean): `true` when the tool itself raised; pinned as a JSON boolean by the schema test (`main.rs:8725-8728`) — never `0`/`1` or a string. JSON consumers must branch on this boolean, not on the presence/absence of content. `is_error=true` paired with non-empty `content` describes a partial-success outcome with an error indicator. -Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8004` (`tool_result_json_pins_top_level_schema`), exercised against both a non-empty content + is_error=true case and an empty content + is_error=false case. +Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8703` (`tool_result_json_pins_top_level_schema`), exercised against both a non-empty content + is_error=true case and an empty content + is_error=false case. -The envelope source-of-truth lives at `tool_result_json` in `agent-os/crates/covenant/src/main.rs:5454`. Two unit tests at `main.rs:7983` (`tool_result_json_renders_stable_shape`) and `main.rs:8004` cover the shape. The CLI verb is wired at `main.rs:3146-3192`; without `--json`, each `Content::Text` block prints its `text` directly and each `Content::Json` block prints its `value` as pretty-printed JSON. +The envelope source-of-truth lives at `tool_result_json` in `agent-os/crates/covenant/src/main.rs:6153`. Two unit tests at `main.rs:8682` (`tool_result_json_renders_stable_shape`) and `main.rs:8703` cover the shape. The CLI verb is wired at `main.rs:3176-3222`; without `--json`, each `Content::Text` block prints its `text` directly and each `Content::Json` block prints its `value` as pretty-printed JSON. `covenant chain flush-receipts --json` emits a receipt-batch summary when it groups local settlement receipts into a single Solana receipt-root transaction. Envelope shape: -- `kind`: literal string `"receipt_batch_flushed"`. Pinned at the value level by `main.rs:8282` (asserts `value["kind"].as_str() == Some("receipt_batch_flushed")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the batch-size cap echoed back from the `--limit` argument. Pinned as u64 by `main.rs:8283-8286` — never a string. -- `receipts_updated` (u64): the number of local receipt rows updated to point at the new batch. Pinned as u64 by `main.rs:8287-8290` — never a string-of-integer. -- `batch` (`ReceiptBatchSummary` object): the batch's wire shape, see below. Pinned as a structured object by `main.rs:8291-8294` — never a string blob. +- `kind`: literal string `"receipt_batch_flushed"`. Pinned at the value level by `main.rs:8981` (asserts `value["kind"].as_str() == Some("receipt_batch_flushed")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the batch-size cap echoed back from the `--limit` argument. Pinned as u64 by `main.rs:8982-8985` — never a string. +- `receipts_updated` (u64): the number of local receipt rows updated to point at the new batch. Pinned as u64 by `main.rs:8986-8989` — never a string-of-integer. +- `batch` (`ReceiptBatchSummary` object): the batch's wire shape, see below. Pinned as a structured object by `main.rs:8990-8993` — never a string blob. -Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8266` (`flush_receipts_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8965` (`flush_receipts_json_pins_top_level_schema`). `ReceiptBatchSummary` shape, defined at `agent-os/crates/covenant-ipc/src/lib.rs:54`: @@ -180,24 +180,24 @@ Top-level keys are pinned to exactly these four by the test at `agent-os/crates/ - `tx_sig` (string or null) — base58 Solana transaction signature once the batch confirms; null before submission completes. - `slot` (u64 or null) — confirmation slot once available; null until then. -The envelope source-of-truth lives at `flush_receipts_json` in `agent-os/crates/covenant/src/main.rs:5497`. Two unit tests at `main.rs:8247` (`flush_receipts_json_renders_stable_shape`) and `main.rs:8266` (`flush_receipts_json_pins_top_level_schema`) cover both the unconfirmed (`tx_sig`/`slot` null) and confirmed (both present) batch states. +The envelope source-of-truth lives at `flush_receipts_json` in `agent-os/crates/covenant/src/main.rs:6196`. Two unit tests at `main.rs:8946` (`flush_receipts_json_renders_stable_shape`) and `main.rs:8965` (`flush_receipts_json_pins_top_level_schema`) cover both the unconfirmed (`tx_sig`/`slot` null) and confirmed (both present) batch states. `covenant chain receipt-batches --json` emits the list of recent receipt batches recorded on-chain. Envelope shape: -- `kind`: literal string `"receipt_batch_list"`. Pinned at the value level by `main.rs:8080` (asserts `value["kind"].as_str() == Some("receipt_batch_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the result cap echoed back from the `--limit` argument. Pinned as u64 by `main.rs:8081-8084` — never a string. -- `batches` (array of `ReceiptBatchSummary`): the batches, in the order returned by the daemon. Each item uses the same `ReceiptBatchSummary` shape documented above (including the `tx_sig`/`slot` null convention for batches whose settlement transaction has not yet confirmed). The array may be empty. Pinned as an array by `main.rs:8085-8088` — never null or a string. +- `kind`: literal string `"receipt_batch_list"`. Pinned at the value level by `main.rs:8779` (asserts `value["kind"].as_str() == Some("receipt_batch_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the result cap echoed back from the `--limit` argument. Pinned as u64 by `main.rs:8780-8783` — never a string. +- `batches` (array of `ReceiptBatchSummary`): the batches, in the order returned by the daemon. Each item uses the same `ReceiptBatchSummary` shape documented above (including the `tx_sig`/`slot` null convention for batches whose settlement transaction has not yet confirmed). The array may be empty. Pinned as an array by `main.rs:8784-8787` — never null or a string. -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:8064` (`receipt_batch_list_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:8763` (`receipt_batch_list_json_pins_top_level_schema`). -The envelope source-of-truth lives at `receipt_batch_list_json` in `agent-os/crates/covenant/src/main.rs:5467`. Two unit tests at `main.rs:8046` (`receipt_batch_list_json_renders_stable_shape`) and `main.rs:8064` (`receipt_batch_list_json_pins_top_level_schema`) cover the populated and empty cases. +The envelope source-of-truth lives at `receipt_batch_list_json` in `agent-os/crates/covenant/src/main.rs:6166`. Two unit tests at `main.rs:8745` (`receipt_batch_list_json_renders_stable_shape`) and `main.rs:8763` (`receipt_batch_list_json_pins_top_level_schema`) cover the populated and empty cases. `covenant receipts recent [-n|--limit ] [--since-ms ] --json` emits a window of local settlement receipts. Envelope shape: -- `kind`: literal string `"receipt_list"` — verb-name asymmetry: the CLI verb is `recent` but the envelope discriminator is `receipt_list` (singular `receipt_`, not `receipts_`); consumers routing on `kind` must match the literal exactly rather than reusing the verb token or pluralising. Pinned at the value level by `main.rs:6478` (asserts `value["kind"].as_str() == Some("receipt_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10`, per `main.rs:2842`). Pinned at the type level by the schema test (`main.rs:6479-6482`) — never a string. -- `since_ms` (u64 or null): the Unix-epoch millisecond threshold echoed from `--since-ms`, or `null` when the flag was omitted. Pinned as u64-or-null at the schema test (`main.rs:6483-6486`) — never a string-of-integer. Filter semantics live with the daemon's `Request::RecentReceipts` handler; this surface only echoes the operator's input. -- `receipts` (array of `SettlementReceipt`): the matched receipts in the order returned by the daemon. The array is empty when no receipts fall in the window; the unsuffixed CLI prints `(no receipts)` for that case at `main.rs:2872`. Pinned as an array by `main.rs:6487-6490` — never null or a string. +- `kind`: literal string `"receipt_list"` — verb-name asymmetry: the CLI verb is `recent` but the envelope discriminator is `receipt_list` (singular `receipt_`, not `receipts_`); consumers routing on `kind` must match the literal exactly rather than reusing the verb token or pluralising. Pinned at the value level by `main.rs:7177` (asserts `value["kind"].as_str() == Some("receipt_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10`, per `main.rs:2872`). Pinned at the type level by the schema test (`main.rs:7178-7181`) — never a string. +- `since_ms` (u64 or null): the Unix-epoch millisecond threshold echoed from `--since-ms`, or `null` when the flag was omitted. Pinned as u64-or-null at the schema test (`main.rs:7182-7185`) — never a string-of-integer. Filter semantics live with the daemon's `Request::RecentReceipts` handler; this surface only echoes the operator's input. +- `receipts` (array of `SettlementReceipt`): the matched receipts in the order returned by the daemon. The array is empty when no receipts fall in the window; the unsuffixed CLI prints `(no receipts)` for that case at `main.rs:2902`. Pinned as an array by `main.rs:7186-7189` — never null or a string. The inner `SettlementReceipt` shape, defined at `agent-os/crates/covenant-types/src/lib.rs:392`: @@ -214,90 +214,90 @@ The inner `SettlementReceipt` shape, defined at `agent-os/crates/covenant-types/ - `tx_sig` (string or null) — base58 Solana transaction signature once the batch confirms; `null` until then. Always present on the wire. - `slot` (u64 or null) — confirmation slot once available; `null` until then. Always present on the wire. - `confirmed_at` (u64 or null) — Unix-epoch milliseconds when the on-chain transaction confirmed; `null` until then. Always present on the wire. -- `onchain_sig` (string or null) — backwards-compatible alias for `tx_sig` (per the struct doc-comment at `covenant-types/src/lib.rs:388-390`) that older clients still consume; new consumers should prefer `tx_sig`. Always present on the wire. Both fields carry the same value once the receipt confirms; the unsuffixed CLI's `(local-only)` fallback at `main.rs:3555-3558` reads `tx_sig` first and falls back to `onchain_sig` for exactly that reason. +- `onchain_sig` (string or null) — backwards-compatible alias for `tx_sig` (per the struct doc-comment at `covenant-types/src/lib.rs:388-390`) that older clients still consume; new consumers should prefer `tx_sig`. Always present on the wire. Both fields carry the same value once the receipt confirms; the unsuffixed CLI's `(local-only)` fallback at `main.rs:3585-3588` reads `tx_sig` first and falls back to `onchain_sig` for exactly that reason. -Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:6462` (`receipt_list_json_pins_top_level_schema`), exercised against three cases: populated with `since_ms`, populated without `since_ms`, and empty without `since_ms`. +Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:7161` (`receipt_list_json_pins_top_level_schema`), exercised against three cases: populated with `since_ms`, populated without `since_ms`, and empty without `since_ms`. -The envelope source-of-truth lives at `receipt_list_json` in `agent-os/crates/covenant/src/main.rs:5259`. Two unit tests at `main.rs:6421` (`receipt_list_json_renders_stable_shape`) and `main.rs:6462` cover the shape. The CLI verb is wired at `main.rs:2837-2890`; without `--json`, each receipt is printed as `[] : credits — ` at `main.rs:2880-2883`, with `` resolving to the `tx_sig`/`onchain_sig` value or the literal `(local-only)` when both are null. +The envelope source-of-truth lives at `receipt_list_json` in `agent-os/crates/covenant/src/main.rs:5958`. Two unit tests at `main.rs:7120` (`receipt_list_json_renders_stable_shape`) and `main.rs:7161` cover the shape. The CLI verb is wired at `main.rs:2867-2920`; without `--json`, each receipt is printed as `[] : credits — ` at `main.rs:2910-2913`, with `` resolving to the `tx_sig`/`onchain_sig` value or the literal `(local-only)` when both are null. `covenant ping --json` emits a daemon-liveness probe. Envelope shape: -- `kind`: literal string `"daemon_ping"`. Pinned at the value level by `main.rs:6842` (asserts `value["kind"].as_str() == Some("daemon_ping")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `status`: literal string `"ok"` — the daemon only returns this envelope when it has accepted the request and produced a `Response::Pong`; failures surface as a non-zero CLI exit rather than a non-`"ok"` payload, so consumers can branch on transport success alone. Pinned as a string by `main.rs:6843-6846` — never an integer or boolean. The literal value `"ok"` is also pinned at the value level by `main.rs:6847` (asserts `value["status"].as_str() == Some("ok")`), so a future status-rename fails the test rather than silently rewriting the literal. +- `kind`: literal string `"daemon_ping"`. Pinned at the value level by `main.rs:7541` (asserts `value["kind"].as_str() == Some("daemon_ping")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `status`: literal string `"ok"` — the daemon only returns this envelope when it has accepted the request and produced a `Response::Pong`; failures surface as a non-zero CLI exit rather than a non-`"ok"` payload, so consumers can branch on transport success alone. Pinned as a string by `main.rs:7542-7545` — never an integer or boolean. The literal value `"ok"` is also pinned at the value level by `main.rs:7546` (asserts `value["status"].as_str() == Some("ok")`), so a future status-rename fails the test rather than silently rewriting the literal. -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:6828` (`ping_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7527` (`ping_json_pins_top_level_schema`). -The envelope source-of-truth lives at `ping_json` in `agent-os/crates/covenant/src/main.rs:5289`. The shape-pinning tests at `main.rs:6821` (`ping_json_renders_stable_shape`) and `main.rs:6828` cover the single emitted shape; the CLI verb is wired at `main.rs:1983-2005` (the unsuffixed `covenant ping` prints `pong` instead). +The envelope source-of-truth lives at `ping_json` in `agent-os/crates/covenant/src/main.rs:5988`. The shape-pinning tests at `main.rs:7520` (`ping_json_renders_stable_shape`) and `main.rs:7527` cover the single emitted shape; the CLI verb is wired at `main.rs:1983-2005` (the unsuffixed `covenant ping` prints `pong` instead). `covenant intent [--json] [--stream] ` emits the dispatched intent's outcome with optional settlement evidence. Envelope shape: -- `kind`: literal string `"intent_result"`. Pinned at the value level by `main.rs:6773` (asserts `value["kind"].as_str() == Some("intent_result")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `intent_id` (string): the dispatched intent's UUID, serialized as the canonical hyphenated string form. Pinned as a string by the schema test (`main.rs:6774-6777`) — never a byte array or struct. -- `status` (string): the outcome status (e.g., `"ok"`). The string shape is pinned by `main.rs:6778-6781`; specific value enumeration lives with the daemon's intent dispatcher rather than this docs surface. -- `text` (string): the result text the daemon returned. The unsuffixed CLI prints this value directly at `main.rs:2075` (a single-line `println!("{text}")`), so `covenant intent --json` and `covenant intent` share the result payload but only `--json` wraps it in the envelope. Pinned as a string by `main.rs:6782` — never an object or array. -- `sources` (array of strings): source labels that contributed to the result (e.g., `["research"]`). Pinned as an array of strings by `main.rs:6783-6786` — never a comma-joined string. Empty when no sources are attached. -- `settlement` (object or null): an optional `SettlementReceipt` (defined at `agent-os/crates/covenant-types/src/lib.rs:392`) carrying the on-chain or local settlement evidence when the intent consumed credits. `null` when the intent did not settle (e.g., a phase-0 echo that does not charge). Pinned as object-or-null by `main.rs:6787-6790` — never an integer or array. +- `kind`: literal string `"intent_result"`. Pinned at the value level by `main.rs:7472` (asserts `value["kind"].as_str() == Some("intent_result")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `intent_id` (string): the dispatched intent's UUID, serialized as the canonical hyphenated string form. Pinned as a string by the schema test (`main.rs:7473-7476`) — never a byte array or struct. +- `status` (string): the outcome status (e.g., `"ok"`). The string shape is pinned by `main.rs:7477-7480`; specific value enumeration lives with the daemon's intent dispatcher rather than this docs surface. +- `text` (string): the result text the daemon returned. The unsuffixed CLI prints this value directly at `main.rs:2075` (a single-line `println!("{text}")`), so `covenant intent --json` and `covenant intent` share the result payload but only `--json` wraps it in the envelope. Pinned as a string by `main.rs:7481` — never an object or array. +- `sources` (array of strings): source labels that contributed to the result (e.g., `["research"]`). Pinned as an array of strings by `main.rs:7482-7485` — never a comma-joined string. Empty when no sources are attached. +- `settlement` (object or null): an optional `SettlementReceipt` (defined at `agent-os/crates/covenant-types/src/lib.rs:392`) carrying the on-chain or local settlement evidence when the intent consumed credits. `null` when the intent did not settle (e.g., a phase-0 echo that does not charge). Pinned as object-or-null by `main.rs:7486-7489` — never an integer or array. -Top-level keys are pinned to exactly these six by the test at `agent-os/crates/covenant/src/main.rs:6750` (`intent_result_json_pins_top_level_schema`), exercised against both a populated `Some(SettlementReceipt)` case and an empty unsettled case. +Top-level keys are pinned to exactly these six by the test at `agent-os/crates/covenant/src/main.rs:7449` (`intent_result_json_pins_top_level_schema`), exercised against both a populated `Some(SettlementReceipt)` case and an empty unsettled case. -The envelope source-of-truth lives at `intent_result_json` in `agent-os/crates/covenant/src/main.rs:5272`. Two unit tests at `main.rs:6732` (`intent_result_json_renders_stable_shape`) and `main.rs:6750` cover the shape. The CLI verb is wired at `main.rs:2006-2080`; the `--json`/`--stream` flags are recognized only in leading position (`main.rs:2019-2028`) so an interior `--json` token is preserved as part of the intent text. The optional `--stream` flag sets `Request::SubmitIntent.prefer_stream = Some(true)` (`main.rs:2717`), enabling the v2 streaming-response path documented under [docs/protocol-versioning.md](./protocol-versioning.md); the terminal `IntentResult` envelope shape is unchanged when the streaming path is not selected. +The envelope source-of-truth lives at `intent_result_json` in `agent-os/crates/covenant/src/main.rs:5971`. Two unit tests at `main.rs:7431` (`intent_result_json_renders_stable_shape`) and `main.rs:7449` cover the shape. The CLI verb is wired at `main.rs:2006-2080`; the `--json`/`--stream` flags are recognized only in leading position (`main.rs:2019-2028`) so an interior `--json` token is preserved as part of the intent text. The optional `--stream` flag sets `Request::SubmitIntent.prefer_stream = Some(true)` (`main.rs:2747`), enabling the v2 streaming-response path documented under [docs/protocol-versioning.md](./protocol-versioning.md); the terminal `IntentResult` envelope shape is unchanged when the streaming path is not selected. -`GET /intents/:id/events` (HTTP-only) opens a `text/event-stream` connection that emits one frame per agent runtime event whose `intent_id` matches the path parameter. Frames carry an `AgentEvent` JSON object (`#[serde(tag = "type", rename_all = "snake_case")]`, defined in `covenant-types/src/lib.rs`) — the public taxonomy with four variants `reasoning`, `tool_call`, `tool_result`, and `file_write`. Runner-side `RuntimeTrace` rows are projected into this taxonomy at the SSE boundary (see `impl From<&RuntimeTrace> for AgentEvent` in `covenant-runtime/src/lib.rs`) so the wire form survives runner swaps without breaking browser clients. The `reasoning` slot is reserved: today the Hermes adapter drops reasoning frames at the SSE seam because they are too high-volume to audit, so no runner emits this variant yet — the slot is wire-compatible so a future runtime can stream condensed reasoning summaries without breaking older clients. Approval frames surface as `tool_call` / `tool_result` with `tool = "approval"`; the audit chain keeps the durable `hermes_approval_requested` / `hermes_approval_resolved` rows for the runner-specific record. The endpoint replaces the 3-second poll on `/intents/:id/result` for the live trace view — a browser opens one `EventSource` per intent page and renders each frame the moment the daemon flushes it. The web client caps the rendered step list at the most recent 200 entries (with an explicit operator opt-in to expand to the full set) so a long-running run does not stall the React reconciler on slow machines; the cap matches the audit fetch `limit=200`, so widening one without the other would silently drop history at the seam. The endpoint subscribes to a daemon-side `tokio::sync::broadcast` fan-out populated by `spawn_runtime_event_drainer` in `agent-os/crates/covenantd/src/lib.rs:524`; the channel exists unconditionally, but the drainer that publishes to it only runs when `COVENANT_LIVE_TRACE=1`, so the endpoint streams nothing until the operator opts into live tracing. Audience model mirrors `/intents/:id/result`: any authenticated peer can stream any intent. Slow subscribers that fall behind the broadcast capacity drop the lagged window and keep streaming; the audit chain is the durable record. Reconnects do not replay missed frames — a client that needs the durable history fetches `/audit/recent` instead. +`GET /intents/:id/events` (HTTP-only) opens a `text/event-stream` connection that emits one frame per agent runtime event whose `intent_id` matches the path parameter. Frames carry an `AgentEvent` JSON object (`#[serde(tag = "type", rename_all = "snake_case")]`, defined in `covenant-types/src/lib.rs`) — the public taxonomy with four variants `reasoning`, `tool_call`, `tool_result`, and `file_write`. Runner-side `RuntimeTrace` rows are projected into this taxonomy at the SSE boundary (see `impl From<&RuntimeTrace> for AgentEvent` in `covenant-runtime/src/lib.rs`) so the wire form survives runner swaps without breaking browser clients. The `reasoning` slot is reserved: today the Hermes adapter drops reasoning frames at the SSE seam because they are too high-volume to audit, so no runner emits this variant yet — the slot is wire-compatible so a future runtime can stream condensed reasoning summaries without breaking older clients. Approval frames surface as `tool_call` / `tool_result` with `tool = "approval"`; the audit chain keeps the durable `hermes_approval_requested` / `hermes_approval_resolved` rows for the runner-specific record. The endpoint replaces the 3-second poll on `/intents/:id/result` for the live trace view — a browser opens one `EventSource` per intent page and renders each frame the moment the daemon flushes it. The web client caps the rendered step list at the most recent 200 entries (with an explicit operator opt-in to expand to the full set) so a long-running run does not stall the React reconciler on slow machines; the cap matches the audit fetch `limit=200`, so widening one without the other would silently drop history at the seam. The endpoint subscribes to a daemon-side `tokio::sync::broadcast` fan-out populated by `spawn_runtime_event_drainer` in `agent-os/crates/covenantd/src/lib.rs:529`; the channel exists unconditionally, but the drainer that publishes to it only runs when `COVENANT_LIVE_TRACE=1`, so the endpoint streams nothing until the operator opts into live tracing. Audience model mirrors `/intents/:id/result`: any authenticated peer can stream any intent. Slow subscribers that fall behind the broadcast capacity drop the lagged window and keep streaming; the audit chain is the durable record. Reconnects do not replay missed frames — a client that needs the durable history fetches `/audit/recent` instead. `covenant capabilities recent [-n|--limit ] --json` emits a peer-scoped view of recent signed capabilities. Envelope shape: -- `kind`: literal string `"capability_list"` — verb-name asymmetry: the CLI verb is `recent` but the envelope discriminator is `capability_list`. Consumers routing on `kind` must match the latter literal exactly rather than reusing the verb token. Pinned at the value level by `main.rs:6907` (asserts `value["kind"].as_str() == Some("capability_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10`, see `main.rs:3253`). Pinned at the type level by the schema test (`main.rs:6908-6911`) — JSON consumers must never receive a string here. -- `capabilities` (array of `SignedCapability`): the filtered live capabilities. Each element has shape `{capability: Capability, signature: }` where `Capability` is defined at `agent-os/crates/covenant-types/src/lib.rs:224` (fields: `subject`, `action`, `scope`, `granted_by`, `expires_at`) and `SignedCapability` is defined at `agent-os/crates/covenant-permissions/src/lib.rs:58`. The `signature` field is the base58 encoding of the 64-byte ed25519 signature (per the `sig_b58` serde module at `lib.rs:64-84`), never the raw byte array. Pinned as an array by `main.rs:6912-6915` — never null or a string. +- `kind`: literal string `"capability_list"` — verb-name asymmetry: the CLI verb is `recent` but the envelope discriminator is `capability_list`. Consumers routing on `kind` must match the latter literal exactly rather than reusing the verb token. Pinned at the value level by `main.rs:7606` (asserts `value["kind"].as_str() == Some("capability_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10`, see `main.rs:3283`). Pinned at the type level by the schema test (`main.rs:7607-7610`) — JSON consumers must never receive a string here. +- `capabilities` (array of `SignedCapability`): the filtered live capabilities. Each element has shape `{capability: Capability, signature: }` where `Capability` is defined at `agent-os/crates/covenant-types/src/lib.rs:224` (fields: `subject`, `action`, `scope`, `granted_by`, `expires_at`) and `SignedCapability` is defined at `agent-os/crates/covenant-permissions/src/lib.rs:58`. The `signature` field is the base58 encoding of the 64-byte ed25519 signature (per the `sig_b58` serde module at `lib.rs:64-84`), never the raw byte array. Pinned as an array by `main.rs:7611-7614` — never null or a string. -The daemon applies a **peer-visibility filter** before returning the list (see `recent_capabilities` at `agent-os/crates/covenantd/src/lib.rs:14123-14139`): only capabilities whose `subject.pubkey` or `granted_by.pubkey` matches the requesting peer's pubkey are included. JSON consumers must not assume this is a global registry dump — operator and delegated callers see a different slice of the same store. +The daemon applies a **peer-visibility filter** before returning the list (see `recent_capabilities` at `agent-os/crates/covenantd/src/lib.rs:14517-14533`): only capabilities whose `subject.pubkey` or `granted_by.pubkey` matches the requesting peer's pubkey are included. JSON consumers must not assume this is a global registry dump — operator and delegated callers see a different slice of the same store. -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:6891` (`capability_list_json_pins_top_level_schema`), which exercises both a populated single-capability case and an empty list. +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7590` (`capability_list_json_pins_top_level_schema`), which exercises both a populated single-capability case and an empty list. -The envelope source-of-truth lives at `capability_list_json` in `agent-os/crates/covenant/src/main.rs:5296`. Two unit tests at `main.rs:6851` (`capability_list_json_renders_stable_shape`) and `main.rs:6891` cover both cases. The CLI verb is wired at `main.rs:2580-2636`; without `--json`, the same response prints one line per capability in the form ` () []` at `main.rs:3296-3302`, or `(no capabilities granted)` when the filtered list is empty. +The envelope source-of-truth lives at `capability_list_json` in `agent-os/crates/covenant/src/main.rs:5995`. Two unit tests at `main.rs:7550` (`capability_list_json_renders_stable_shape`) and `main.rs:7590` cover both cases. The CLI verb is wired at `main.rs:2610-2666`; without `--json`, the same response prints one line per capability in the form ` () []` at `main.rs:3326-3332`, or `(no capabilities granted)` when the filtered list is empty. `covenant capabilities grant [--scope ] [--expires-at ] --json` emits the freshly-signed capability after the daemon accepts the grant. Envelope shape: -- `kind`: literal string `"capability_granted"` — past-tense outcome name, distinct from the verb name `grant`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token. Pinned at the value level by `main.rs:6980` (asserts `value["kind"].as_str() == Some("capability_granted")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `subject_display` (string): the daemon-synthesized human-readable subject (e.g., `operator@local`). The daemon owns this field — consumers must not reconstruct it from the request. Pinned as a string by `main.rs:6981-6984` — never an object or array. -- `action` (string): the action the capability was granted for. **Not always the verbatim CLI argument**: when the CLI receives an a2a peer-prefix shorthand it expands the prefix to the full peer-bound action before signing (see `expand_a2a_action` invoked at `main.rs:2669-2702`); the envelope reports the post-expansion full form, and the unsuffixed CLI prints an `expanding ` line to stderr at `main.rs:3364`. Pinned as a string by `main.rs:6985-6988` — never an object or array. -- `signature_b58` (string): the base58 signature over the signed-capability bytes. This is the same value consumers pass back to `covenant capabilities revoke ` to tombstone the capability. Pinned as a string by `main.rs:6989-6992` — never an object or array. -- `scope` (object or null): the structured scope object echoed from the request, or `null` when `--scope` was omitted. Pinned at the type level by the schema test (`main.rs:6993-6996`) — JSON consumers must never receive a string blob here, so a scope value of `"{\"version\":1}"` would be a contract break. -- `expires_at` (u64 or null): the Unix-epoch millisecond expiry echoed from `--expires-at`, or `null` when the flag was omitted. Pinned at the type level by the schema test (`main.rs:6997-7000`) — JSON consumers must never receive a string here, so a value of `"1700000000000"` would be a contract break. +- `kind`: literal string `"capability_granted"` — past-tense outcome name, distinct from the verb name `grant`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token. Pinned at the value level by `main.rs:7679` (asserts `value["kind"].as_str() == Some("capability_granted")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `subject_display` (string): the daemon-synthesized human-readable subject (e.g., `operator@local`). The daemon owns this field — consumers must not reconstruct it from the request. Pinned as a string by `main.rs:7680-7683` — never an object or array. +- `action` (string): the action the capability was granted for. **Not always the verbatim CLI argument**: when the CLI receives an a2a peer-prefix shorthand it expands the prefix to the full peer-bound action before signing (see `expand_a2a_action` invoked at `main.rs:2699-2732`); the envelope reports the post-expansion full form, and the unsuffixed CLI prints an `expanding ` line to stderr at `main.rs:3394`. Pinned as a string by `main.rs:7684-7687` — never an object or array. +- `signature_b58` (string): the base58 signature over the signed-capability bytes. This is the same value consumers pass back to `covenant capabilities revoke ` to tombstone the capability. Pinned as a string by `main.rs:7688-7691` — never an object or array. +- `scope` (object or null): the structured scope object echoed from the request, or `null` when `--scope` was omitted. Pinned at the type level by the schema test (`main.rs:7692-7695`) — JSON consumers must never receive a string blob here, so a scope value of `"{\"version\":1}"` would be a contract break. +- `expires_at` (u64 or null): the Unix-epoch millisecond expiry echoed from `--expires-at`, or `null` when the flag was omitted. Pinned at the type level by the schema test (`main.rs:7696-7699`) — JSON consumers must never receive a string here, so a value of `"1700000000000"` would be a contract break. -Top-level keys are pinned to exactly these six by the test at `agent-os/crates/covenant/src/main.rs:6957` (`capability_grant_json_pins_top_level_schema`), which also asserts the `scope` object-or-null and `expires_at` u64-or-null typing. +Top-level keys are pinned to exactly these six by the test at `agent-os/crates/covenant/src/main.rs:7656` (`capability_grant_json_pins_top_level_schema`), which also asserts the `scope` object-or-null and `expires_at` u64-or-null typing. -The envelope source-of-truth lives at `capability_grant_json` in `agent-os/crates/covenant/src/main.rs:5304`. Two unit tests at `main.rs:6934` (`capability_grant_json_renders_stable_shape`, covers both a scoped+timed grant and an unscoped+untimed grant) and `main.rs:6957` cover both populated cases. The CLI verb is wired at `main.rs:2638-2730`; without `--json`, the same response prints `granted: ` followed by the signature on a second line. +The envelope source-of-truth lives at `capability_grant_json` in `agent-os/crates/covenant/src/main.rs:6003`. Two unit tests at `main.rs:7633` (`capability_grant_json_renders_stable_shape`, covers both a scoped+timed grant and an unscoped+untimed grant) and `main.rs:7656` cover both populated cases. The CLI verb is wired at `main.rs:2668-2760`; without `--json`, the same response prints `granted: ` followed by the signature on a second line. `covenant capabilities revoke --json` emits the outcome of revoking a single signed capability by its signature. Envelope shape: -- `kind`: literal string `"capability_revoked"` — past-tense outcome name, distinct from the verb name `revoke`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token. Pinned at the value level by `main.rs:7050` (asserts `value["kind"].as_str() == Some("capability_revoked")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `signature_b58` (string): the base58 signature echoed back from the request, so consumers can correlate the response to the revoke call without tracking it out of band. Pinned as a string by `main.rs:7051-7054` — never an object or array. -- `removed` (boolean): `true` if a live capability matched and was tombstoned, `false` if no live row matched that signature. Pinned as a JSON boolean by `main.rs:7055-7058` — never `0`/`1` or a string. `false` is a benign no-op outcome, not an error — the daemon still returns `Response::CapabilityRevoked` and the unsuffixed CLI prints `(no live capability with that signature)` for that case at `main.rs:3453`. JSON consumers must not treat `removed=false` as a failure. +- `kind`: literal string `"capability_revoked"` — past-tense outcome name, distinct from the verb name `revoke`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token. Pinned at the value level by `main.rs:7749` (asserts `value["kind"].as_str() == Some("capability_revoked")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `signature_b58` (string): the base58 signature echoed back from the request, so consumers can correlate the response to the revoke call without tracking it out of band. Pinned as a string by `main.rs:7750-7753` — never an object or array. +- `removed` (boolean): `true` if a live capability matched and was tombstoned, `false` if no live row matched that signature. Pinned as a JSON boolean by `main.rs:7754-7757` — never `0`/`1` or a string. `false` is a benign no-op outcome, not an error — the daemon still returns `Response::CapabilityRevoked` and the unsuffixed CLI prints `(no live capability with that signature)` for that case at `main.rs:3483`. JSON consumers must not treat `removed=false` as a failure. -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7034` (`capability_revoke_json_pins_top_level_schema`), which also asserts `removed` is a JSON boolean (never `0`/`1` or a string). +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7733` (`capability_revoke_json_pins_top_level_schema`), which also asserts `removed` is a JSON boolean (never `0`/`1` or a string). -The envelope source-of-truth lives at `capability_revoke_json` in `agent-os/crates/covenant/src/main.rs:5321`. Two unit tests at `main.rs:7021` (`capability_revoke_json_renders_stable_shape`) and `main.rs:7034` cover both the `removed=true` and `removed=false` cases. The CLI verb is wired at `main.rs:3843-2786`. +The envelope source-of-truth lives at `capability_revoke_json` in `agent-os/crates/covenant/src/main.rs:6020`. Two unit tests at `main.rs:7720` (`capability_revoke_json_renders_stable_shape`) and `main.rs:7733` cover both the `removed=true` and `removed=false` cases. The CLI verb is wired at `main.rs:3873-2816`. `covenant capabilities purge --json` emits a summary of revoked-capability garbage collection. Envelope shape: -- `kind`: literal string `"capabilities_purged"`. Pinned at the value level by `main.rs:7090` (asserts `value["kind"].as_str() == Some("capabilities_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `before_ms` (u64): the resolved Unix-epoch millisecond cutoff. The CLI accepts either `--before-ms ` (echoed verbatim) or `--older-than-ms ` (resolved against the system clock as `now - D` per `main.rs:3479-3483`); the envelope always reports the single resolved value, so consumers cannot distinguish which input form the operator typed. Pinned as u64 by `main.rs:7091-7094` — never a string-of-integer. -- `purged` (u64): the count of revoked-capability rows removed. May legitimately be `0` when no rows matched the cutoff — the verb does not error on an empty purge. Pinned as u64 by `main.rs:7095-7098` — never a string-of-integer. +- `kind`: literal string `"capabilities_purged"`. Pinned at the value level by `main.rs:7789` (asserts `value["kind"].as_str() == Some("capabilities_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `before_ms` (u64): the resolved Unix-epoch millisecond cutoff. The CLI accepts either `--before-ms ` (echoed verbatim) or `--older-than-ms ` (resolved against the system clock as `now - D` per `main.rs:3509-3513`); the envelope always reports the single resolved value, so consumers cannot distinguish which input form the operator typed. Pinned as u64 by `main.rs:7790-7793` — never a string-of-integer. +- `purged` (u64): the count of revoked-capability rows removed. May legitimately be `0` when no rows matched the cutoff — the verb does not error on an empty purge. Pinned as u64 by `main.rs:7794-7797` — never a string-of-integer. -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7074` (`capabilities_purge_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7773` (`capabilities_purge_json_pins_top_level_schema`). -The envelope source-of-truth lives at `capabilities_purge_json` in `agent-os/crates/covenant/src/main.rs:5329`. Two unit tests at `main.rs:7066` (`capabilities_purge_json_renders_stable_shape`) and `main.rs:7074` (`capabilities_purge_json_pins_top_level_schema`) cover the populated (`purged=3`) and empty (`purged=0`) cases. The CLI verb is wired at `main.rs:3888-2836`; without `--json`, the same response prints `purged revoked capability(ies)`. +The envelope source-of-truth lives at `capabilities_purge_json` in `agent-os/crates/covenant/src/main.rs:6028`. Two unit tests at `main.rs:7765` (`capabilities_purge_json_renders_stable_shape`) and `main.rs:7773` (`capabilities_purge_json_pins_top_level_schema`) cover the populated (`purged=3`) and empty (`purged=0`) cases. The CLI verb is wired at `main.rs:3918-2866`; without `--json`, the same response prints `purged revoked capability(ies)`. `covenant peers list [--limit ] [--prefix

] [--live-only|--revoked-only] --json` emits the registered peer roster filtered by the supplied flags. Envelope shape: -- `kind`: literal string `"peer_list"`. Pinned at the value level by `main.rs:6036` (asserts `value["kind"].as_str() == Some("peer_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the request limit echoed back from `--limit` (default `20`, per `main.rs:4390`). Pinned as u64 by `main.rs:6037-6040` — never a string. -- `filter_pubkey_prefix` (string or null): the prefix echoed from `--prefix`, or `null` when the flag was omitted. Pinned at the type level by the schema test (`main.rs:6041-6045`) — never an integer or array. -- `matched_count` (u64): row count of the `peers` array; equals the exhaustive match count when `truncated` is `false`. Pinned as u64 by `main.rs:6046-6049` — never a string. -- `peers` (array of `PeerSummary`): the matched roster slice, see below. Pinned as an array by `main.rs:6050` — never null or a string blob. -- `operator_pubkey_b58` (string): the requesting operator's own pubkey in base58. The unsuffixed CLI line formatter at `peer_list_lines` (`main.rs:4353`) compares each peer's `pubkey_base58()` against this value to append a ` (self)` marker on the operator's own row; JSON consumers must apply the same comparison to render the self-tag, not assume the operator's row is reliably first. Pinned as a string by `main.rs:6051-6054` — never an object or array. -- `truncated` (boolean): `true` when the registry held more matching entries than `limit`, `false` otherwise. Pinned as a JSON boolean by the schema test at `main.rs:6055-6058` — never `0`/`1`. **This is the only signal of incomplete results**; `matched_count == limit` with `truncated == false` means the page is the exhaustive match set, not a hint to paginate. +- `kind`: literal string `"peer_list"`. Pinned at the value level by `main.rs:6735` (asserts `value["kind"].as_str() == Some("peer_list")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the request limit echoed back from `--limit` (default `20`, per `main.rs:4420`). Pinned as u64 by `main.rs:6736-6739` — never a string. +- `filter_pubkey_prefix` (string or null): the prefix echoed from `--prefix`, or `null` when the flag was omitted. Pinned at the type level by the schema test (`main.rs:6740-6744`) — never an integer or array. +- `matched_count` (u64): row count of the `peers` array; equals the exhaustive match count when `truncated` is `false`. Pinned as u64 by `main.rs:6745-6748` — never a string. +- `peers` (array of `PeerSummary`): the matched roster slice, see below. Pinned as an array by `main.rs:6749` — never null or a string blob. +- `operator_pubkey_b58` (string): the requesting operator's own pubkey in base58. The unsuffixed CLI line formatter at `peer_list_lines` (`main.rs:4383`) compares each peer's `pubkey_base58()` against this value to append a ` (self)` marker on the operator's own row; JSON consumers must apply the same comparison to render the self-tag, not assume the operator's row is reliably first. Pinned as a string by `main.rs:6750-6753` — never an object or array. +- `truncated` (boolean): `true` when the registry held more matching entries than `limit`, `false` otherwise. Pinned as a JSON boolean by the schema test at `main.rs:6754-6757` — never `0`/`1`. **This is the only signal of incomplete results**; `matched_count == limit` with `truncated == false` means the page is the exhaustive match set, not a hint to paginate. The inner `PeerSummary` shape, defined at `agent-os/crates/covenant-peer-auth/src/lib.rs:140`: @@ -306,35 +306,35 @@ The inner `PeerSummary` shape, defined at `agent-os/crates/covenant-peer-auth/sr - `registered_at` (u64) — Unix-epoch milliseconds when the peer registered. - `revoked_at` (u64 or null) — Unix-epoch milliseconds when the peer was tombstoned; `null` for live entries. Composes with the `--live-only`/`--revoked-only` flags (and the equivalent `status_filter` query parameter described above) for filtering — the filter runs before the registry's truncation peek. -Top-level keys are pinned to exactly these seven by the test at `agent-os/crates/covenant/src/main.rs:6012` (`peer_list_json_pins_top_level_schema`), exercised against a populated two-peer (one live, one revoked) case and an empty case. +Top-level keys are pinned to exactly these seven by the test at `agent-os/crates/covenant/src/main.rs:6711` (`peer_list_json_pins_top_level_schema`), exercised against a populated two-peer (one live, one revoked) case and an empty case. -The envelope source-of-truth lives at `peer_list_json` in `agent-os/crates/covenant/src/main.rs:5165`. Schema and behavioral tests live at `main.rs:6012` (key set + per-key typing), `main.rs:5979` (`peer_list_json_echoes_prefix_and_match_count`), `main.rs:5993` (`peer_list_json_omits_prefix_when_inactive`), and `main.rs:6004` (`peer_list_json_reports_zero_match_count_for_empty_response`). The CLI verb is wired at `main.rs:3719-3772`; without `--json`, the same response is rendered line-by-line by `peer_list_lines` (`main.rs:4353`) with a `(truncated; shown — narrow with --prefix or raise --limit)` hint appended when `truncated` is `true` (`main.rs:4384`). See also the **Query Parameters** section above for the same filter composition rules over the HTTP gateway. +The envelope source-of-truth lives at `peer_list_json` in `agent-os/crates/covenant/src/main.rs:5864`. Schema and behavioral tests live at `main.rs:6711` (key set + per-key typing), `main.rs:6678` (`peer_list_json_echoes_prefix_and_match_count`), `main.rs:6692` (`peer_list_json_omits_prefix_when_inactive`), and `main.rs:6703` (`peer_list_json_reports_zero_match_count_for_empty_response`). The CLI verb is wired at `main.rs:3749-3802`; without `--json`, the same response is rendered line-by-line by `peer_list_lines` (`main.rs:4383`) with a `(truncated; shown — narrow with --prefix or raise --limit)` hint appended when `truncated` is `true` (`main.rs:4414`). See also the **Query Parameters** section above for the same filter composition rules over the HTTP gateway. `covenant peers purge --json` emits a summary of revoked-peer garbage collection. Envelope shape: -- `kind`: literal string `"peers_purged"` — the only structural disambiguator from `capabilities_purged`; both envelopes share the same three-key layout, so consumers that route on `kind` must check the full literal rather than treating any `*_purged` envelope as interchangeable. Pinned at the value level by `main.rs:7130` (asserts `value["kind"].as_str() == Some("peers_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `before_ms` (u64): resolved Unix-epoch millisecond cutoff. The CLI accepts `--before-ms` or `--older-than-ms` with the same resolution semantics as `covenant capabilities purge --json` above. Pinned as u64 by `main.rs:7131-7134` — never a string-of-integer. -- `purged` (u64): count of revoked-peer rows removed. Only revoked rows are eligible — the verb does not touch live peers (the unsuffixed CLI prints `purged revoked peer(s)` at `main.rs:3669`). May legitimately be `0` when no rows matched. Pinned as u64 by `main.rs:7135-7138` — never a string-of-integer. +- `kind`: literal string `"peers_purged"` — the only structural disambiguator from `capabilities_purged`; both envelopes share the same three-key layout, so consumers that route on `kind` must check the full literal rather than treating any `*_purged` envelope as interchangeable. Pinned at the value level by `main.rs:7829` (asserts `value["kind"].as_str() == Some("peers_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `before_ms` (u64): resolved Unix-epoch millisecond cutoff. The CLI accepts `--before-ms` or `--older-than-ms` with the same resolution semantics as `covenant capabilities purge --json` above. Pinned as u64 by `main.rs:7830-7833` — never a string-of-integer. +- `purged` (u64): count of revoked-peer rows removed. Only revoked rows are eligible — the verb does not touch live peers (the unsuffixed CLI prints `purged revoked peer(s)` at `main.rs:3699`). May legitimately be `0` when no rows matched. Pinned as u64 by `main.rs:7834-7837` — never a string-of-integer. -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7114` (`peers_purge_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7813` (`peers_purge_json_pins_top_level_schema`). -The envelope source-of-truth lives at `peers_purge_json` in `agent-os/crates/covenant/src/main.rs:5337`. Two unit tests at `main.rs:7106` (`peers_purge_json_renders_stable_shape`) and `main.rs:7114` cover the populated and empty cases. The CLI verb is wired at `main.rs:3629-3675`. +The envelope source-of-truth lives at `peers_purge_json` in `agent-os/crates/covenant/src/main.rs:6036`. Two unit tests at `main.rs:7805` (`peers_purge_json_renders_stable_shape`) and `main.rs:7813` cover the populated and empty cases. The CLI verb is wired at `main.rs:3659-3705`. `covenant peers rotate --json` emits the new operator token after rotation. Envelope shape: -- `kind`: literal string `"peer_token_rotated"`. Pinned at the value level by `main.rs:7169` (asserts `value["kind"].as_str() == Some("peer_token_rotated")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `token_b58` (string): the full base58 operator token. The value is the new authentication credential, not a fingerprint — the envelope is **secret-bearing** and JSON output must be treated as sensitive (no logging, no shell history capture, no transport over unsecured channels). Pinned as a string by `main.rs:7170-7173` — never bytes or a structured object. +- `kind`: literal string `"peer_token_rotated"`. Pinned at the value level by `main.rs:7868` (asserts `value["kind"].as_str() == Some("peer_token_rotated")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `token_b58` (string): the full base58 operator token. The value is the new authentication credential, not a fingerprint — the envelope is **secret-bearing** and JSON output must be treated as sensitive (no logging, no shell history capture, no transport over unsecured channels). Pinned as a string by `main.rs:7869-7872` — never bytes or a structured object. -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7153` (`peers_rotate_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7852` (`peers_rotate_json_pins_top_level_schema`). -Side effects before the envelope returns (per the CLI comment at `main.rs:4366-4372`): the daemon has already persisted the new token to `$COVENANT_HOME/peers/operator.token` (mode `0600`), so the envelope is informational. Existing shells holding the previous token continue to authenticate with the old value until they re-read the file; consumers that cache the token in memory must refresh after rotation. +Side effects before the envelope returns (per the CLI comment at `main.rs:4396-4402`): the daemon has already persisted the new token to `$COVENANT_HOME/peers/operator.token` (mode `0600`), so the envelope is informational. Existing shells holding the previous token continue to authenticate with the old value until they re-read the file; consumers that cache the token in memory must refresh after rotation. -The envelope source-of-truth lives at `peers_rotate_json` in `agent-os/crates/covenant/src/main.rs:5345`. The shape-pinning tests at `main.rs:7146` (`peers_rotate_json_renders_stable_shape`) and `main.rs:7153` (`peers_rotate_json_pins_top_level_schema`) cover both a typical-token case and an empty-string defensive case (the latter exercises the key-set invariant rather than a legitimate runtime value). The CLI verb is wired at `main.rs:3676-3711`; without `--json`, the same response prints a two-line message terminating in the raw token value. +The envelope source-of-truth lives at `peers_rotate_json` in `agent-os/crates/covenant/src/main.rs:6044`. The shape-pinning tests at `main.rs:7845` (`peers_rotate_json_renders_stable_shape`) and `main.rs:7852` (`peers_rotate_json_pins_top_level_schema`) cover both a typical-token case and an empty-string defensive case (the latter exercises the key-set invariant rather than a legitimate runtime value). The CLI verb is wired at `main.rs:3706-3741`; without `--json`, the same response prints a two-line message terminating in the raw token value. `covenant peers revoke [--force] [--limit-matches ] --json` emits the outcome of revoking a single peer by its base58 token prefix. Envelope shape: -- `kind`: literal string `"peer_revoke"` — verb-form, not past-tense. Distinct from the sibling envelopes whose outcome names took the past-tense form (`capability_revoked`, `peer_token_rotated`, `peers_purged`); consumers routing on `kind` must match the literal exactly rather than guessing `peer_revoked` or `peers_revoke`. Pinned at the value level by `main.rs:6153` (asserts `value["kind"].as_str() == Some("peer_revoke")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `outcome` (object): a tagged-enum `RevokeOutcome` (defined at `agent-os/crates/covenant-peer-auth/src/lib.rs:182` with `#[serde(tag = "type", rename_all = "snake_case")]`). The top-level object has exactly two keys (`kind` and `outcome`); the inner `outcome` is pinned by the schema test at `main.rs:6154-6157` to be a JSON object, never a string blob. +- `kind`: literal string `"peer_revoke"` — verb-form, not past-tense. Distinct from the sibling envelopes whose outcome names took the past-tense form (`capability_revoked`, `peer_token_rotated`, `peers_purged`); consumers routing on `kind` must match the literal exactly rather than guessing `peer_revoked` or `peers_revoke`. Pinned at the value level by `main.rs:6852` (asserts `value["kind"].as_str() == Some("peer_revoke")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `outcome` (object): a tagged-enum `RevokeOutcome` (defined at `agent-os/crates/covenant-peer-auth/src/lib.rs:182` with `#[serde(tag = "type", rename_all = "snake_case")]`). The top-level object has exactly two keys (`kind` and `outcome`); the inner `outcome` is pinned by the schema test at `main.rs:6853-6856` to be a JSON object, never a string blob. The five `RevokeOutcome` variants the daemon may return: @@ -344,18 +344,18 @@ The five `RevokeOutcome` variants the daemon may return: - `{type: "ambiguous", matches: [PeerSummary...], truncated: bool}` — more than one entry matched the prefix; the registry is unchanged. `matches.len()` is bounded by `--limit-matches`; `truncated` is `true` when more than that limit matched (see `RevokeOutcome::Ambiguous` at `covenant-peer-auth/src/lib.rs:207-211`). The field carries `#[serde(default)]` so a stale CLI built before `truncated` landed still deserialises a new daemon's response (degrading to the pre-bound assumption that the displayed matches are exhaustive); the daemon-side serializer always writes the field. - `{type: "self_revoke_forbidden", agent_id, token_prefix, registered_at, revoked_at}` — same inlined `PeerSummary` shape; the unique live match is the operator's own bootstrap row and the request did not pass `--force`. The registry is unchanged and `revoked_at` is `null` (the entry remained live). This is defence-in-depth against the "fat-finger via web UI bypassed by curl" failure mode where a UI-only confirmation guard is trivially circumvented by a direct daemon API call. -**Exit-code coupling**: the `peer_revoke_is_failure` classifier at `agent-os/crates/covenant/src/main.rs:5634-5641` maps `not_found`, `ambiguous`, and `self_revoke_forbidden` to a CLI exit code of `1` — including in the `--json` path (`main.rs:4496-4498`). `revoked` and `already_revoked` map to exit `0`. JSON consumers must branch on `outcome.type` for success/failure semantics; transport success (exit `0`) is **not** synonymous with revocation success. The classifier's mapping is pinned by the test at `main.rs:8599` (`peer_revoke_json_exit_classification_matches_human_cli`). +**Exit-code coupling**: the `peer_revoke_is_failure` classifier at `agent-os/crates/covenant/src/main.rs:6333-6340` maps `not_found`, `ambiguous`, and `self_revoke_forbidden` to a CLI exit code of `1` — including in the `--json` path (`main.rs:4526-4528`). `revoked` and `already_revoked` map to exit `0`. JSON consumers must branch on `outcome.type` for success/failure semantics; transport success (exit `0`) is **not** synonymous with revocation success. The classifier's mapping is pinned by the test at `main.rs:9298` (`peer_revoke_json_exit_classification_matches_human_cli`). -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:6137` (`peer_revoke_json_pins_top_level_schema`), which also asserts `outcome` is a tagged-enum object and exercises both the `Ambiguous` and `NotFound` variants. +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:6836` (`peer_revoke_json_pins_top_level_schema`), which also asserts `outcome` is a tagged-enum object and exercises both the `Ambiguous` and `NotFound` variants. -The envelope source-of-truth lives at `peer_revoke_json` in `agent-os/crates/covenant/src/main.rs:5572`. Two unit tests at `main.rs:6119` (`peer_revoke_json_renders_stable_ambiguous_shape`) and `main.rs:6137` cover the shape. The CLI verb is wired at `main.rs:3776-3876`; without `--json`, `Revoked` and `AlreadyRevoked` print tab-separated success lines to stdout, while `NotFound`, `Ambiguous`, and `SelfRevokeForbidden` print human-readable diagnostics to stderr before exiting `1`. +The envelope source-of-truth lives at `peer_revoke_json` in `agent-os/crates/covenant/src/main.rs:6271`. Two unit tests at `main.rs:6818` (`peer_revoke_json_renders_stable_ambiguous_shape`) and `main.rs:6836` cover the shape. The CLI verb is wired at `main.rs:3806-3906`; without `--json`, `Revoked` and `AlreadyRevoked` print tab-separated success lines to stdout, while `NotFound`, `Ambiguous`, and `SelfRevokeForbidden` print human-readable diagnostics to stderr before exiting `1`. `covenant audit recent [-n|--limit ] [--since-ms ] [--stream] --json` emits a window of audit events. Envelope shape: -- `kind`: literal string `"audit_recent"`. Pinned at the value level by `main.rs:7388` (asserts `value["kind"].as_str() == Some("audit_recent")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `50`, per `main.rs:3210`). Pinned as u64 at the schema test (`main.rs:7389-7392`) — never a string. -- `since_ms` (u64 or null): the Unix-epoch millisecond threshold echoed from `--since-ms`, or `null` when the flag was omitted. Pinned as u64-or-null at the schema test (`main.rs:7393-7396`) — never a string-of-integer. Same semantic as the HTTP gateway query parameter described in the **Query Parameters** section above: events whose `timestamp_ms` is strictly less than the threshold are dropped before the limit truncation. -- `events` (array of `AuditEvent`): the matched events. The array is empty when no events fall in the window. Pinned as an array by `main.rs:7397-7400` — never null or a string. +- `kind`: literal string `"audit_recent"`. Pinned at the value level by `main.rs:8087` (asserts `value["kind"].as_str() == Some("audit_recent")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `50`, per `main.rs:3240`). Pinned as u64 at the schema test (`main.rs:8088-8091`) — never a string. +- `since_ms` (u64 or null): the Unix-epoch millisecond threshold echoed from `--since-ms`, or `null` when the flag was omitted. Pinned as u64-or-null at the schema test (`main.rs:8092-8095`) — never a string-of-integer. Same semantic as the HTTP gateway query parameter described in the **Query Parameters** section above: events whose `timestamp_ms` is strictly less than the threshold are dropped before the limit truncation. +- `events` (array of `AuditEvent`): the matched events. The array is empty when no events fall in the window. Pinned as an array by `main.rs:8096-8099` — never null or a string. The inner `AuditEvent` shape, defined at `agent-os/crates/covenant-audit/src/lib.rs:43`: @@ -364,59 +364,59 @@ The inner `AuditEvent` shape, defined at `agent-os/crates/covenant-audit/src/lib - `issuer` (object) — `{display: string, pubkey: string (base58)}` per the `AgentId` Serialize impl at `covenant-types/src/lib.rs:177`. - `kind` (object) — tagged-enum `AuditKind` (defined at `covenant-audit/src/lib.rs:71` onwards) with a `type` discriminator (e.g., `"capability_granted"`, `"intent_dispatched"`, `"hermes_tool_invoked"`) and variant-specific extra fields. Consumers must route on `kind.type` before reading variant-specific fields. -Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:7372` (`audit_recent_json_pins_top_level_schema`), exercised against three cases: populated with `since_ms`, empty with `since_ms`, and empty without `since_ms`. +Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8071` (`audit_recent_json_pins_top_level_schema`), exercised against three cases: populated with `since_ms`, empty with `since_ms`, and empty without `since_ms`. -The envelope source-of-truth lives at `audit_recent_json` in `agent-os/crates/covenant/src/main.rs:5367`. Two unit tests at `main.rs:7345` (`audit_recent_json_renders_stable_shape`) and `main.rs:7372` cover the shape. The CLI verb is wired at `main.rs:3209-3279`; without `--json`, the same response is rendered as JSONL (one `AuditEvent` per line at `main.rs:3272`) mirroring the durable `audit/events.jsonl` row shape, with `(no audit events)` printed at `main.rs:3269` when empty. The optional `--stream` flag sets `Request::RecentAudit.prefer_stream = Some(true)` (`main.rs:3239`), enabling the v2 streaming-response path documented under [docs/protocol-versioning.md](./protocol-versioning.md); the terminal-response shape is unchanged when the streaming path is not selected. +The envelope source-of-truth lives at `audit_recent_json` in `agent-os/crates/covenant/src/main.rs:6066`. Two unit tests at `main.rs:8044` (`audit_recent_json_renders_stable_shape`) and `main.rs:8071` cover the shape. The CLI verb is wired at `main.rs:3239-3309`; without `--json`, the same response is rendered as JSONL (one `AuditEvent` per line at `main.rs:3302`) mirroring the durable `audit/events.jsonl` row shape, with `(no audit events)` printed at `main.rs:3299` when empty. The optional `--stream` flag sets `Request::RecentAudit.prefer_stream = Some(true)` (`main.rs:3269`), enabling the v2 streaming-response path documented under [docs/protocol-versioning.md](./protocol-versioning.md); the terminal-response shape is unchanged when the streaming path is not selected. `covenant audit purge --json` emits a summary of time-bounded audit-log garbage collection. Envelope shape: -- `kind`: literal string `"audit_purged"`. Pinned at the value level by `main.rs:7329` (asserts `value["kind"].as_str() == Some("audit_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `before_ms` (u64): resolved Unix-epoch millisecond cutoff. The CLI accepts `--before-ms` or `--older-than-ms` with the same resolution semantics as `covenant capabilities purge --json` above. Pinned as u64 by `main.rs:7330-7333` — never a string-of-integer. -- `purged` (u64): count of audit events removed (the unsuffixed CLI message at `main.rs:3339` reads `purged event(s)`, confirming the unit is an audit event, not a row class). May legitimately be `0` when no rows matched. Pinned as u64 by `main.rs:7334-7337` — never a string-of-integer. +- `kind`: literal string `"audit_purged"`. Pinned at the value level by `main.rs:8028` (asserts `value["kind"].as_str() == Some("audit_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `before_ms` (u64): resolved Unix-epoch millisecond cutoff. The CLI accepts `--before-ms` or `--older-than-ms` with the same resolution semantics as `covenant capabilities purge --json` above. Pinned as u64 by `main.rs:8029-8032` — never a string-of-integer. +- `purged` (u64): count of audit events removed (the unsuffixed CLI message at `main.rs:3369` reads `purged event(s)`, confirming the unit is an audit event, not a row class). May legitimately be `0` when no rows matched. Pinned as u64 by `main.rs:8033-8036` — never a string-of-integer. Unlike the capability- and peer-purge verbs, this removes hash-chain entries; the cutoff enforcement is bound to the `audit.purge` capability scope at dispatch time so a delegated caller cannot purge beyond its scope's `before_ms` (see `docs/capabilities.md`). -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7313` (`audit_purge_json_pins_top_level_schema`). +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:8012` (`audit_purge_json_pins_top_level_schema`). -The envelope source-of-truth lives at `audit_purge_json` in `agent-os/crates/covenant/src/main.rs:5359`. Two unit tests at `main.rs:7305` (`audit_purge_json_renders_stable_shape`) and `main.rs:7313` cover the populated (`purged=3`) and empty (`purged=0`) cases. The CLI verb is wired at `main.rs:3303-3345`. +The envelope source-of-truth lives at `audit_purge_json` in `agent-os/crates/covenant/src/main.rs:6058`. Two unit tests at `main.rs:8004` (`audit_purge_json_renders_stable_shape`) and `main.rs:8012` cover the populated (`purged=3`) and empty (`purged=0`) cases. The CLI verb is wired at `main.rs:3333-3375`. `covenant audit verify --json` emits the audit-log hash-chain integrity report. Envelope shape: -- `kind`: literal string `"audit_integrity"` — past-tense outcome name, distinct from the verb name `verify` and from the workspace-level `verify_report` envelope; consumers routing on `kind` must match this literal exactly rather than reusing either of those tokens. Pinned at the value level by `main.rs:7466` (asserts `value["kind"].as_str() == Some("audit_integrity")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `report` (object): a structured `covenant_audit::AuditIntegrityReport`, never a string blob. The top-level object has exactly two keys (`kind` and `report`); the inner `report` is pinned by the schema test at `main.rs:7467-7470` to be a JSON object. +- `kind`: literal string `"audit_integrity"` — past-tense outcome name, distinct from the verb name `verify` and from the workspace-level `verify_report` envelope; consumers routing on `kind` must match this literal exactly rather than reusing either of those tokens. Pinned at the value level by `main.rs:8165` (asserts `value["kind"].as_str() == Some("audit_integrity")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `report` (object): a structured `covenant_audit::AuditIntegrityReport`, never a string blob. The top-level object has exactly two keys (`kind` and `report`); the inner `report` is pinned by the schema test at `main.rs:8166-8169` to be a JSON object. The inner `AuditIntegrityReport` shape, defined at `agent-os/crates/covenant-audit/src/lib.rs:61`: - `events` (u64) — total audit events the integrity walk visited. - `anchors` (u64) — count of anchor records (root-hash checkpoints) the walk crossed. - `valid` (bool) — `true` when the hash chain is intact end-to-end; `false` when one or more failures were recorded. -- `root_hash_hex` (string) — the final root hash as lowercase hex, 64 characters (SHA-256). Pinned at the length level by the stable-shape test at `main.rs:7436-7442`. -- `failures` (array of strings) — human-readable failure descriptions (e.g., `"chain hash mismatch at event 3"`), empty when `valid` is `true`. The empty case is pinned by the stable-shape test at `main.rs:7443-7446` (asserts `as_array().map(Vec::len) == Some(0)`). +- `root_hash_hex` (string) — the final root hash as lowercase hex, 64 characters (SHA-256). Pinned at the length level by the stable-shape test at `main.rs:8135-8141`. +- `failures` (array of strings) — human-readable failure descriptions (e.g., `"chain hash mismatch at event 3"`), empty when `valid` is `true`. The empty case is pinned by the stable-shape test at `main.rs:8142-8145` (asserts `as_array().map(Vec::len) == Some(0)`). -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7450` (`audit_verify_json_pins_top_level_schema`), exercised against both a valid and an invalid report. +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:8149` (`audit_verify_json_pins_top_level_schema`), exercised against both a valid and an invalid report. -The envelope source-of-truth lives at `audit_verify_json` in `agent-os/crates/covenant/src/main.rs:5380`. Two unit tests at `main.rs:7422` (`audit_verify_json_renders_stable_shape`) and `main.rs:7450` cover the shape. The CLI verb is wired at `main.rs:3280-3302`; without `--json`, the same response is printed as the bare `AuditIntegrityReport` JSON (no envelope wrapper) at `main.rs:3296`, so JSON consumers must use `--json` to get the kind-discriminated envelope — the unsuffixed output is structurally compatible with `report` but lacks the `kind` field. +The envelope source-of-truth lives at `audit_verify_json` in `agent-os/crates/covenant/src/main.rs:6079`. Two unit tests at `main.rs:8121` (`audit_verify_json_renders_stable_shape`) and `main.rs:8149` cover the shape. The CLI verb is wired at `main.rs:3310-3332`; without `--json`, the same response is printed as the bare `AuditIntegrityReport` JSON (no envelope wrapper) at `main.rs:3326`, so JSON consumers must use `--json` to get the kind-discriminated envelope — the unsuffixed output is structurally compatible with `report` but lacks the `kind` field. `covenant memory purge --json` emits a summary of time-bounded memory-store garbage collection. Envelope shape: -- `kind`: literal string `"memory_purged"`. Pinned at the value level by `main.rs:7521` (asserts `value["kind"].as_str() == Some("memory_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `tier` (string or null): the memory tier slug — exactly one of `"working"`, `"episodic"`, or `"longterm"` (one word, per `memory_tier_slug` at `main.rs:1725-1730`). Null when `--tier` was omitted, meaning the purge applied to all tiers. Note an input-form asymmetry: the CLI parser at `main.rs:1735-1737` accepts `longterm`, `long-term`, and `long_term` for the `--tier` argument, but only the `longterm` slug is ever emitted in the envelope. Pinned as string-or-null by `main.rs:7522-7525` — never a structured object. -- `before_ms` (u64): resolved Unix-epoch millisecond cutoff. Same `--before-ms` / `--older-than-ms` resolution semantics as `covenant capabilities purge --json` above. Pinned as u64 by `main.rs:7526-7529` — never a string-of-integer. -- `purged` (u64): count of memory records removed. The unsuffixed CLI prints `purged record(s)` at `main.rs:2855`, confirming the unit is a memory record. May legitimately be `0` when no rows matched. Pinned as u64 by `main.rs:7530-7533` — never a string-of-integer. +- `kind`: literal string `"memory_purged"`. Pinned at the value level by `main.rs:8220` (asserts `value["kind"].as_str() == Some("memory_purged")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `tier` (string or null): the memory tier slug — exactly one of `"working"`, `"episodic"`, or `"longterm"` (one word, per `memory_tier_slug` at `main.rs:1725-1730`). Null when `--tier` was omitted, meaning the purge applied to all tiers. Note an input-form asymmetry: the CLI parser at `main.rs:1735-1737` accepts `longterm`, `long-term`, and `long_term` for the `--tier` argument, but only the `longterm` slug is ever emitted in the envelope. Pinned as string-or-null by `main.rs:8221-8224` — never a structured object. +- `before_ms` (u64): resolved Unix-epoch millisecond cutoff. Same `--before-ms` / `--older-than-ms` resolution semantics as `covenant capabilities purge --json` above. Pinned as u64 by `main.rs:8225-8228` — never a string-of-integer. +- `purged` (u64): count of memory records removed. The unsuffixed CLI prints `purged record(s)` at `main.rs:2885`, confirming the unit is a memory record. May legitimately be `0` when no rows matched. Pinned as u64 by `main.rs:8229-8232` — never a string-of-integer. -Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:7505` (`memory_purge_json_pins_top_level_schema`), which also exercises the null-tier case. +Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8204` (`memory_purge_json_pins_top_level_schema`), which also exercises the null-tier case. -The envelope source-of-truth lives at `memory_purge_json` in `agent-os/crates/covenant/src/main.rs:5387`. Two unit tests at `main.rs:7493` (`memory_purge_json_renders_stable_shape`, both a Working-tier populated case and a no-tier null case) and `main.rs:7505` cover the populated and empty (`purged=0`, no-tier) cases. The CLI verb is wired at `main.rs:2126-2182`. +The envelope source-of-truth lives at `memory_purge_json` in `agent-os/crates/covenant/src/main.rs:6086`. Two unit tests at `main.rs:8192` (`memory_purge_json_renders_stable_shape`, both a Working-tier populated case and a no-tier null case) and `main.rs:8204` cover the populated and empty (`purged=0`, no-tier) cases. The CLI verb is wired at `main.rs:2126-2212`. `covenant memory recent [--tier ] [-n|--limit ] [--stream] --json` and `covenant memory search [--tier ] [-n|--limit ] [--min-relevance ] --json` both emit the same memory-read envelope, distinguished only by the `mode` discriminator. Envelope shape: -- `kind`: literal string `"memory_read"`. Pinned at the value level by `main.rs:7821` (asserts `value["kind"].as_str() == Some("memory_read")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `mode` (string): exactly one of `"recent"` or `"search"` (lowercase, matching the CLI verb name — no other values are emitted). Consumers must route on `mode` to know which null pattern to expect across `query` and `min_relevance`. Pinned as a string by `main.rs:7822` — never an object or array. -- `tier` (string or null): the requested `MemoryTier` as its lowercase wire slug — exactly one of `"working"`, `"episodic"`, or `"longterm"` (one word, per `MemoryTier`'s `#[serde(rename_all = "lowercase")]` at `covenant-types/src/lib.rs:23` and the slug map at `memory_tier_slug` in `main.rs:1725-1730`). The CLI parser accepts `longterm`, `long-term`, and `long_term` as input forms for `--tier`, but only the `longterm` slug is ever emitted. `null` when `--tier` was omitted (meaning the request applied to all tiers). Pinned as string-or-null by the schema test (`main.rs:7827-7830`) — never a structured object. -- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10` for both verbs, per `main.rs:2083` and `main.rs:2499`). Pinned as u64 at the schema test (`main.rs:7823-7826`). -- `query` (string or null): for `mode="search"`, the request query (whitespace-joined when the operator passed multiple positional tokens, per `main.rs:2534`). For `mode="recent"`, always `null` (the recent verb does not accept a query). Pinned as string-or-null by the schema test (`main.rs:7831-7834`). -- `min_relevance` (number or null): for `mode="search"`, the float echoed from `--min-relevance` (validated to a finite `f32` in `[0.0, 1.0]` at `main.rs:2522-2526`), or `null` when the flag was omitted. For `mode="recent"`, always `null`. Pinned as f64-or-null by the schema test (`main.rs:7835-7838`) — never a string. -- `records` (array of `MemoryRecord`): the matched records in the order returned by the daemon. The array is empty when no records match; the unsuffixed CLI prints `(no records)` for that case at `main.rs:1631`. Pinned as an array by `main.rs:7839-7842` — never null or a string. +- `kind`: literal string `"memory_read"`. Pinned at the value level by `main.rs:8520` (asserts `value["kind"].as_str() == Some("memory_read")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `mode` (string): exactly one of `"recent"` or `"search"` (lowercase, matching the CLI verb name — no other values are emitted). Consumers must route on `mode` to know which null pattern to expect across `query` and `min_relevance`. Pinned as a string by `main.rs:8521` — never an object or array. +- `tier` (string or null): the requested `MemoryTier` as its lowercase wire slug — exactly one of `"working"`, `"episodic"`, or `"longterm"` (one word, per `MemoryTier`'s `#[serde(rename_all = "lowercase")]` at `covenant-types/src/lib.rs:23` and the slug map at `memory_tier_slug` in `main.rs:1725-1730`). The CLI parser accepts `longterm`, `long-term`, and `long_term` as input forms for `--tier`, but only the `longterm` slug is ever emitted. `null` when `--tier` was omitted (meaning the request applied to all tiers). Pinned as string-or-null by the schema test (`main.rs:8526-8529`) — never a structured object. +- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10` for both verbs, per `main.rs:2083` and `main.rs:2529`). Pinned as u64 at the schema test (`main.rs:8522-8525`). +- `query` (string or null): for `mode="search"`, the request query (whitespace-joined when the operator passed multiple positional tokens, per `main.rs:2564`). For `mode="recent"`, always `null` (the recent verb does not accept a query). Pinned as string-or-null by the schema test (`main.rs:8530-8533`). +- `min_relevance` (number or null): for `mode="search"`, the float echoed from `--min-relevance` (validated to a finite `f32` in `[0.0, 1.0]` at `main.rs:2552-2556`), or `null` when the flag was omitted. For `mode="recent"`, always `null`. Pinned as f64-or-null by the schema test (`main.rs:8534-8537`) — never a string. +- `records` (array of `MemoryRecord`): the matched records in the order returned by the daemon. The array is empty when no records match; the unsuffixed CLI prints `(no records)` for that case at `main.rs:1631`. Pinned as an array by `main.rs:8538-8541` — never null or a string. The inner `MemoryRecord` shape, defined at `agent-os/crates/covenant-types/src/lib.rs:236`: @@ -429,19 +429,19 @@ The inner `MemoryRecord` shape, defined at `agent-os/crates/covenant-types/src/l - `created_at` (u64) — Unix-epoch milliseconds when the record was written. - `parent` (string or null) — parent record UUID for derived memories. Carries `#[serde(default)]` at `covenant-types/src/lib.rs:245-246` **without** `skip_serializing_if`, so the field is **always emitted** (as `null` when the record has no parent), not omitted. JSON consumers must read it with null-vs-value, not key-existence. -Top-level keys are pinned to exactly these seven by the test at `agent-os/crates/covenant/src/main.rs:7797` (`memory_read_json_pins_top_level_schema`), exercised against both a `mode="search"` case (populated `query`, `min_relevance`, non-empty `records`) and a `mode="recent"` case (null `query`, null `min_relevance`, empty `records`). +Top-level keys are pinned to exactly these seven by the test at `agent-os/crates/covenant/src/main.rs:8496` (`memory_read_json_pins_top_level_schema`), exercised against both a `mode="search"` case (populated `query`, `min_relevance`, non-empty `records`) and a `mode="recent"` case (null `query`, null `min_relevance`, empty `records`). -The envelope source-of-truth lives at `memory_read_json` in `agent-os/crates/covenant/src/main.rs:5415`. Two unit tests at `main.rs:7754` (`memory_read_json_renders_stable_shape`) and `main.rs:7797` cover both modes. The CLI verbs are wired at `main.rs:2081-2125` (`covenant memory recent`) and `main.rs:2493-2559` (`covenant memory search`); without `--json`, each record prints as `[] : ` at `main.rs:1635`. The optional `--stream` flag is accepted only by `covenant memory recent` (per `main.rs:2100`) and sets `Request::RecentMemory.prefer_stream = Some(true)` to enable the v2 streaming-response path documented under [docs/protocol-versioning.md](./protocol-versioning.md); the terminal envelope shape is unchanged when the streaming path is not selected. `covenant memory search` has no `--stream` flag. +The envelope source-of-truth lives at `memory_read_json` in `agent-os/crates/covenant/src/main.rs:6114`. Two unit tests at `main.rs:8453` (`memory_read_json_renders_stable_shape`) and `main.rs:8496` cover both modes. The CLI verbs are wired at `main.rs:2081-2125` (`covenant memory recent`) and `main.rs:2523-2589` (`covenant memory search`); without `--json`, each record prints as `[] : ` at `main.rs:1635`. The optional `--stream` flag is accepted only by `covenant memory recent` (per `main.rs:2100`) and sets `Request::RecentMemory.prefer_stream = Some(true)` to enable the v2 streaming-response path documented under [docs/protocol-versioning.md](./protocol-versioning.md); the terminal envelope shape is unchanged when the streaming path is not selected. `covenant memory search` has no `--stream` flag. `covenant a2a status [-n|--limit ] [--min-lease-age-ms ] [--deadline-within-ms ] [--state queued|in_flight] --json` emits the current A2A queue snapshot — queued tasks, in-flight leases, and pending results — narrowed by the supplied filters. Envelope shape: -- `kind`: literal string `"a2a_status"`. Pinned at the value level by `main.rs:8392` (asserts `value["kind"].as_str() == Some("a2a_status")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10`, per `main.rs:4039`). Pinned as u64 by the schema test (`main.rs:8393-8396`). -- `min_lease_age_ms` (u64 or null): the threshold echoed from `--min-lease-age-ms`, or `null` when the flag was omitted. Always emitted (as `null` when inactive) — never omitted from the envelope. Pinned as u64-or-null by the schema test (`main.rs:8397-8400`). -- `deadline_within_ms` (u64 or null): the threshold echoed from `--deadline-within-ms`, or `null` when the flag was omitted. Same always-emitted-as-null contract as `min_lease_age_ms`. Pinned as u64-or-null by the schema test (`main.rs:8401-8404`). -- `state_filter` (string or null): the `A2ATaskQueueState` slug echoed from `--state` — exactly `"queued"` or `"in_flight"` (snake_case, per `A2ATaskQueueState`'s `#[serde(rename_all = "snake_case")]` at `covenant-a2a/src/lib.rs:124-129`), or `null` when the flag was omitted. Pinned as string-or-null by the schema test (`main.rs:8405-8408`) — never an integer or array. Consumers must route on the lowercase wire form, **not** the Rust TitleCase names (`"Queued"`, `"InFlight"`). -- `tasks` (array of `A2ATaskQueueEntry`): the matched queue entries in the order returned by the daemon. The array may be empty. Pinned as an array by `main.rs:8409` — never null or a string blob. -- `results` (array of `A2ATaskResult`): pending results not yet acknowledged. The array may be empty; the unsuffixed CLI prints `(a2a queue empty)` at `main.rs:4104` when both `tasks` and `results` are empty. Pinned as an array by `main.rs:8410-8413` — never null or a string. +- `kind`: literal string `"a2a_status"`. Pinned at the value level by `main.rs:9091` (asserts `value["kind"].as_str() == Some("a2a_status")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `limit` (u64): the request limit echoed back from `-n`/`--limit` (default `10`, per `main.rs:4069`). Pinned as u64 by the schema test (`main.rs:9092-9095`). +- `min_lease_age_ms` (u64 or null): the threshold echoed from `--min-lease-age-ms`, or `null` when the flag was omitted. Always emitted (as `null` when inactive) — never omitted from the envelope. Pinned as u64-or-null by the schema test (`main.rs:9096-9099`). +- `deadline_within_ms` (u64 or null): the threshold echoed from `--deadline-within-ms`, or `null` when the flag was omitted. Same always-emitted-as-null contract as `min_lease_age_ms`. Pinned as u64-or-null by the schema test (`main.rs:9100-9103`). +- `state_filter` (string or null): the `A2ATaskQueueState` slug echoed from `--state` — exactly `"queued"` or `"in_flight"` (snake_case, per `A2ATaskQueueState`'s `#[serde(rename_all = "snake_case")]` at `covenant-a2a/src/lib.rs:124-129`), or `null` when the flag was omitted. Pinned as string-or-null by the schema test (`main.rs:9104-9107`) — never an integer or array. Consumers must route on the lowercase wire form, **not** the Rust TitleCase names (`"Queued"`, `"InFlight"`). +- `tasks` (array of `A2ATaskQueueEntry`): the matched queue entries in the order returned by the daemon. The array may be empty. Pinned as an array by `main.rs:9108` — never null or a string blob. +- `results` (array of `A2ATaskResult`): pending results not yet acknowledged. The array may be empty; the unsuffixed CLI prints `(a2a queue empty)` at `main.rs:4134` when both `tasks` and `results` are empty. Pinned as an array by `main.rs:9109-9112` — never null or a string. The inner `A2ATaskQueueEntry` shape, defined at `agent-os/crates/covenant-a2a/src/lib.rs:132`: @@ -470,16 +470,16 @@ The inner `A2ATaskResult` shape, defined at `agent-os/crates/covenant-a2a/src/li - `content` (array of `Content`) — the same tagged-enum `Content` shape (`{type: "text", text: }` or `{type: "json", value: }`) already documented in the `tool_result` block above; empty for `error` results per `A2ATaskResult::error` at `covenant-a2a/src/lib.rs:406-413`. - `error_message` (string, omitted when null) — diagnostic message for `error` results; `skip_serializing_if = "Option::is_none"` per `covenant-a2a/src/lib.rs:392-393`. Absent on `ok` and `partial` results. -Top-level keys are pinned to exactly these seven by the test at `agent-os/crates/covenant/src/main.rs:8368` (`a2a_status_json_pins_top_level_schema`), exercised against both a populated-filters case and an all-null-filters case. +Top-level keys are pinned to exactly these seven by the test at `agent-os/crates/covenant/src/main.rs:9067` (`a2a_status_json_pins_top_level_schema`), exercised against both a populated-filters case and an all-null-filters case. -The envelope source-of-truth lives at `a2a_status_json` in `agent-os/crates/covenant/src/main.rs:5546`. Three unit tests at `main.rs:8317` (`a2a_status_json_renders_stable_shape`), `main.rs:8359` (`a2a_status_json_omits_deadline_filter_when_inactive`, which pins the always-emitted-as-null contract on the filter fields), and `main.rs:8368` cover the shape. The CLI verb is wired at `main.rs:3361-3446`; without `--json`, the same response is rendered as JSONL with each task printed as `{"type": "task", "entry": }` and each result as `{"type": "result", "result": }` (per `main.rs:3429-3440`) — a different envelope shape than `--json`, so JSON consumers must use `--json` to get the kind-discriminated envelope. +The envelope source-of-truth lives at `a2a_status_json` in `agent-os/crates/covenant/src/main.rs:6245`. Three unit tests at `main.rs:9016` (`a2a_status_json_renders_stable_shape`), `main.rs:9058` (`a2a_status_json_omits_deadline_filter_when_inactive`, which pins the always-emitted-as-null contract on the filter fields), and `main.rs:9067` cover the shape. The CLI verb is wired at `main.rs:3391-3476`; without `--json`, the same response is rendered as JSONL with each task printed as `{"type": "task", "entry": }` and each result as `{"type": "result", "result": }` (per `main.rs:3459-3470`) — a different envelope shape than `--json`, so JSON consumers must use `--json` to get the kind-discriminated envelope. `covenant a2a retry-stale [--enable] [--min-lease-age-ms ] [--max-attempts ] [--max-requeues ] [--scan-limit ] --json` emits a per-call report describing what the auto-retry scan considered, requeued, and skipped. Envelope shape: -- `kind`: literal string `"a2a_auto_retry"`. Pinned at the value level by `main.rs:7269` (asserts `value["kind"].as_str() == Some("a2a_auto_retry")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `report` (object): a structured `A2AAutoRetryReport` (defined at `agent-os/crates/covenant-a2a/src/lib.rs:288`), never a string blob. The top-level object has exactly two keys (`kind` and `report`); the inner `report` is pinned by the schema test at `main.rs:7270-7273` to be a JSON object. +- `kind`: literal string `"a2a_auto_retry"`. Pinned at the value level by `main.rs:7968` (asserts `value["kind"].as_str() == Some("a2a_auto_retry")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `report` (object): a structured `A2AAutoRetryReport` (defined at `agent-os/crates/covenant-a2a/src/lib.rs:288`), never a string blob. The top-level object has exactly two keys (`kind` and `report`); the inner `report` is pinned by the schema test at `main.rs:7969-7972` to be a JSON object. -**Dry-run by default**: `A2AAutoRetryPolicy.enabled` defaults to `false` (per `Default for A2AAutoRetryPolicy` at `covenant-a2a/src/lib.rs:228-238`), and the CLI's `--enable` flag is the only path that flips it (`main.rs:3549`). On a `--json` call without `--enable`, every queue entry will appear under `skipped[]` with `reason: "disabled"` and the registry will not be mutated — a `requeued=0` result there is **not** a "nothing to retry" signal. Consumers analysing the report must read `report.policy.enabled` before drawing conclusions about whether `considered` minus `requeued.len()` indicates real skip pressure or a dry-run preview. +**Dry-run by default**: `A2AAutoRetryPolicy.enabled` defaults to `false` (per `Default for A2AAutoRetryPolicy` at `covenant-a2a/src/lib.rs:228-238`), and the CLI's `--enable` flag is the only path that flips it (`main.rs:3579`). On a `--json` call without `--enable`, every queue entry will appear under `skipped[]` with `reason: "disabled"` and the registry will not be mutated — a `requeued=0` result there is **not** a "nothing to retry" signal. Consumers analysing the report must read `report.policy.enabled` before drawing conclusions about whether `considered` minus `requeued.len()` indicates real skip pressure or a dry-run preview. The inner `A2AAutoRetryReport` shape: @@ -524,143 +524,143 @@ The inner `A2AAutoRetrySkipped` shape, defined at `covenant-a2a/src/lib.rs:271`: Consumers must route on the lowercase wire form, **not** the Rust TitleCase names (`"Disabled"`, `"NotInFlight"`, etc.) — those never appear on the wire. -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7253` (`a2a_retry_json_pins_top_level_schema`), exercised against both a populated (requeued + skipped) case and an empty (fresh policy) case. +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7952` (`a2a_retry_json_pins_top_level_schema`), exercised against both a populated (requeued + skipped) case and an empty (fresh policy) case. -The envelope source-of-truth lives at `a2a_retry_json` in `agent-os/crates/covenant/src/main.rs:5565`. Two unit tests at `main.rs:7216` (`a2a_retry_json_renders_stable_shape`) and `main.rs:7253` cover the shape. The CLI verb is wired at `main.rs:3536-3592`; without `--json`, the same response prints `considered task(s), requeued , skipped ` followed by `automatic retry disabled; pass --enable to mutate` whenever `report.policy.enabled` is `false` (per `main.rs:3578-3586`). +The envelope source-of-truth lives at `a2a_retry_json` in `agent-os/crates/covenant/src/main.rs:6264`. Two unit tests at `main.rs:7915` (`a2a_retry_json_renders_stable_shape`) and `main.rs:7952` cover the shape. The CLI verb is wired at `main.rs:3566-3622`; without `--json`, the same response prints `considered task(s), requeued , skipped ` followed by `automatic retry disabled; pass --enable to mutate` whenever `report.policy.enabled` is `false` (per `main.rs:3608-3616`). `covenant a2a compact --json` emits a summary of the event-log compaction that drops lines for fully-resolved A2A tasks. Envelope shape: -- `kind`: literal string `"a2a_compacted"` — past-tense outcome name, distinct from the verb name `compact`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token (`"a2a_compact"`) or guessing a noun form (`"a2a_compaction"`). Pinned at the value level by `main.rs:7204` (asserts `value["kind"].as_str() == Some("a2a_compacted")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `dropped` (u64): count of event-log lines removed for resolved tasks. May legitimately be `0` when no resolved tasks remain — the unsuffixed CLI still prints `dropped 0 a2a event(s)` at `main.rs:3609`, and JSON consumers must not treat `dropped=0` as an error. Pinned as u64 by `main.rs:7205-7208` — never a string-of-integer. +- `kind`: literal string `"a2a_compacted"` — past-tense outcome name, distinct from the verb name `compact`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token (`"a2a_compact"`) or guessing a noun form (`"a2a_compaction"`). Pinned at the value level by `main.rs:7903` (asserts `value["kind"].as_str() == Some("a2a_compacted")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `dropped` (u64): count of event-log lines removed for resolved tasks. May legitimately be `0` when no resolved tasks remain — the unsuffixed CLI still prints `dropped 0 a2a event(s)` at `main.rs:3639`, and JSON consumers must not treat `dropped=0` as an error. Pinned as u64 by `main.rs:7904-7907` — never a string-of-integer. -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7188` (`a2a_compact_json_pins_top_level_schema`), exercised against both a populated (`dropped=3`) and an empty (`dropped=0`) case. +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7887` (`a2a_compact_json_pins_top_level_schema`), exercised against both a populated (`dropped=3`) and an empty (`dropped=0`) case. -The envelope source-of-truth lives at `a2a_compact_json` in `agent-os/crates/covenant/src/main.rs:5352`. Two unit tests at `main.rs:7181` (`a2a_compact_json_renders_stable_shape`) and `main.rs:7188` cover the shape. The CLI verb is wired at `main.rs:3593-3615`; without `--json`, the same response prints `dropped a2a event(s)` at `main.rs:3609`. +The envelope source-of-truth lives at `a2a_compact_json` in `agent-os/crates/covenant/src/main.rs:6051`. Two unit tests at `main.rs:7880` (`a2a_compact_json_renders_stable_shape`) and `main.rs:7887` cover the shape. The CLI verb is wired at `main.rs:3623-3645`; without `--json`, the same response prints `dropped a2a event(s)` at `main.rs:3639`. `covenant memory compact --reason [--apply] [--detach-stale-parents] [--delete-working-before-ms | --delete-working-older-than-ms ] [--delete-episodic-before-ms | --delete-episodic-older-than-ms ] [--mark-longterm-stale-before-ms | --mark-longterm-stale-older-than-ms ] [--marked-at-ms ] --json` emits the outcome of a memory-store compaction pass. Envelope shape: -- `kind`: literal string `"memory_compacted"` — past-tense outcome name, distinct from the verb name `compact`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token (`"memory_compact"`) or guessing a noun form (`"memory_compaction"`). Pinned at the value level by `main.rs:7589` (asserts `value["kind"].as_str() == Some("memory_compacted")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `outcome` (object): a structured `MemoryCompactionOutcome` (defined at `agent-os/crates/covenant-types/src/lib.rs:350`), never a string blob. The top-level object has exactly two keys (`kind` and `outcome`); the inner `outcome` is pinned by the schema test at `main.rs:7590-7593` to be a JSON object. +- `kind`: literal string `"memory_compacted"` — past-tense outcome name, distinct from the verb name `compact`; consumers routing on `kind` must match the literal exactly rather than reusing the verb token (`"memory_compact"`) or guessing a noun form (`"memory_compaction"`). Pinned at the value level by `main.rs:8288` (asserts `value["kind"].as_str() == Some("memory_compacted")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `outcome` (object): a structured `MemoryCompactionOutcome` (defined at `agent-os/crates/covenant-types/src/lib.rs:350`), never a string blob. The top-level object has exactly two keys (`kind` and `outcome`); the inner `outcome` is pinned by the schema test at `main.rs:8289-8292` to be a JSON object. -**Dry-run by default, mutates only with `--apply`**: the CLI defaults to `MemoryRepairMode::DryRun` (per `main.rs:2954-2962`) and `--reason ` is mandatory regardless of mode (the CLI bails with `"missing --reason"` at `main.rs:2961` when omitted). Without `--apply`, the daemon evaluates the policy and reports what *would* change but does not mutate the store. +**Dry-run by default, mutates only with `--apply`**: the CLI defaults to `MemoryRepairMode::DryRun` (per `main.rs:2984-2992`) and `--reason ` is mandatory regardless of mode (the CLI bails with `"missing --reason"` at `main.rs:2991` when omitted). Without `--apply`, the daemon evaluates the policy and reports what *would* change but does not mutate the store. The inner `MemoryCompactionOutcome` shape: - `mode` (string) — `MemoryRepairMode` slug, exactly `"dry_run"` or `"apply"` (snake_case, per `MemoryRepairMode`'s `#[serde(rename_all = "snake_case")]` at `covenant-types/src/lib.rs:249-254`). Consumers must route on the lowercase wire form, **not** the Rust TitleCase names. - `would_change` (bool) — the policy identified at least one mutation that would land. Reliable in both modes — `true` whenever the policy matched records. - `changed` (bool) — the store was actually mutated by this call. In `mode: "dry_run"` this is **always `false`** even when `would_change` is `true`; only `mode: "apply"` can set it. JSON consumers branching on `changed` alone will silently treat dry-run planning runs as no-ops; route on the `(mode, would_change, changed)` triple instead. -- `deleted` (array of strings) — UUIDs of records the policy deleted (in `apply` mode) or would delete (in `dry_run` mode). The empty-case is pinned by the stable-shape test at `main.rs:7560-7563` (asserts `value["outcome"]["deleted"].as_array().map(Vec::len) == Some(0)`). +- `deleted` (array of strings) — UUIDs of records the policy deleted (in `apply` mode) or would delete (in `dry_run` mode). The empty-case is pinned by the stable-shape test at `main.rs:8259-8262` (asserts `value["outcome"]["deleted"].as_array().map(Vec::len) == Some(0)`). - `stale_marked` (array of strings) — UUIDs of long-term records the policy marked stale (or would mark, in dry-run mode). -- `parents_detached` (array of strings) — UUIDs of records whose parent pointer the policy detached (or would detach, when `--detach-stale-parents` is supplied). The empty-case is pinned by the stable-shape test at `main.rs:7564-7569` (asserts `value["outcome"]["parents_detached"].as_array().map(Vec::len) == Some(0)`). +- `parents_detached` (array of strings) — UUIDs of records whose parent pointer the policy detached (or would detach, when `--detach-stale-parents` is supplied). The empty-case is pinned by the stable-shape test at `main.rs:8263-8268` (asserts `value["outcome"]["parents_detached"].as_array().map(Vec::len) == Some(0)`). -Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:7573` (`memory_compaction_json_pins_top_level_schema`), exercised against both a populated `apply` case and an empty `dry_run` case. +Top-level keys are pinned to exactly these two by the test at `agent-os/crates/covenant/src/main.rs:8272` (`memory_compaction_json_pins_top_level_schema`), exercised against both a populated `apply` case and an empty `dry_run` case. -The envelope source-of-truth lives at `memory_compaction_json` in `agent-os/crates/covenant/src/main.rs:5396`. Two unit tests at `main.rs:7545` (`memory_compaction_json_renders_stable_shape`) and `main.rs:7573` cover the shape. The CLI verb is wired at `main.rs:2183-2291` (shared with `covenant memory plan-compaction`; the `plan-compaction` arm forces dry-run and emits a different envelope documented below). +The envelope source-of-truth lives at `memory_compaction_json` in `agent-os/crates/covenant/src/main.rs:6095`. Two unit tests at `main.rs:8244` (`memory_compaction_json_renders_stable_shape`) and `main.rs:8272` cover the shape. The CLI verb is wired at `main.rs:2213-2321` (shared with `covenant memory plan-compaction`; the `plan-compaction` arm forces dry-run and emits a different envelope documented below). `covenant memory plan-compaction --reason [--detach-stale-parents] [--delete-working-before-ms | --delete-working-older-than-ms ] [--delete-episodic-before-ms | --delete-episodic-older-than-ms ] [--mark-longterm-stale-before-ms | --mark-longterm-stale-older-than-ms ] [--marked-at-ms ] --json` emits a read-only compaction plan. The verb shares its argument parser with `covenant memory compact` but is forced into dry-run mode. Envelope shape: -- `kind`: literal string `"memory_compaction_plan"` — distinct from `memory_compacted` so consumers can route on the planning vs mutating outcome without inspecting `outcome.mode`. Pinned at the value level by `main.rs:7658` (asserts `value["kind"].as_str() == Some("memory_compaction_plan")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `outcome` (object): the same `MemoryCompactionOutcome` shape documented in the `memory_compacted` block above. For this verb, `outcome.mode` is **always** `"dry_run"` and `outcome.changed` is **always** `false`; a non-`dry_run` value here indicates daemon/CLI drift and JSON consumers should treat it as a protocol violation. Pinned as a structured object by `main.rs:7659-7662` — never a string blob. -- `expected_receipt_changes` (object): a forward-compatibility placeholder pinned by the schema test at `main.rs:7691` (`memory_compaction_plan_json_pins_expected_receipt_changes_schema`). The block has exactly three keys today and is currently a no-claim stub; consumers must validate the inner shape rather than dispatch directly to apply-mode logic. Pinned as a structured object by `main.rs:7663-7666` — never a string blob. +- `kind`: literal string `"memory_compaction_plan"` — distinct from `memory_compacted` so consumers can route on the planning vs mutating outcome without inspecting `outcome.mode`. Pinned at the value level by `main.rs:8357` (asserts `value["kind"].as_str() == Some("memory_compaction_plan")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `outcome` (object): the same `MemoryCompactionOutcome` shape documented in the `memory_compacted` block above. For this verb, `outcome.mode` is **always** `"dry_run"` and `outcome.changed` is **always** `false`; a non-`dry_run` value here indicates daemon/CLI drift and JSON consumers should treat it as a protocol violation. Pinned as a structured object by `main.rs:8358-8361` — never a string blob. +- `expected_receipt_changes` (object): a forward-compatibility placeholder pinned by the schema test at `main.rs:8390` (`memory_compaction_plan_json_pins_expected_receipt_changes_schema`). The block has exactly three keys today and is currently a no-claim stub; consumers must validate the inner shape rather than dispatch directly to apply-mode logic. Pinned as a structured object by `main.rs:8362-8365` — never a string blob. -**`--apply` is rejected** at the CLI level (`main.rs:2192-2194`: `bail!("memory plan-compaction is read-only and does not accept --apply")`) even though the underlying `Request::CompactMemory` request accepts both modes. `--reason ` remains mandatory, matching the `memory compact` verb. +**`--apply` is rejected** at the CLI level (`main.rs:2222-2224`: `bail!("memory plan-compaction is read-only and does not accept --apply")`) even though the underlying `Request::CompactMemory` request accepts both modes. `--reason ` remains mandatory, matching the `memory compact` verb. The inner `expected_receipt_changes` shape: -- `mode` (string): literal `"none"` today. Pinned by the schema test at `main.rs:7710-7714` as the only currently-allowed value; consumers must treat any other value as a sign that receipt-aware compaction has shipped and the docs are stale. Pinned as a string by `main.rs:7706-7709` — never a structured object. -- `records` (array): empty today (length pinned to `0` at `main.rs:7719-7725`). Will gain a real shape once receipt-aware compaction lands. Pinned as an array by `main.rs:7715-7718` — never null or a string. The renders-test sibling at `main.rs:7633-7638` independently pins the same empty-case `expected_receipt_changes.records` assertion (`as_array().map(Vec::len) == Some(0)`). -- `reason` (string): a human-readable explanation of why the block is empty. Currently the literal `"dry-run compaction planning does not mutate memory or settlement receipts"` per `main.rs:4573`; consumers must not branch on the exact text — only on the field's existence and type. Pinned as a string by `main.rs:7726-7729` — never a structured object. +- `mode` (string): literal `"none"` today. Pinned by the schema test at `main.rs:8409-8413` as the only currently-allowed value; consumers must treat any other value as a sign that receipt-aware compaction has shipped and the docs are stale. Pinned as a string by `main.rs:8405-8408` — never a structured object. +- `records` (array): empty today (length pinned to `0` at `main.rs:8418-8424`). Will gain a real shape once receipt-aware compaction lands. Pinned as an array by `main.rs:8414-8417` — never null or a string. The renders-test sibling at `main.rs:8332-8337` independently pins the same empty-case `expected_receipt_changes.records` assertion (`as_array().map(Vec::len) == Some(0)`). +- `reason` (string): a human-readable explanation of why the block is empty. Currently the literal `"dry-run compaction planning does not mutate memory or settlement receipts"` per `main.rs:4603`; consumers must not branch on the exact text — only on the field's existence and type. Pinned as a string by `main.rs:8425-8428` — never a structured object. -Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:7642` (`memory_compaction_plan_json_pins_top_level_schema`), exercised against both a populated dry-run case and an empty dry-run case. +Top-level keys are pinned to exactly these three by the test at `agent-os/crates/covenant/src/main.rs:8341` (`memory_compaction_plan_json_pins_top_level_schema`), exercised against both a populated dry-run case and an empty dry-run case. -The envelope source-of-truth lives at `memory_compaction_plan_json` in `agent-os/crates/covenant/src/main.rs:5403`. Three unit tests at `main.rs:7618` (`memory_compaction_plan_json_renders_stable_shape`), `main.rs:7642` (`memory_compaction_plan_json_pins_top_level_schema`), and `main.rs:7691` (`memory_compaction_plan_json_pins_expected_receipt_changes_schema`) cover both the outer envelope and the placeholder block. The CLI verb is wired at `main.rs:2183-2291` (shared parser with `covenant memory compact`, branched into the plan-only path at `main.rs:2184`); the `plan-compaction` arm sets `as_json` to `true` by default (`main.rs:2188`) so the unsuffixed CLI also emits the JSON envelope — there is no human-readable plan rendering. +The envelope source-of-truth lives at `memory_compaction_plan_json` in `agent-os/crates/covenant/src/main.rs:6102`. Three unit tests at `main.rs:8317` (`memory_compaction_plan_json_renders_stable_shape`), `main.rs:8341` (`memory_compaction_plan_json_pins_top_level_schema`), and `main.rs:8390` (`memory_compaction_plan_json_pins_expected_receipt_changes_schema`) cover both the outer envelope and the placeholder block. The CLI verb is wired at `main.rs:2213-2321` (shared parser with `covenant memory compact`, branched into the plan-only path at `main.rs:2214`); the `plan-compaction` arm sets `as_json` to `true` by default (`main.rs:2218`) so the unsuffixed CLI also emits the JSON envelope — there is no human-readable plan rendering. `covenant ignore check --json` emits the result of evaluating the configured ignore rules against operator-supplied text. Envelope shape: -- `kind`: literal string `"ignore_report"`. Pinned at the value level by `main.rs:7900` (asserts `value["kind"].as_str() == Some("ignore_report")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `ignored` (boolean): `true` when at least one loaded rule matched the supplied text; `false` otherwise. Pinned as a JSON boolean by the schema test (`main.rs:7901-7904`) — never `0`/`1` or a string-truthy value. -- `matched_pattern` (string or null): the matched rule pattern when `ignored` is `true`; **always `null`** when `ignored` is `false`. Pinned as string-or-null by the schema test (`main.rs:7905-7908`) — never an empty string for the unmatched case. JSON consumers must use `null` (not `""`) as the unmatched discriminator. -- `rules_loaded` (u64): count of ignore rules the daemon evaluated. May legitimately be `0` when no rules are configured, in which case `ignored` is always `false` and `matched_pattern` is always `null`. Pinned as u64 by `main.rs:7909-7912` — never a string-of-integer. +- `kind`: literal string `"ignore_report"`. Pinned at the value level by `main.rs:8599` (asserts `value["kind"].as_str() == Some("ignore_report")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `ignored` (boolean): `true` when at least one loaded rule matched the supplied text; `false` otherwise. Pinned as a JSON boolean by the schema test (`main.rs:8600-8603`) — never `0`/`1` or a string-truthy value. +- `matched_pattern` (string or null): the matched rule pattern when `ignored` is `true`; **always `null`** when `ignored` is `false`. Pinned as string-or-null by the schema test (`main.rs:8604-8607`) — never an empty string for the unmatched case. JSON consumers must use `null` (not `""`) as the unmatched discriminator. +- `rules_loaded` (u64): count of ignore rules the daemon evaluated. May legitimately be `0` when no rules are configured, in which case `ignored` is always `false` and `matched_pattern` is always `null`. Pinned as u64 by `main.rs:8608-8611` — never a string-of-integer. -**Exit-code coupling**: when `ignored` is `true`, the CLI exits `1` even in the `--json` path (per `main.rs:4709-4711`); the envelope is written to stdout *before* the exit. JSON consumers running this verb to gate downstream processing must read the envelope rather than relying solely on transport success — a `--json` invocation that exits `1` is the **expected** signal for a matched ignore rule, not an error. +**Exit-code coupling**: when `ignored` is `true`, the CLI exits `1` even in the `--json` path (per `main.rs:4739-4741`); the envelope is written to stdout *before* the exit. JSON consumers running this verb to gate downstream processing must read the envelope rather than relying solely on transport success — a `--json` invocation that exits `1` is the **expected** signal for a matched ignore rule, not an error. -Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:7884` (`ignore_report_json_pins_top_level_schema`), exercised against both an `ignored=true` case with a non-null `matched_pattern` and an `ignored=false` case with a null `matched_pattern` and zero `rules_loaded`. +Top-level keys are pinned to exactly these four by the test at `agent-os/crates/covenant/src/main.rs:8583` (`ignore_report_json_pins_top_level_schema`), exercised against both an `ignored=true` case with a non-null `matched_pattern` and an `ignored=false` case with a null `matched_pattern` and zero `rules_loaded`. -The envelope source-of-truth lives at `ignore_report_json` in `agent-os/crates/covenant/src/main.rs:5434`. Two unit tests at `main.rs:7872` (`ignore_report_json_renders_stable_shape`) and `main.rs:7884` cover the shape. The CLI verb is wired at `main.rs:4668-4716`; without `--json`, the matched case prints `ignored — matched rule: ` at `main.rs:4705` and the unmatched case prints `not ignored ( rule(s) loaded)` at `main.rs:4707`. Both paths share the exit-1-when-ignored convention. +The envelope source-of-truth lives at `ignore_report_json` in `agent-os/crates/covenant/src/main.rs:6133`. Two unit tests at `main.rs:8571` (`ignore_report_json_renders_stable_shape`) and `main.rs:8583` cover the shape. The CLI verb is wired at `main.rs:4698-4746`; without `--json`, the matched case prints `ignored — matched rule: ` at `main.rs:4735` and the unmatched case prints `not ignored ( rule(s) loaded)` at `main.rs:4737`. Both paths share the exit-1-when-ignored convention. `covenant bootstrap --json` emits a summary of the capability-bootstrap pass that grants every action required by manifests under `$COVENANT_HOME/agents/*/agent.toml` (plus the implicit `memory.write`, which the daemon writes on every successful dispatch). Envelope shape: -- `kind`: literal string `"bootstrap_result"`. Pinned at the value level by `main.rs:6689` (asserts `value["kind"].as_str() == Some("bootstrap_result")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. -- `granted` (array of `{action: string, signature_b58: string}` objects): the capabilities **newly granted** during this bootstrap call. Each element echoes the action string and the daemon-signed base58 signature that authorises it. Pinned as an array by `main.rs:6690-6693` — never null or a string. The asymmetric inner shape — `granted` entries are objects, not bare strings — is pinned by `main.rs:6707-6710` (asserts `populated["granted"][0].is_object()`). -- `already_granted` (array of strings): the action names the daemon **already had** before this call. Note the asymmetry with `granted`: this field carries **bare action strings**, not the `{action, signature_b58}` object shape — the existing signatures are not echoed here. JSON consumers must not iterate `already_granted` as if it were objects. Pinned as an array by `main.rs:6694-6697` — never null or a string. The asymmetric inner shape — `already_granted` entries are bare strings, not objects — is pinned by `main.rs:6711-6714` (asserts `populated["already_granted"][0].is_string()`). +- `kind`: literal string `"bootstrap_result"`. Pinned at the value level by `main.rs:7388` (asserts `value["kind"].as_str() == Some("bootstrap_result")`), so a future kind-rename fails the test rather than silently rewriting the discriminator string. +- `granted` (array of `{action: string, signature_b58: string}` objects): the capabilities **newly granted** during this bootstrap call. Each element echoes the action string and the daemon-signed base58 signature that authorises it. Pinned as an array by `main.rs:7389-7392` — never null or a string. The asymmetric inner shape — `granted` entries are objects, not bare strings — is pinned by `main.rs:7406-7409` (asserts `populated["granted"][0].is_object()`). +- `already_granted` (array of strings): the action names the daemon **already had** before this call. Note the asymmetry with `granted`: this field carries **bare action strings**, not the `{action, signature_b58}` object shape — the existing signatures are not echoed here. JSON consumers must not iterate `already_granted` as if it were objects. Pinned as an array by `main.rs:7393-7396` — never null or a string. The asymmetric inner shape — `already_granted` entries are bare strings, not objects — is pinned by `main.rs:7410-7413` (asserts `populated["already_granted"][0].is_string()`). -Top-level keys are pinned by the test at `agent-os/crates/covenant/src/main.rs:6673` (`bootstrap_result_json_pins_top_level_schema`), exercised against a populated case (two newly-granted entries plus one already-granted entry), an empty-granted case (no new grants, two already-granted entries), and a fully-empty case. The test also asserts the asymmetric inner shape: `granted` entries are `{action, signature_b58}` objects while `already_granted` entries are bare action strings. +Top-level keys are pinned by the test at `agent-os/crates/covenant/src/main.rs:7372` (`bootstrap_result_json_pins_top_level_schema`), exercised against a populated case (two newly-granted entries plus one already-granted entry), an empty-granted case (no new grants, two already-granted entries), and a fully-empty case. The test also asserts the asymmetric inner shape: `granted` entries are `{action, signature_b58}` objects while `already_granted` entries are bare action strings. -Re-running `covenant bootstrap` is idempotent: if every required action is already granted, `granted` is empty and `already_granted` carries the full set. An empty `granted` array is the **expected** signal for "nothing to do" — not a transport failure. The empty-granted case must serialize as a JSON array (`[]`), not as `null` or an absent key; this invariant is pinned by `main.rs:6719-6722` (asserts `no_new_grants["granted"].as_array().unwrap().is_empty()`). The unsuffixed CLI prints `nothing to do — every required capability is already granted ( total)` at `main.rs:1953-1956` for that case. +Re-running `covenant bootstrap` is idempotent: if every required action is already granted, `granted` is empty and `already_granted` carries the full set. An empty `granted` array is the **expected** signal for "nothing to do" — not a transport failure. The empty-granted case must serialize as a JSON array (`[]`), not as `null` or an absent key; this invariant is pinned by `main.rs:7418-7421` (asserts `no_new_grants["granted"].as_array().unwrap().is_empty()`). The unsuffixed CLI prints `nothing to do — every required capability is already granted ( total)` at `main.rs:1953-1956` for that case. -The envelope source-of-truth lives at `bootstrap_result_json` in `agent-os/crates/covenant/src/main.rs:5532`. Two unit tests at `main.rs:6650` (`bootstrap_result_json_renders_stable_shape`) and `main.rs:6673` cover the shape. The CLI verb is wired at `main.rs:1872-1975`; the JSON emission site calls the helper at `main.rs:1950-1951`. Required actions are derived from the union of every `agent.toml`'s `[capabilities].required` list (`main.rs:1888-1908`) plus the unconditional `memory.write` insertion (`main.rs:1890`). The daemon-side dispatch is `Request::GrantCapability` per action (`main.rs:1929-1936`); failures fall through to a `daemon error granting : ` bail rather than into the envelope. Without `--json`, the same response prints `granted of capabilities to user@local:` followed by one ` + ()` line per newly-granted entry and a final `ready. try: covenant intent "say hello"` (per `main.rs:1958-1973`). +The envelope source-of-truth lives at `bootstrap_result_json` in `agent-os/crates/covenant/src/main.rs:6231`. Two unit tests at `main.rs:7349` (`bootstrap_result_json_renders_stable_shape`) and `main.rs:7372` cover the shape. The CLI verb is wired at `main.rs:1872-1975`; the JSON emission site calls the helper at `main.rs:1950-1951`. Required actions are derived from the union of every `agent.toml`'s `[capabilities].required` list (`main.rs:1888-1908`) plus the unconditional `memory.write` insertion (`main.rs:1890`). The daemon-side dispatch is `Request::GrantCapability` per action (`main.rs:1929-1936`); failures fall through to a `daemon error granting : ` bail rather than into the envelope. Without `--json`, the same response prints `granted of capabilities to user@local:` followed by one ` + ()` line per newly-granted entry and a final `ready. try: covenant intent "say hello"` (per `main.rs:1958-1973`). `covenant intents resume --json` emits the outcome of resuming a previously-paused intent (typically one that hit a `BudgetExhausted` audit row). The envelope is **two-shape**: success and error share the same `kind` discriminator and use a flat `ok` boolean as the structural discriminator at the top level — **not** a tagged-enum `outcome.type` like `peer_revoke`. Consumers must branch on `ok` to know which key set to expect. Both branches share these fields: -- `kind`: literal string `"intents_resume"` — verb-name asymmetry: the CLI verb is `resume` but the envelope discriminator is `intents_resume` (the noun-verb compound, not the verb token alone); consumers routing on `kind` must match the literal exactly. The same literal is emitted on both `ok=true` and `ok=false` envelopes. Pinned at the value level by `main.rs:6359` (success branch) and `main.rs:6202` (error branch) — each asserts `value["kind"].as_str() == Some("intents_resume")` — so a future kind-rename fails the tests rather than silently rewriting the discriminator string. -- `ok` (boolean): `true` on success, `false` on every error path. Pinned as a JSON boolean by the schema tests at `main.rs:6360-6363` and `main.rs:6203-6206` — never `0`/`1` or a string-truthy value. JSON consumers branching on `ok` alone get the correct outcome class without inspecting variant-specific fields. The error branch's invariant `ok=false` is also pinned at the value level by `main.rs:6207-6211` (asserts `value["ok"].as_bool() == Some(false)`), so a regression that emitted `ok=true` from the error envelope would fail at test time. -- `mode` (string): exactly `"explicit"` or `"latest"`, derived from the CLI invocation form at `main.rs:4578` (`--latest`/`latest` → `"latest"`, any positional intent-id → `"explicit"`). The envelope echoes the operator's input form, so consumers can distinguish a targeted resume from a "resume the most recent paused intent" call. Pinned as a string by `main.rs:6364` (success branch) and `main.rs:6212` (error branch) — never an object or array. +- `kind`: literal string `"intents_resume"` — verb-name asymmetry: the CLI verb is `resume` but the envelope discriminator is `intents_resume` (the noun-verb compound, not the verb token alone); consumers routing on `kind` must match the literal exactly. The same literal is emitted on both `ok=true` and `ok=false` envelopes. Pinned at the value level by `main.rs:7058` (success branch) and `main.rs:6901` (error branch) — each asserts `value["kind"].as_str() == Some("intents_resume")` — so a future kind-rename fails the tests rather than silently rewriting the discriminator string. +- `ok` (boolean): `true` on success, `false` on every error path. Pinned as a JSON boolean by the schema tests at `main.rs:7059-7062` and `main.rs:6902-6905` — never `0`/`1` or a string-truthy value. JSON consumers branching on `ok` alone get the correct outcome class without inspecting variant-specific fields. The error branch's invariant `ok=false` is also pinned at the value level by `main.rs:6906-6910` (asserts `value["ok"].as_bool() == Some(false)`), so a regression that emitted `ok=true` from the error envelope would fail at test time. +- `mode` (string): exactly `"explicit"` or `"latest"`, derived from the CLI invocation form at `main.rs:4608` (`--latest`/`latest` → `"latest"`, any positional intent-id → `"explicit"`). The envelope echoes the operator's input form, so consumers can distinguish a targeted resume from a "resume the most recent paused intent" call. Pinned as a string by `main.rs:7063` (success branch) and `main.rs:6911` (error branch) — never an object or array. -**Success branch (`ok=true`)** carries these eight top-level keys per the test EXPECTED_KEYS at `main.rs:6335-6344`: +**Success branch (`ok=true`)** carries these eight top-level keys per the test EXPECTED_KEYS at `main.rs:7034-7043`: -- `intent_id` (string) — the resumed intent's UUID in canonical hyphenated form. Pinned as a string by `main.rs:6365-6368` — never a byte array. -- `status` (string) — the daemon-returned outcome status (typically `"ok"`). The string shape is pinned at `main.rs:6369-6372`; specific value enumeration lives with the daemon's intent dispatcher rather than this docs surface. -- `text` (string) — the result text the daemon returned for the resumed intent. The unsuffixed CLI prints this value directly at `main.rs:3950`. Pinned as a string by `main.rs:6373` — never an object or array. -- `sources` (array of strings) — source labels that contributed to the result. Pinned as an array of strings by `main.rs:6374-6377` — never a comma-joined string. Empty when no sources are attached; the unsuffixed CLI prints a `sources:` block followed by ` -