From 2a61f089aeb2bc5bffeee926abafd8449145c2ce Mon Sep 17 00:00:00 2001 From: Achille Wasque Date: Mon, 8 Jun 2026 11:15:12 +0200 Subject: [PATCH 01/22] add said-bridge crate with off-chain register and lookup scaffold covenant-said-bridge symmetrical to covenant-sap-bridge: - Cluster + PaidGates config layered from COVENANT_SAID_* env - REST client to api.saidprotocol.com - AgentCard register_off_chain and lookup - worker subprocess stub for the on-chain instructions (wired in P3) daemon: with_said_bridge builder, said_bridge_config_from_env, SaidStatus / SaidRegisterOffChain / SaidLookup IPC. cli: covenant said {status, register --off-chain, lookup}. paid-tx gates default off; bridge ships disabled by default and a disabled bridge is still attached so /said status is always answerable. --- agent-os/Cargo.lock | 23 ++ agent-os/Cargo.toml | 1 + agent-os/crates/covenant-ipc/src/lib.rs | 34 +++ .../crates/covenant-said-bridge/Cargo.toml | 22 ++ .../covenant-said-bridge/src/agent_card.rs | 97 +++++++ .../crates/covenant-said-bridge/src/client.rs | 39 +++ .../crates/covenant-said-bridge/src/config.rs | 269 ++++++++++++++++++ .../crates/covenant-said-bridge/src/lib.rs | 60 ++++ .../crates/covenant-said-bridge/src/rest.rs | 96 +++++++ .../crates/covenant-said-bridge/src/worker.rs | 138 +++++++++ agent-os/crates/covenant/src/main.rs | 232 +++++++++++++++ agent-os/crates/covenantd/Cargo.toml | 1 + agent-os/crates/covenantd/src/lib.rs | 104 +++++++ agent-os/crates/covenantd/src/main.rs | 16 +- 14 files changed, 1131 insertions(+), 1 deletion(-) create mode 100644 agent-os/crates/covenant-said-bridge/Cargo.toml create mode 100644 agent-os/crates/covenant-said-bridge/src/agent_card.rs create mode 100644 agent-os/crates/covenant-said-bridge/src/client.rs create mode 100644 agent-os/crates/covenant-said-bridge/src/config.rs create mode 100644 agent-os/crates/covenant-said-bridge/src/lib.rs create mode 100644 agent-os/crates/covenant-said-bridge/src/rest.rs create mode 100644 agent-os/crates/covenant-said-bridge/src/worker.rs diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index 887703c2a..55161a588 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1358,6 +1358,22 @@ dependencies = [ "wiremock", ] +[[package]] +name = "covenant-said-bridge" +version = "0.0.0" +dependencies = [ + "hex", + "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 +1543,7 @@ dependencies = [ "covenant-permissions", "covenant-router", "covenant-runtime", + "covenant-said-bridge", "covenant-sap-bridge", "covenant-settlement", "covenant-types", @@ -2616,6 +2633,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/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index e87fc451b..0bf2f8dad 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -762,6 +762,18 @@ pub enum Request { #[serde(default)] expires_at_unix: Option, }, + /// Resolved SAID bridge status. Read-only; no signer or RPC needed. + SaidStatus, + /// Register an agent off-chain via SAID's REST API. Free, no SOL. + /// The card travels as a JSON string to keep the IPC surface decoupled + /// from the said-bridge crate's types. + SaidRegisterOffChain { + card_json: String, + }, + /// Look up a SAID agent by wallet (identity + verification + reputation). + SaidLookup { + wallet: String, + }, } fn default_recent_limit() -> usize { @@ -1016,6 +1028,28 @@ 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, + }, + SaidRegistered { + wallet: String, + off_chain: bool, + }, + SaidAgent { + wallet: String, + name: Option, + is_verified: bool, + verification_tier: u8, + stake_amount: u64, + reputation_score: u16, + total_interactions: u64, + }, Error { message: String, }, 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..f883c2d27 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/Cargo.toml @@ -0,0 +1,22 @@ +[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 } +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..867995638 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/agent_card.rs @@ -0,0 +1,97 @@ +//! Off-chain AgentCard registration against SAID's REST API. +//! +//! Free, instant, no SOL. Use this for the MVP path: register every +//! Covenant agent off-chain at boot, then upgrade to on-chain via the +//! worker once a paid-tx gate is opened. + +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 AgentCard { + pub wallet: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OffChainRegistration { + pub wallet: String, + pub off_chain: bool, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLookup { + pub wallet: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub is_verified: bool, + #[serde(default)] + pub verification_tier: u8, + #[serde(default)] + pub stake_amount: u64, + #[serde(default)] + pub reputation_score: u16, + #[serde(default)] + pub total_interactions: u64, +} + +impl SaidBridge { + /// Register an AgentCard off-chain via REST. No SOL spent. Idempotent + /// by wallet on SAID's side. + pub async fn register_off_chain(&self, card: &AgentCard) -> Result { + self.require_enabled()?; + let client = rest::build_client(self.config().rest_timeout)?; + rest::post_json(&client, &self.config().api_base_url, "/api/agents", card).await + } + + /// Look up an agent by Solana wallet address. Returns identity + + /// verification + reputation summary in one fetch. + 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 agent_card_serializes_camel_case() { + let card = AgentCard { + wallet: "AdChc…".into(), + name: "Covenant".into(), + description: Some("Open agent coordination".into()), + metadata_uri: Some("https://opencovenant.org/agent.json".into()), + homepage: None, + capabilities: vec!["code.review".into(), "code.write".into()], + tags: vec![], + }; + let json = serde_json::to_value(&card).unwrap(); + assert_eq!(json["metadataUri"], "https://opencovenant.org/agent.json"); + assert_eq!(json["capabilities"][0], "code.review"); + assert!(json.get("homepage").is_none()); + assert!(json.get("tags").is_none()); + } +} 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..30ed8af11 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/config.rs @@ -0,0 +1,269 @@ +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()] +} + +/// Per-instruction paid-tx gates. Each one defaults off so the bridge +/// can ship and run REST-only without ever spending SOL. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct PaidGates { + pub register: bool, + pub verify: bool, + pub anchor: bool, + pub validate_work: bool, + pub sponsor: bool, +} + +impl PaidGates { + pub fn any(&self) -> bool { + self.register || self.verify || self.anchor || self.validate_work || self.sponsor + } + + 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 self.sponsor { on.push("sponsor"); } + 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")), + sponsor: parse_bool(get("COVENANT_SAID_ALLOW_PAID_SPONSOR")), + }; + + 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!(!cfg.paid.sponsor); + 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/lib.rs b/agent-os/crates/covenant-said-bridge/src/lib.rs new file mode 100644 index 000000000..029301d05 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/lib.rs @@ -0,0 +1,60 @@ +//! Bridge between Covenant and SAID Protocol (Solana Agent Identity Standard). +//! +//! SAID is the public agent-commons identity, reputation, and cross-chain +//! reach layer on Solana mainnet program `5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G`. +//! This crate is the local-side adapter: it registers a Covenant agent's +//! public identity, pushes Merkle-rooted audit slices into SAID's anchor +//! stream, emits `validate_work` records on completed FairScale-attested +//! jobs, and routes A2A messages over SAID's cross-chain hub. +//! +//! Plane separation (versus the existing Covenant settlement program at +//! `cov9UDyp…`): SAID is the public identity + reputation surface that +//! external platforms read across 10 chains. Covenant settlement is the +//! internal CVNT-economic credit-account + slash-vault. They share no +//! signer, no stake pool, and no slash authority. +//! +//! The bridge is strictly opt-in. Every paid on-chain operation is also +//! gated behind a per-instruction `COVENANT_SAID_ALLOW_PAID_*` flag so +//! an operator can fund anchor cadence without unlocking sponsorship. +//! +//! The daemon holds no JS runtime and no SAID SDK. Off-chain registration +//! and cross-chain messaging happen in-process over REST; the four paid +//! on-chain instructions (`register_agent`, `get_verified`, +//! `submit_anchor`, `validate_work`) are delegated to the TypeScript +//! bridge worker at `@covenant/said-bridge` over the same JSON envelope +//! contract used by `@covenant/sap-bridge`. + +#![deny(unsafe_code)] + +pub mod agent_card; +pub mod client; +pub mod config; +pub mod rest; +pub mod worker; + +pub use client::SaidBridge; +pub use config::{Cluster, Config, PaidGates, DEFAULT_SAID_MAINNET_PROGRAM_ID, DEFAULT_SAID_DEVNET_PROGRAM_ID, DEFAULT_SAID_API_BASE_URL}; + +#[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..f7ca15130 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/rest.rs @@ -0,0 +1,96 @@ +//! REST client for `api.saidprotocol.com`. +//! +//! Owns the free/off-chain surface (`POST /api/agents`, `GET /api/agents/:wallet`) +//! and the cross-chain pipes (`/xchain/inbox`, `/xchain/message`). On-chain +//! instructions go through the TS worker (`worker` module), not here. + +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..308e35d4d --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/worker.rs @@ -0,0 +1,138 @@ +//! Subprocess transport to the SAID bridge worker (`@covenant/said-bridge`). +//! +//! Mirrors the JSON envelope contract used by `covenant-sap-bridge`: +//! +//! ```json +//! { "ok": true, "data": } +//! { "ok": false, "error": "", "name": "" } +//! ``` +//! +//! The worker resolves its own cluster, RPC, program id, and signer from +//! the inherited environment. The SAID owner keypair lives at +//! `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/src/main.rs b/agent-os/crates/covenant/src/main.rs index e34ca99ec..53c4b35dd 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2168,6 +2168,15 @@ 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 status (cluster, program, api, paid gates)" + ); + eprintln!( + " covenant said register --off-chain --wallet --name [--description --metadata-uri --homepage --capability* --tag*] [--json] register an AgentCard on SAID's public REST registry" + ); + eprintln!( + " covenant said lookup --wallet [--json] fetch an agent's SAID identity + verification + reputation" + ); } struct MemoryReadJsonArgs { @@ -5045,6 +5054,229 @@ 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 off_chain = false; + let mut on_chain = false; + let mut wallet: Option = None; + let mut name: Option = None; + let mut description: Option = None; + let mut metadata_uri: Option = None; + let mut homepage: Option = None; + let mut capabilities: Vec = Vec::new(); + let mut tags: Vec = Vec::new(); + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--off-chain" => off_chain = true, + "--on-chain" => on_chain = true, + "--wallet" => { + i += 1; + wallet = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--wallet needs an address") + })?); + } + "--name" => { + i += 1; + name = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--name needs a value") + })?); + } + "--description" => { + i += 1; + description = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--description needs a value") + })?); + } + "--metadata-uri" => { + i += 1; + metadata_uri = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--metadata-uri needs a URL") + })?); + } + "--homepage" => { + i += 1; + homepage = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--homepage needs a URL") + })?); + } + "--capability" => { + i += 1; + capabilities.push(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--capability needs a value") + })?); + } + "--tag" => { + i += 1; + tags.push(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--tag needs a value") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + if off_chain == on_chain { + bail!("covenant said register requires exactly one of --off-chain or --on-chain"); + } + if on_chain { + bail!("covenant said register --on-chain is not wired yet (P3)"); + } + let wallet = wallet.ok_or_else(|| { + anyhow::anyhow!("covenant said register requires --wallet ") + })?; + let name = name.ok_or_else(|| { + anyhow::anyhow!("covenant said register requires --name ") + })?; + let card = serde_json::json!({ + "wallet": wallet, + "name": name, + "description": description, + "metadataUri": metadata_uri, + "homepage": homepage, + "capabilities": capabilities, + "tags": tags, + }); + let card_json = serde_json::to_string(&card)?; + write_frame( + &mut stream, + &Request::SaidRegisterOffChain { card_json }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidRegistered { wallet, off_chain } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_registered", + "wallet": wallet, + "off_chain": off_chain, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("wallet: {wallet}"); + println!("off_chain: {off_chain}"); + } + } + 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, + name, + is_verified, + verification_tier, + stake_amount, + reputation_score, + total_interactions, + } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_agent", + "wallet": wallet, + "name": name, + "is_verified": is_verified, + "verification_tier": verification_tier, + "stake_amount": stake_amount, + "reputation_score": reputation_score, + "total_interactions": total_interactions, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("wallet: {wallet}"); + println!("name: {}", name.unwrap_or_default()); + println!("is_verified: {is_verified}"); + println!("verification_tier: {verification_tier}"); + println!("stake_amount: {stake_amount}"); + println!("reputation_score: {reputation_score}"); + println!("total_interactions: {total_interactions}"); + } + } + 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..274ad24bd 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,13 @@ pub fn sap_bridge_config_from_env() -> SapBridgeConfig { SapBridgeConfig::from_env(std::env::vars()) } +/// Resolve the SAID Protocol bridge config from `COVENANT_SAID_*` env. +/// Default `enabled: false`. Each paid on-chain instruction is gated by +/// its own `COVENANT_SAID_ALLOW_PAID_*` flag. +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 +1075,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 +1121,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 +1227,95 @@ impl Server { self.sap_bridge.as_ref() } + /// Attach the SAID Protocol bridge. Symmetric to `with_sap_bridge`: + /// daemon `main` calls this once at boot with the bridge resolved + /// from `said_bridge_config_from_env`. A disabled-config bridge is + /// still attached; the `enabled` flag governs behavior. + 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, + }, + } + } + + pub(crate) async fn said_register_off_chain(&self, card_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 card: covenant_said_bridge::agent_card::AgentCard = + match serde_json::from_str(&card_json) { + Ok(c) => c, + Err(e) => { + return Response::Error { + message: format!("invalid agent card JSON: {e}"), + } + } + }; + match bridge.register_off_chain(&card).await { + Ok(reg) => Response::SaidRegistered { + wallet: reg.wallet, + off_chain: reg.off_chain, + }, + Err(e) => Response::Error { + message: format!("said register_off_chain: {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, + name: a.name, + is_verified: a.is_verified, + verification_tier: a.verification_tier, + stake_amount: a.stake_amount, + reputation_score: a.reputation_score, + total_interactions: a.total_interactions, + }, + 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 +2190,11 @@ impl Server { ) .await } + Request::SaidStatus => self.said_status(), + Request::SaidRegisterOffChain { card_json } => { + self.said_register_off_chain(card_json).await + } + Request::SaidLookup { wallet } => self.said_lookup(wallet).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) => { From d1c7036c1d599d65a8b8cd8e98c2356c54f7032b Mon Sep 17 00:00:00 2001 From: Achille Wasque Date: Mon, 8 Jun 2026 11:21:37 +0200 Subject: [PATCH 02/22] said: anchor pipeline with sqlite cursor and fixture mode - cursor.rs: sqlite-backed AnchorCursor, parking_lot::Mutex wraps the Connection so the cursor is Sync and bridge methods that hold a borrow across an await keep their futures Send for Server handlers. - anchor.rs: bridge.anchor() in Fixture and Live modes. Fixture writes to anchor_pending.jsonl and never spends SOL; Live requires the COVENANT_SAID_ALLOW_PAID_ANCHOR gate and drives the worker. - ipc: SaidAnchor / SaidAnchorStatus requests + responses, SaidAnchorRow row. - daemon: said_anchor + said_anchor_status open the cursor at $COVENANT_HOME/said/cursor.db and the fixture file alongside it. - cli: covenant said anchor --start --end --root [--live] [--json]; covenant said anchor-status [--recent N] [--json]. paid gates stay off by default. fixture mode is the safe default for landing the pipeline; live anchor is one config flag away once the operator wallet is funded. --- agent-os/Cargo.lock | 1 + agent-os/crates/covenant-ipc/src/lib.rs | 43 +++ .../crates/covenant-said-bridge/Cargo.toml | 1 + .../crates/covenant-said-bridge/src/anchor.rs | 298 ++++++++++++++++++ .../crates/covenant-said-bridge/src/cursor.rs | 247 +++++++++++++++ .../crates/covenant-said-bridge/src/lib.rs | 2 + agent-os/crates/covenant/src/main.rs | 165 ++++++++++ agent-os/crates/covenantd/src/lib.rs | 133 ++++++++ 8 files changed, 890 insertions(+) create mode 100644 agent-os/crates/covenant-said-bridge/src/anchor.rs create mode 100644 agent-os/crates/covenant-said-bridge/src/cursor.rs diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index 55161a588..35663cae3 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1363,6 +1363,7 @@ name = "covenant-said-bridge" version = "0.0.0" dependencies = [ "hex", + "parking_lot", "reqwest 0.12.28", "rusqlite", "serde", diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index 0bf2f8dad..99c30e0ed 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -774,6 +774,23 @@ pub enum Request { SaidLookup { wallet: String, }, + /// Anchor a Merkle-rooted audit slice into SAID. In `live = false` + /// (default) mode the payload is written to `said/anchor_pending.jsonl` + /// under `$COVENANT_HOME` and no SOL is spent. In live mode the + /// `COVENANT_SAID_ALLOW_PAID_ANCHOR` gate must be open and the worker + /// must have a signer. + SaidAnchor { + start_audit_index: u64, + end_audit_index: u64, + merkle_root_hex: String, + #[serde(default)] + live: bool, + }, + /// Cursor snapshot: next anchor index + last confirmed index + recent rows. + SaidAnchorStatus { + #[serde(default = "default_recent_limit")] + recent_limit: usize, + }, } fn default_recent_limit() -> usize { @@ -1050,11 +1067,37 @@ pub enum Response { reputation_score: u16, total_interactions: u64, }, + 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, + }, Error { message: String, }, } +#[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 index f883c2d27..65b54ebf6 100644 --- a/agent-os/crates/covenant-said-bridge/Cargo.toml +++ b/agent-os/crates/covenant-said-bridge/Cargo.toml @@ -15,6 +15,7 @@ tracing = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } rusqlite = { workspace = true } +parking_lot = { workspace = true } hex = "0.4" url = "2" 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..079d7863a --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/anchor.rs @@ -0,0 +1,298 @@ +//! Anchor pipeline. +//! +//! Submits sequential Merkle-rooted audit slices to SAID's `submit_anchor`. +//! Fixture mode writes the payload to a JSONL file and never spends SOL — +//! the default for any environment that hasn't opened +//! `COVENANT_SAID_ALLOW_PAID_ANCHOR=1`. +//! +//! The submitted payload mirrors SAID's instruction: +//! submit_anchor(anchor_index, start_seq, end_seq, merkle_root) +//! where `(start_seq, end_seq)` are derived from `AuditChainEntry.index` +//! (a sequential, hash-chained position) rather than 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 { + /// Anchor a Merkle-rooted audit slice. In `Fixture` mode the call is + /// free; in `Live` mode the bridge must be enabled AND the anchor + /// paid-tx gate must be on, otherwise [`BridgeError::PaidGateClosed`]. + 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() + } + + #[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 live_mode_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" })); + } + + #[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(_)))); + } +} 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..ac2716343 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/cursor.rs @@ -0,0 +1,247 @@ +//! SQLite-backed anchor cursor. +//! +//! SAID's `submit_anchor` rejects any `anchor_index` that is not exactly +//! `AgentIdentity.last_anchor_index + 1`. The cursor durably tracks +//! every claimed slot so a daemon crash mid-submit doesn't lose the +//! position, and so two daemons can't claim the same slot under +//! ordinary single-host operation. + +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/lib.rs b/agent-os/crates/covenant-said-bridge/src/lib.rs index 029301d05..1a07a1b53 100644 --- a/agent-os/crates/covenant-said-bridge/src/lib.rs +++ b/agent-os/crates/covenant-said-bridge/src/lib.rs @@ -27,8 +27,10 @@ #![deny(unsafe_code)] pub mod agent_card; +pub mod anchor; pub mod client; pub mod config; +pub mod cursor; pub mod rest; pub mod worker; diff --git a/agent-os/crates/covenant/src/main.rs b/agent-os/crates/covenant/src/main.rs index 53c4b35dd..2e3b9e503 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2177,6 +2177,12 @@ fn print_usage() { eprintln!( " covenant said lookup --wallet [--json] fetch an agent's SAID identity + verification + reputation" ); + eprintln!( + " covenant said anchor --start --end --root [--live] [--json] anchor an audit slice (fixture by default; --live needs the paid gate)" + ); + eprintln!( + " covenant said anchor-status [--recent ] [--json] cursor snapshot: next index, last confirmed, recent rows" + ); } struct MemoryReadJsonArgs { @@ -5270,6 +5276,165 @@ async fn main() -> Result<()> { 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:?}"), + } + } + "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(); diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index 274ad24bd..e4d913310 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -1294,6 +1294,129 @@ impl Server { } } + 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_lookup(&self, wallet: String) -> Response { let Some(bridge) = self.said_bridge.as_ref() else { return Response::Error { @@ -2195,6 +2318,16 @@ impl Server { self.said_register_off_chain(card_json).await } 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::FlushReceipts { limit } => self.flush_receipts(limit, peer).await, Request::ReceiptBatches { limit } => self.receipt_batches(limit, peer).await, Request::PayX402 { From d19b811650cae4a3d209d7a461ff3bb96686fbf5 Mon Sep 17 00:00:00 2001 From: Achille Wasque Date: Mon, 8 Jun 2026 11:23:44 +0200 Subject: [PATCH 03/22] said: xchain inbox poll + free-tier check + outbound send - xchain.rs: REST against /xchain/inbox/:chain/:address, /xchain/free-tier/:address, and POST /xchain/message. - ipc: SaidInbox / SaidFreeTier / SaidSend requests + responses, SaidInboxMessage row. Payload travels as a JSON string. - daemon: said_inbox, said_free_tier, said_send handlers. 402 responses from SAID surface as upstream errors (paid x402 path follows in P8 via covenant-x402-signer). - cli: covenant said inbox / free-tier / send. free tier covers 10/day per agent at zero cost. Beyond that the x402 challenge bubbles up; the signer hookup keeps Hyre's own x402 path independent. --- agent-os/crates/covenant-ipc/src/lib.rs | 47 ++++ .../crates/covenant-said-bridge/src/lib.rs | 1 + .../crates/covenant-said-bridge/src/xchain.rs | 111 +++++++++ agent-os/crates/covenant/src/main.rs | 225 ++++++++++++++++++ agent-os/crates/covenantd/src/lib.rs | 107 +++++++++ 5 files changed, 491 insertions(+) create mode 100644 agent-os/crates/covenant-said-bridge/src/xchain.rs diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index 99c30e0ed..4056682b7 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -791,6 +791,26 @@ pub enum Request { #[serde(default = "default_recent_limit")] recent_limit: usize, }, + /// Poll the SAID xchain inbox for pending messages addressed to this + /// agent's wallet on the given chain. + SaidInbox { + chain: String, + address: String, + }, + /// Read the free-tier message quota for the given address. + SaidFreeTier { + address: String, + }, + /// Send a cross-chain message through SAID's `/xchain/message`. Free + /// tier covers 10/day; beyond that SAID returns 402 and the daemon + /// surfaces the upstream error verbatim (paid path is x402 follow-up). + SaidSend { + source_chain: String, + source_address: String, + target_chain: String, + target_address: String, + payload_json: String, + }, } fn default_recent_limit() -> usize { @@ -1082,11 +1102,38 @@ pub enum Response { pending: u64, recent: Vec, }, + SaidInbox { + chain: String, + address: String, + messages: Vec, + }, + SaidFreeTier { + address: String, + chain: String, + remaining: u32, + resets_at: Option, + }, + SaidSent { + message_id: String, + free_tier_remaining: Option, + delivered_at: Option, + }, 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, diff --git a/agent-os/crates/covenant-said-bridge/src/lib.rs b/agent-os/crates/covenant-said-bridge/src/lib.rs index 1a07a1b53..1867fffc6 100644 --- a/agent-os/crates/covenant-said-bridge/src/lib.rs +++ b/agent-os/crates/covenant-said-bridge/src/lib.rs @@ -33,6 +33,7 @@ pub mod config; pub mod cursor; pub mod rest; pub mod worker; +pub mod xchain; pub use client::SaidBridge; pub use config::{Cluster, Config, PaidGates, DEFAULT_SAID_MAINNET_PROGRAM_ID, DEFAULT_SAID_DEVNET_PROGRAM_ID, DEFAULT_SAID_API_BASE_URL}; 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..d2ae41887 --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/xchain.rs @@ -0,0 +1,111 @@ +//! Cross-chain inbox + outbound messaging via SAID's `/xchain` hub. +//! +//! The free tier covers 10 messages per agent per day at no cost. Beyond +//! that SAID returns 402 with an x402 challenge that the caller settles +//! via `covenant-x402-signer`. This module covers the unpaid surface; +//! the paid send is plumbed in P8. + +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, + pub chain: String, + pub remaining: u32, + #[serde(default)] + pub resets_at: Option, +} + +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 + } + + /// Send a free-tier message. If SAID returns 402 (free tier exhausted) + /// the caller is responsible for settling via the x402 path; we surface + /// the 402 verbatim so the outer signer can pick it up. + 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/src/main.rs b/agent-os/crates/covenant/src/main.rs index 2e3b9e503..b23d96760 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2183,6 +2183,15 @@ fn print_usage() { 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 on SAID" + ); + 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" + ); } struct MemoryReadJsonArgs { @@ -5368,6 +5377,222 @@ async fn main() -> Result<()> { 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, chain, remaining, resets_at } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_free_tier", + "address": address, + "chain": chain, + "remaining": remaining, + "resets_at": resets_at, + }); + println!("{}", serde_json::to_string(&value)?); + } else { + println!("address: {address}"); + println!("chain: {chain}"); + println!("remaining: {remaining}"); + println!( + "resets_at: {}", + resets_at.map(|v| v.to_string()).unwrap_or_else(|| "-".into()) + ); + } + } + 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; diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index e4d913310..3a3a2d6e1 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -1417,6 +1417,95 @@ impl Server { } } + 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, + chain: ft.chain, + remaining: ft.remaining, + resets_at: ft.resets_at, + }, + 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_lookup(&self, wallet: String) -> Response { let Some(bridge) = self.said_bridge.as_ref() else { return Response::Error { @@ -2328,6 +2417,24 @@ impl Server { .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::FlushReceipts { limit } => self.flush_receipts(limit, peer).await, Request::ReceiptBatches { limit } => self.receipt_batches(limit, peer).await, Request::PayX402 { From 18bb2e444fd377033ce49b3c989692c9cfcb13db Mon Sep 17 00:00:00 2001 From: Achille Wasque Date: Mon, 8 Jun 2026 11:26:26 +0200 Subject: [PATCH 04/22] said: register-on-chain, verify, validate-work, sponsor instructions - instructions.rs: register_on_chain, get_verified, validate_work, sponsor_register, sponsor_verify. Each gated by its own paid-tx flag (REGISTER / VERIFY / VALIDATE / SPONSOR). Hex32 validator rejects bad task_hash before the worker round-trip. - ipc: SaidRegisterOnChain / SaidGetVerified / SaidValidateWork / SaidSponsorRegister / SaidSponsorVerify; new responses SaidOnChainRegistered, SaidVerified, SaidValidationPosted. - daemon: paired handlers (six total). - cli: covenant said register --on-chain, verify, validate-work, sponsor-register, sponsor-verify. covenant said register --on-chain routes through SaidRegisterOnChain instead of the off-chain REST. every paid path stays gated off by default. The TS worker that backs these instructions ships in the next commit. --- agent-os/crates/covenant-ipc/src/lib.rs | 36 +++ .../covenant-said-bridge/src/instructions.rs | 185 +++++++++++++ .../crates/covenant-said-bridge/src/lib.rs | 1 + agent-os/crates/covenant/src/main.rs | 260 +++++++++++++++++- agent-os/crates/covenantd/src/lib.rs | 130 +++++++++ 5 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 agent-os/crates/covenant-said-bridge/src/instructions.rs diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index 4056682b7..8f41db74c 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -811,6 +811,28 @@ pub enum Request { target_address: String, payload_json: String, }, + /// Register the agent on SAID's program. Behind COVENANT_SAID_ALLOW_PAID_REGISTER. + SaidRegisterOnChain { + metadata_uri: String, + }, + /// Pay 0.01 SOL for the verification badge. Behind COVENANT_SAID_ALLOW_PAID_VERIFY. + SaidGetVerified, + /// Post a work-validation record. Behind COVENANT_SAID_ALLOW_PAID_VALIDATE. + SaidValidateWork { + agent: String, + task_hash_hex: String, + passed: bool, + evidence_uri: String, + }, + /// Sponsor an external owner's `register_agent`. Behind COVENANT_SAID_ALLOW_PAID_SPONSOR. + SaidSponsorRegister { + sponsored_owner: String, + metadata_uri: String, + }, + /// Sponsor an external owner's `get_verified`. Behind COVENANT_SAID_ALLOW_PAID_SPONSOR. + SaidSponsorVerify { + sponsored_owner: String, + }, } fn default_recent_limit() -> usize { @@ -1118,6 +1140,20 @@ pub enum Response { 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, }, 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..e82ba389a --- /dev/null +++ b/agent-os/crates/covenant-said-bridge/src/instructions.rs @@ -0,0 +1,185 @@ +//! Worker-driven SAID instructions. +//! +//! Every method here invokes `covenant-said-worker` over the JSON +//! envelope contract in [`crate::worker`]. The worker owns the SDK and +//! the funding key; this module owns the gating and the input +//! validation. Each instruction is behind its own paid-tx gate so an +//! operator can fund anchor cadence without unlocking sponsorship. + +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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SponsorRegisterInput { + pub sponsored_owner: String, + pub metadata_uri: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SponsorVerifyInput { + pub sponsored_owner: 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 + } + + pub async fn sponsor_register( + &self, + input: &SponsorRegisterInput, + ) -> Result { + self.require_paid("sponsor_register", self.config().paid.sponsor)?; + if input.metadata_uri.trim().is_empty() { + return Err(BridgeError::Invalid("metadata_uri must not be empty".into())); + } + if input.sponsored_owner.trim().is_empty() { + return Err(BridgeError::Invalid("sponsored_owner must not be empty".into())); + } + worker::invoke(self.config(), "sponsor-register", input).await + } + + pub async fn sponsor_verify(&self, input: &SponsorVerifyInput) -> Result { + self.require_paid("sponsor_verify", self.config().paid.sponsor)?; + if input.sponsored_owner.trim().is_empty() { + return Err(BridgeError::Invalid("sponsored_owner must not be empty".into())); + } + worker::invoke(self.config(), "sponsor-verify", 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 validate_work_rejects_bad_hex() { + let mut paid = crate::config::PaidGates::default(); + paid.validate_work = true; + 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 sponsor_register_rejects_empty_owner() { + let mut paid = crate::config::PaidGates::default(); + paid.sponsor = true; + let b = bridge_with(paid); + let err = b + .sponsor_register(&SponsorRegisterInput { + sponsored_owner: "".into(), + metadata_uri: "https://example.test".into(), + }) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::Invalid(m) if m.contains("sponsored_owner"))); + } +} diff --git a/agent-os/crates/covenant-said-bridge/src/lib.rs b/agent-os/crates/covenant-said-bridge/src/lib.rs index 1867fffc6..ae86572f0 100644 --- a/agent-os/crates/covenant-said-bridge/src/lib.rs +++ b/agent-os/crates/covenant-said-bridge/src/lib.rs @@ -31,6 +31,7 @@ pub mod anchor; pub mod client; pub mod config; pub mod cursor; +pub mod instructions; pub mod rest; pub mod worker; pub mod xchain; diff --git a/agent-os/crates/covenant/src/main.rs b/agent-os/crates/covenant/src/main.rs index b23d96760..552554850 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2192,6 +2192,21 @@ fn print_usage() { eprintln!( " covenant said send --source-address --target-chain --target-address --payload [--json] send an xchain message" ); + eprintln!( + " covenant said register --on-chain --metadata-uri [--json] call SAID register_agent (paid gate REGISTER must be open)" + ); + 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)" + ); + eprintln!( + " covenant said sponsor-register --owner --metadata-uri [--json] sponsor an external agent's SAID register (paid gate SPONSOR)" + ); + eprintln!( + " covenant said sponsor-verify --owner [--json] sponsor an external agent's SAID verify (paid gate SPONSOR)" + ); } struct MemoryReadJsonArgs { @@ -5187,7 +5202,38 @@ async fn main() -> Result<()> { bail!("covenant said register requires exactly one of --off-chain or --on-chain"); } if on_chain { - bail!("covenant said register --on-chain is not wired yet (P3)"); + let metadata_uri = metadata_uri.ok_or_else(|| { + anyhow::anyhow!("covenant said register --on-chain 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_on_chain_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:?}"), + } + return Ok(()); } let wallet = wallet.ok_or_else(|| { anyhow::anyhow!("covenant said register requires --wallet ") @@ -5228,6 +5274,218 @@ async fn main() -> Result<()> { 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:?}"), + } + } + "sponsor-register" => { + let mut sponsored_owner: Option = None; + let mut metadata_uri: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--owner" => { + i += 1; + sponsored_owner = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--owner needs a wallet") + })?); + } + "--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 sponsored_owner = sponsored_owner.ok_or_else(|| { + anyhow::anyhow!("covenant said sponsor-register requires --owner") + })?; + let metadata_uri = metadata_uri.ok_or_else(|| { + anyhow::anyhow!("covenant said sponsor-register requires --metadata-uri") + })?; + write_frame( + &mut stream, + &Request::SaidSponsorRegister { + sponsored_owner, + 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_sponsored_register", + "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:?}"), + } + } + "sponsor-verify" => { + let mut sponsored_owner: Option = None; + let mut as_json = false; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--owner" => { + i += 1; + sponsored_owner = Some(args.get(i).cloned().ok_or_else(|| { + anyhow::anyhow!("--owner needs a wallet") + })?); + } + "--json" => as_json = true, + other => bail!("unknown flag '{other}'"), + } + i += 1; + } + let sponsored_owner = sponsored_owner.ok_or_else(|| { + anyhow::anyhow!("covenant said sponsor-verify requires --owner") + })?; + write_frame( + &mut stream, + &Request::SaidSponsorVerify { sponsored_owner }, + ) + .await?; + match read_frame::<_, Response>(&mut stream).await? { + Response::SaidVerified { signature, slot } => { + if as_json { + let value = serde_json::json!({ + "kind": "said_sponsored_verify", + "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:?}"), + } + } "lookup" => { let mut wallet: Option = None; let mut as_json = false; diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index 3a3a2d6e1..bb308d43b 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -1506,6 +1506,116 @@ impl Server { } } + 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 on-chain: {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_sponsor_register( + &self, + sponsored_owner: String, + 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::SponsorRegisterInput { + sponsored_owner, + metadata_uri, + }; + match bridge.sponsor_register(&input).await { + Ok(r) => Response::SaidOnChainRegistered { + agent_pda: r.agent_pda, + owner: r.owner, + signature: r.signature, + }, + Err(e) => Response::Error { + message: format!("said sponsor_register: {e}"), + }, + } + } + + pub(crate) async fn said_sponsor_verify(&self, sponsored_owner: 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::SponsorVerifyInput { sponsored_owner }; + match bridge.sponsor_verify(&input).await { + Ok(r) => Response::SaidVerified { + signature: r.signature, + slot: r.slot, + }, + Err(e) => Response::Error { + message: format!("said sponsor_verify: {e}"), + }, + } + } + pub(crate) async fn said_lookup(&self, wallet: String) -> Response { let Some(bridge) = self.said_bridge.as_ref() else { return Response::Error { @@ -2435,6 +2545,26 @@ impl Server { ) .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::SaidSponsorRegister { + sponsored_owner, + metadata_uri, + } => self.said_sponsor_register(sponsored_owner, metadata_uri).await, + Request::SaidSponsorVerify { sponsored_owner } => { + self.said_sponsor_verify(sponsored_owner).await + } Request::FlushReceipts { limit } => self.flush_receipts(limit, peer).await, Request::ReceiptBatches { limit } => self.receipt_batches(limit, peer).await, Request::PayX402 { From 5ad2943cc7d2d7f4bedb4c60f4594aefbb080552 Mon Sep 17 00:00:00 2001 From: Iko Rane Date: Mon, 8 Jun 2026 11:29:05 +0200 Subject: [PATCH 05/22] said-bridge: TS worker package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @covenant/said-bridge ships covenant-said-worker — the subprocess the Rust bridge crate spawns for every on-chain SAID instruction. The daemon stays JS-runtime-free and SDK-free; the worker owns @solana/web3.js and said-sdk as peer deps and loads them lazily. - config.ts: resolveSaidConfig mirrors the Rust env layering (COVENANT_SAID_*, cluster-specific override beats global). - index.ts: SaidBridge wraps said-sdk SAIDAgent. Paid gates enforced in TS too so a misconfigured worker can't spend SOL bypassing the Rust guard. - worker.ts: JSON envelope contract identical to @covenant/sap-bridge: argv[2] = command, payload on stdin, single {ok,data}|{ok:false,error,name} line on stdout. Commands: status, register-agent, get-verified, submit-anchor, validate-work, sponsor-register, sponsor-verify. - keypair.ts: loads COVENANT_SAID_KEYPAIR (Solana CLI 64-byte JSON). smoke: `node dist/worker.js status` returns the resolved config including hasSigner. paid: every gate defaults off. --- packages/said-bridge/package.json | 58 ++++++++ packages/said-bridge/src/config.ts | 92 +++++++++++++ packages/said-bridge/src/index.ts | 205 ++++++++++++++++++++++++++++ packages/said-bridge/src/keypair.ts | 22 +++ packages/said-bridge/src/worker.ts | 106 ++++++++++++++ packages/said-bridge/tsconfig.json | 14 ++ pnpm-lock.yaml | 116 ++++++++++------ pnpm-workspace.yaml | 1 + 8 files changed, 573 insertions(+), 41 deletions(-) create mode 100644 packages/said-bridge/package.json create mode 100644 packages/said-bridge/src/config.ts create mode 100644 packages/said-bridge/src/index.ts create mode 100644 packages/said-bridge/src/keypair.ts create mode 100644 packages/said-bridge/src/worker.ts create mode 100644 packages/said-bridge/tsconfig.json diff --git a/packages/said-bridge/package.json b/packages/said-bridge/package.json new file mode 100644 index 000000000..7a613923b --- /dev/null +++ b/packages/said-bridge/package.json @@ -0,0 +1,58 @@ +{ + "name": "@covenant/said-bridge", + "version": "0.0.0", + "description": "Opt-in bridge between Covenant and SAID Protocol (Solana Agent Identity Standard)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/open-covenant/covenant" + }, + "homepage": "https://opencovenant.org", + "keywords": [ + "solana", + "agent", + "identity", + "covenant", + "said" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "covenant-said-worker": "./dist/worker.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "default": "./dist/worker.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --passWithNoTests", + "lint": "echo skip", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "@solana/web3.js": ">=1.90.0", + "said-sdk": ">=0.3.0" + }, + "peerDependenciesMeta": { + "@solana/web3.js": { "optional": true }, + "said-sdk": { "optional": true } + }, + "devDependencies": { + "@solana/web3.js": "^1.98.4", + "typescript": "6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/said-bridge/src/config.ts b/packages/said-bridge/src/config.ts new file mode 100644 index 000000000..589db6d25 --- /dev/null +++ b/packages/said-bridge/src/config.ts @@ -0,0 +1,92 @@ +// Resolves the SAID bridge config from environment variables. Mirrors +// `Config::from_env` in the Rust `covenant-said-bridge` crate — the +// daemon and worker must read the same variables to stay consistent. + +export type Cluster = 'devnet' | 'localnet' | 'mainnet'; + +export interface SaidConfig { + enabled: boolean; + cluster: Cluster; + programId: string; + rpcUrl: string; + apiBaseUrl: string; + paid: { + register: boolean; + verify: boolean; + anchor: boolean; + validateWork: boolean; + sponsor: boolean; + }; +} + +const DEFAULT_MAINNET_PROGRAM_ID = '5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G'; +const DEFAULT_DEVNET_PROGRAM_ID = 'ESPreFucjVwtDmZbhtL3JLJ9VxCethNEYtosMQhkcurv'; +const DEFAULT_API_BASE_URL = 'https://api.saidprotocol.com'; + +function parseBool(value: string | undefined): boolean { + if (!value) return false; + const v = value.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes'; +} + +function parseCluster(value: string | undefined): Cluster { + switch ((value ?? '').toLowerCase()) { + case 'mainnet': + case 'mainnet-beta': + return 'mainnet'; + case 'localnet': + return 'localnet'; + case 'devnet': + default: + return 'devnet'; + } +} + +function defaultProgramId(cluster: Cluster): string { + return cluster === 'mainnet' ? DEFAULT_MAINNET_PROGRAM_ID : DEFAULT_DEVNET_PROGRAM_ID; +} + +function defaultRpcUrl(cluster: Cluster): string { + switch (cluster) { + case 'mainnet': + return 'https://api.mainnet-beta.solana.com'; + case 'localnet': + return 'http://127.0.0.1:8899'; + case 'devnet': + default: + return 'https://api.devnet.solana.com'; + } +} + +export function resolveSaidConfig(env: NodeJS.ProcessEnv): SaidConfig { + const cluster = parseCluster(env.COVENANT_SOLANA_CLUSTER); + const upper = cluster.toUpperCase(); + + const enabled = parseBool( + env[`COVENANT_SAID_${upper}_ENABLED`] ?? env.COVENANT_SAID_ENABLED, + ); + const programId = + env[`COVENANT_SAID_${upper}_PROGRAM_ID`] ?? + env.COVENANT_SAID_PROGRAM_ID ?? + defaultProgramId(cluster); + const rpcUrl = + env[`COVENANT_SAID_${upper}_RPC_URL`] ?? + env.COVENANT_SAID_RPC_URL ?? + defaultRpcUrl(cluster); + const apiBaseUrl = env.COVENANT_SAID_API_BASE_URL ?? DEFAULT_API_BASE_URL; + + return { + enabled, + cluster, + programId, + rpcUrl, + apiBaseUrl, + paid: { + register: parseBool(env.COVENANT_SAID_ALLOW_PAID_REGISTER), + verify: parseBool(env.COVENANT_SAID_ALLOW_PAID_VERIFY), + anchor: parseBool(env.COVENANT_SAID_ALLOW_PAID_ANCHOR), + validateWork: parseBool(env.COVENANT_SAID_ALLOW_PAID_VALIDATE), + sponsor: parseBool(env.COVENANT_SAID_ALLOW_PAID_SPONSOR), + }, + }; +} diff --git a/packages/said-bridge/src/index.ts b/packages/said-bridge/src/index.ts new file mode 100644 index 000000000..f38170411 --- /dev/null +++ b/packages/said-bridge/src/index.ts @@ -0,0 +1,205 @@ +// @covenant/said-bridge +// +// Thin wrapper over the `said-sdk` npm package. The SDK and +// @solana/web3.js are peer dependencies and loaded lazily so consumers +// that only need `status()` / `resolveSaidConfig()` do not pay for the +// dependency tree. + +import { createRequire } from 'node:module'; + +import { resolveSaidConfig, type SaidConfig } from './config.js'; + +export { resolveSaidConfig, type SaidConfig }; + +export class BridgeDisabledError extends Error { + constructor() { + super('said bridge is disabled'); + this.name = 'BridgeDisabledError'; + } +} + +export class BridgeSignerRequiredError extends Error { + constructor(op: string) { + super(`said bridge: ${op} requires a signer (set COVENANT_SAID_KEYPAIR)`); + this.name = 'BridgeSignerRequiredError'; + } +} + +export class BridgePaidGateClosedError extends Error { + constructor(instruction: string) { + super(`said bridge: paid gate for ${instruction} is closed`); + this.name = 'BridgePaidGateClosedError'; + } +} + +export class SaidSdkUnavailableError extends Error { + constructor(cause: unknown) { + super( + 'said bridge: said-sdk peer dependency is not installed. ' + + 'Run `pnpm add said-sdk @solana/web3.js` in the worker host.', + ); + this.name = 'SaidSdkUnavailableError'; + (this as { cause?: unknown }).cause = cause; + } +} + +export interface BridgeOptions { + config: SaidConfig; + signer?: { publicKey: { toBase58(): string }; secretKey: Uint8Array }; +} + +interface SaidSdkLike { + SAIDAgent: new (connection: unknown, wallet: unknown) => SaidAgentLike; + Connection: new (rpcUrl: string, commitment?: string) => unknown; +} + +interface SaidAgentLike { + register(metadataUri: string): Promise<{ agentPda: string; signature: string }>; + verify(): Promise<{ signature: string; slot?: number }>; + submitAnchor(args: { + anchorIndex: number | bigint; + startSeq: number | bigint; + endSeq: number | bigint; + merkleRootHex: string; + }): Promise<{ signature: string; slot?: number }>; + validateWork(args: { + agent: string; + taskHashHex: string; + passed: boolean; + evidenceUri: string; + }): Promise<{ validationPda: string; signature: string }>; + sponsorRegister(args: { + sponsoredOwner: string; + metadataUri: string; + }): Promise<{ agentPda: string; signature: string }>; + sponsorVerify(args: { sponsoredOwner: string }): Promise<{ signature: string; slot?: number }>; +} + +function loadSdk(): SaidSdkLike { + const require = createRequire(import.meta.url); + try { + const sdk = require('said-sdk'); + const web3 = require('@solana/web3.js'); + return { + SAIDAgent: sdk.SAIDAgent, + Connection: web3.Connection, + }; + } catch (cause) { + throw new SaidSdkUnavailableError(cause); + } +} + +export class SaidBridge { + private readonly config: SaidConfig; + private readonly signer?: BridgeOptions['signer']; + + constructor(opts: BridgeOptions) { + this.config = opts.config; + this.signer = opts.signer; + } + + status(): SaidConfig { + return this.config; + } + + private requireEnabled(): void { + if (!this.config.enabled) throw new BridgeDisabledError(); + } + + private requirePaid(instruction: keyof SaidConfig['paid'], op: string): void { + this.requireEnabled(); + if (!this.config.paid[instruction]) throw new BridgePaidGateClosedError(op); + } + + private requireSigner(op: string): NonNullable { + if (!this.signer) throw new BridgeSignerRequiredError(op); + return this.signer; + } + + private agent(): SaidAgentLike { + const { SAIDAgent, Connection } = loadSdk(); + const connection = new Connection(this.config.rpcUrl, 'confirmed'); + return new SAIDAgent(connection, this.requireSigner('on-chain instruction')); + } + + async registerAgent(args: { metadataUri: string }): Promise<{ + agentPda: string; + owner: string; + signature: string; + }> { + this.requirePaid('register', 'register-agent'); + const signer = this.requireSigner('register-agent'); + const agent = this.agent(); + const result = await agent.register(args.metadataUri); + return { + agentPda: result.agentPda, + owner: signer.publicKey.toBase58(), + signature: result.signature, + }; + } + + async getVerified(): Promise<{ signature: string; slot: number }> { + this.requirePaid('verify', 'get-verified'); + const agent = this.agent(); + const result = await agent.verify(); + return { signature: result.signature, slot: result.slot ?? 0 }; + } + + async submitAnchor(args: { + anchorIndex: number; + startSeq: number; + endSeq: number; + merkleRootHex: string; + }): Promise<{ txSig: string; slot: number }> { + this.requirePaid('anchor', 'submit-anchor'); + const agent = this.agent(); + const result = await agent.submitAnchor({ + anchorIndex: args.anchorIndex, + startSeq: args.startSeq, + endSeq: args.endSeq, + merkleRootHex: args.merkleRootHex, + }); + return { txSig: result.signature, slot: result.slot ?? 0 }; + } + + async validateWork(args: { + agent: string; + taskHashHex: string; + passed: boolean; + evidenceUri: string; + }): Promise<{ validationPda: string; validator: string; signature: string }> { + this.requirePaid('validateWork', 'validate-work'); + const signer = this.requireSigner('validate-work'); + const agent = this.agent(); + const result = await agent.validateWork(args); + return { + validationPda: result.validationPda, + validator: signer.publicKey.toBase58(), + signature: result.signature, + }; + } + + async sponsorRegister(args: { sponsoredOwner: string; metadataUri: string }): Promise<{ + agentPda: string; + owner: string; + signature: string; + }> { + this.requirePaid('sponsor', 'sponsor-register'); + const agent = this.agent(); + const result = await agent.sponsorRegister(args); + return { + agentPda: result.agentPda, + owner: args.sponsoredOwner, + signature: result.signature, + }; + } + + async sponsorVerify(args: { + sponsoredOwner: string; + }): Promise<{ signature: string; slot: number }> { + this.requirePaid('sponsor', 'sponsor-verify'); + const agent = this.agent(); + const result = await agent.sponsorVerify(args); + return { signature: result.signature, slot: result.slot ?? 0 }; + } +} diff --git a/packages/said-bridge/src/keypair.ts b/packages/said-bridge/src/keypair.ts new file mode 100644 index 000000000..ea2442eb3 --- /dev/null +++ b/packages/said-bridge/src/keypair.ts @@ -0,0 +1,22 @@ +// Reads a Solana CLI keypair file (JSON array of 64 bytes). The exact +// Keypair class returned must come from the same @solana/web3.js the +// said-sdk resolved, so we use createRequire to grab the runtime module. + +import type { Keypair } from '@solana/web3.js'; + +export async function loadKeypairFromFile(path: string): Promise { + const { readFileSync } = await import('node:fs'); + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const web3 = require('@solana/web3.js') as typeof import('@solana/web3.js'); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, 'utf8').trim()); + } catch (cause) { + throw new Error(`said bridge: cannot read keypair file at ${path}`, { cause }); + } + if (!Array.isArray(parsed) || parsed.some((b) => typeof b !== 'number')) { + throw new Error(`said bridge: keypair file ${path} is not a JSON byte array`); + } + return web3.Keypair.fromSecretKey(Uint8Array.from(parsed as number[])); +} diff --git a/packages/said-bridge/src/worker.ts b/packages/said-bridge/src/worker.ts new file mode 100644 index 000000000..50225d3dc --- /dev/null +++ b/packages/said-bridge/src/worker.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Covenant SAID bridge worker. +// +// The daemon (covenant-said-bridge crate) holds no JS runtime and no +// SDK, so it shells out to this worker for every on-chain operation. +// The worker is also a usable CLI on its own. +// +// Protocol: argv[2] is the command. A JSON payload may be supplied on +// stdin. Exactly one JSON object is written to stdout: +// { "ok": true, "data": } +// { "ok": false, "error": "", "name": "" } +// A non-zero exit code accompanies any { ok: false }. +// +// Config: COVENANT_SAID_* env vars (see resolveSaidConfig). Signer: +// COVENANT_SAID_KEYPAIR points at a Solana CLI keypair JSON file. +// +// Commands: +// status — resolved config snapshot + signer presence (no network) +// register-agent — stdin: { metadataUri } +// get-verified — stdin: {} +// submit-anchor — stdin: { anchorIndex, startSeq, endSeq, merkleRootHex } +// validate-work — stdin: { agent, taskHashHex, passed, evidenceUri } +// sponsor-register — stdin: { sponsoredOwner, metadataUri } +// sponsor-verify — stdin: { sponsoredOwner } + +import { SaidBridge, resolveSaidConfig } from './index.js'; +import { loadKeypairFromFile } from './keypair.js'; + +async function readStdin(): Promise { + if (process.stdin.isTTY) return ''; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8').trim(); +} + +async function parsePayload(): Promise { + const raw = await readStdin(); + if (!raw) return {} as T; + try { + return JSON.parse(raw) as T; + } catch (cause) { + throw new Error('said bridge worker: stdin is not valid JSON', { cause }); + } +} + +async function loadSigner(): Promise> | undefined> { + const path = process.env.COVENANT_SAID_KEYPAIR; + if (!path) return undefined; + return loadKeypairFromFile(path); +} + +function emit(data: unknown): void { + process.stdout.write(JSON.stringify({ ok: true, data }) + '\n'); +} + +function fail(err: unknown): never { + const error = err instanceof Error ? err.message : String(err); + const name = err instanceof Error ? err.name : 'Error'; + process.stdout.write(JSON.stringify({ ok: false, error, name }) + '\n'); + process.exit(1); +} + +async function dispatch(bridge: SaidBridge, command: string): Promise { + switch (command) { + case 'register-agent': + return bridge.registerAgent(await parsePayload()); + case 'get-verified': + return bridge.getVerified(); + case 'submit-anchor': + return bridge.submitAnchor(await parsePayload()); + case 'validate-work': + return bridge.validateWork(await parsePayload()); + case 'sponsor-register': + return bridge.sponsorRegister(await parsePayload()); + case 'sponsor-verify': + return bridge.sponsorVerify(await parsePayload()); + default: + throw new Error( + `unknown command '${command}'. Expected: status | register-agent | get-verified | ` + + 'submit-anchor | validate-work | sponsor-register | sponsor-verify', + ); + } +} + +async function main(): Promise { + const command = process.argv[2] ?? ''; + const config = resolveSaidConfig(process.env); + + if (command === 'status') { + emit({ + ...config, + hasSigner: Boolean(process.env.COVENANT_SAID_KEYPAIR?.trim()), + }); + return; + } + + const bridge = new SaidBridge({ + config, + signer: await loadSigner(), + }); + emit(await dispatch(bridge, command)); +} + +main().catch(fail); diff --git a/packages/said-bridge/tsconfig.json b/packages/said-bridge/tsconfig.json new file mode 100644 index 000000000..c358d73e3 --- /dev/null +++ b/packages/said-bridge/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cab2faa1b..d4c005699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: landing: dependencies: + '@noble/curves': + specifier: ^2.2.0 + version: 2.2.0 '@noble/hashes': specifier: ^1.5.0 version: 1.8.0 @@ -59,7 +62,7 @@ importers: version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3) '@solana/wallet-adapter-wallets': specifier: ^0.19.37 - version: 0.19.38(@babel/runtime@7.29.2)(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.14)(bs58@6.0.0)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.10.1)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@3.25.76) + version: 0.19.38(@babel/runtime@7.29.2)(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.14)(bs58@6.0.0)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.10.1)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@3.25.76) '@solana/web3.js': specifier: ^1.95.5 version: 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -125,6 +128,22 @@ importers: specifier: ^5.2.0 version: 5.2.8 + packages/said-bridge: + dependencies: + said-sdk: + specifier: '>=0.3.0' + version: 0.3.4(bufferutil@4.1.0)(typescript@6.0.3) + devDependencies: + '@solana/web3.js': + specifier: ^1.98.4 + version: 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.2(@noble/hashes@2.2.0))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/sap-bridge: dependencies: '@covenant/config': @@ -6803,6 +6822,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + said-sdk@0.3.4: + resolution: {integrity: sha512-XsUsrssnkn8Y24EsmjhWMgrwyvjLf+VqnbjaxS8hoyUlHY6SgVDshByAV9ZdGyKa64rvLhjP9IL52ohuv/HZKg==} + hasBin: true + salmon-adapter-sdk@1.1.1: resolution: {integrity: sha512-28ysSzmDjx2AbotxSggqdclh9MCwlPJUldKkCph48oS5Xtwu0QOg8T9ZRHS2Mben4Y8sTq6VvxXznKssCYFBJA==} peerDependencies: @@ -10028,31 +10051,31 @@ snapshots: - react-native - typescript - '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': + '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana-program/system@0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(utf-8-validate@6.0.6))': dependencies: '@solana/kit': 5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(utf-8-validate@6.0.6) optional: true - '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': + '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/sysvars': 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) - '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana-program/token@0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(utf-8-validate@6.0.6))': dependencies: @@ -10368,7 +10391,7 @@ snapshots: - fastestsmallesttextencoderdecoder optional: true - '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) @@ -10381,11 +10404,11 @@ snapshots: '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/rpc-parsed-types': 2.3.0(typescript@6.0.3) '@solana/rpc-spec-types': 2.3.0(typescript@6.0.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) typescript: 6.0.3 @@ -10609,14 +10632,14 @@ snapshots: - fastestsmallesttextencoderdecoder optional: true - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@solana/errors': 2.3.0(typescript@6.0.3) '@solana/functional': 2.3.0(typescript@6.0.3) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@6.0.3) '@solana/subscribable': 2.3.0(typescript@6.0.3) typescript: 6.0.3 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@solana/rpc-subscriptions-channel-websocket@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: @@ -10650,7 +10673,7 @@ snapshots: typescript: 6.0.3 optional: true - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@solana/errors': 2.3.0(typescript@6.0.3) '@solana/fast-stable-stringify': 2.3.0(typescript@6.0.3) @@ -10658,7 +10681,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@6.0.3) '@solana/rpc-spec-types': 2.3.0(typescript@6.0.3) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@6.0.3) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) @@ -10883,7 +10906,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) @@ -10891,7 +10914,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/promises': 2.3.0(typescript@6.0.3) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) @@ -11203,11 +11226,11 @@ snapshots: - typescript - utf-8-validate - '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) - '@trezor/connect-web': 9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@trezor/connect-web': 9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) buffer: 6.0.3 transitivePeerDependencies: - '@solana/sysvars' @@ -11270,7 +11293,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.38(@babel/runtime@7.29.2)(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.14)(bs58@6.0.0)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.10.1)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@3.25.76)': + '@solana/wallet-adapter-wallets@0.19.38(@babel/runtime@7.29.2)(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.14)(bs58@6.0.0)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.10.1)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@3.25.76)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) @@ -11303,7 +11326,7 @@ snapshots: '@solana/wallet-adapter-tokenary': 0.1.16(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) '@solana/wallet-adapter-tokenpocket': 0.4.23(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) '@solana/wallet-adapter-torus': 0.11.32(@babel/runtime@7.29.2)(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) - '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.14)(bufferutil@4.1.0)(ioredis@5.10.1)(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@3.25.76) @@ -11663,13 +11686,13 @@ snapshots: - supports-color - utf-8-validate - '@trezor/blockchain-link@2.6.2(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@trezor/blockchain-link@2.6.2(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))) - '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))) + '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@stellar/stellar-sdk': 14.2.0 '@trezor/blockchain-link-types': 1.5.1(tslib@2.8.1) @@ -11717,9 +11740,9 @@ snapshots: - expo-localization - react-native - '@trezor/connect-web@9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@trezor/connect-web@9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - '@trezor/connect': 9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@trezor/connect': 9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@trezor/connect-common': 0.5.1(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1) '@trezor/utils': 9.5.0(tslib@2.8.1) '@trezor/websocket-client': 1.3.0(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6) @@ -11738,7 +11761,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@trezor/connect@9.7.3(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@ethereumjs/common': 10.1.1 '@ethereumjs/tx': 10.1.1 @@ -11746,12 +11769,12 @@ snapshots: '@mobily/ts-belt': 3.13.1 '@noble/hashes': 1.8.0 '@scure/bip39': 1.6.0 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))) - '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - '@trezor/blockchain-link': 2.6.2(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))) + '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@trezor/blockchain-link': 2.6.2(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(typescript@6.0.3)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@trezor/blockchain-link-types': 1.5.1(tslib@2.8.1) '@trezor/blockchain-link-utils': 1.5.2(bufferutil@4.1.0)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1)(utf-8-validate@6.0.6) '@trezor/connect-analytics': 1.4.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(tslib@2.8.1) @@ -15110,7 +15133,7 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)): + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -15163,7 +15186,7 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)) + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 8.3.2 @@ -16687,6 +16710,17 @@ snapshots: safer-buffer@2.1.2: {} + said-sdk@0.3.4(bufferutil@4.1.0)(typescript@6.0.3): + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + bs58: 5.0.0 + commander: 12.1.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + salmon-adapter-sdk@1.1.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)): dependencies: '@project-serum/sol-wallet-adapter': 0.2.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f86a69deb..6da3b43ca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/config - packages/sap-bridge + - packages/said-bridge - packages/sdk - packages/sdk-ui - packages/ui From af5df1786aee7060ab49c3a40ab5ebbd7e15fc1b Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:33:30 +0200 Subject: [PATCH 06/22] said: free-tier schema matches live api.saidprotocol.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live response shape is { address, used, remaining, limit, paidPrice, paymentChains: [{name, network}] }. The CAIP-2 network strings stay in the bridge layer; the IPC + CLI flatten paymentChains to a list of names for terse output. Verified end-to-end: 'covenant said free-tier --address …' on a fresh wallet returns used=0 remaining=10 limit=10 paid_price=$0.01 payment_chains=solana,base,polygon,avalanche,sei,iotex,peaq,xlayer. --- agent-os/crates/covenant-ipc/src/lib.rs | 6 +++-- .../crates/covenant-said-bridge/src/xchain.rs | 16 +++++++++++-- agent-os/crates/covenant/src/main.rs | 24 ++++++++++++------- agent-os/crates/covenantd/src/lib.rs | 6 +++-- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index 8f41db74c..8cddb1ccb 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -1131,9 +1131,11 @@ pub enum Response { }, SaidFreeTier { address: String, - chain: String, + used: u32, remaining: u32, - resets_at: Option, + limit: u32, + paid_price: Option, + payment_chains: Vec, }, SaidSent { message_id: String, diff --git a/agent-os/crates/covenant-said-bridge/src/xchain.rs b/agent-os/crates/covenant-said-bridge/src/xchain.rs index d2ae41887..f7adf2c2f 100644 --- a/agent-os/crates/covenant-said-bridge/src/xchain.rs +++ b/agent-os/crates/covenant-said-bridge/src/xchain.rs @@ -60,10 +60,22 @@ pub struct SendReceipt { #[serde(rename_all = "camelCase")] pub struct FreeTierStatus { pub address: String, - pub chain: String, + #[serde(default)] + pub used: u32, pub remaining: u32, #[serde(default)] - pub resets_at: Option, + 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 { diff --git a/agent-os/crates/covenant/src/main.rs b/agent-os/crates/covenant/src/main.rs index 552554850..fe6c29ea8 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -5716,24 +5716,32 @@ async fn main() -> Result<()> { })?; write_frame(&mut stream, &Request::SaidFreeTier { address }).await?; match read_frame::<_, Response>(&mut stream).await? { - Response::SaidFreeTier { address, chain, remaining, resets_at } => { + Response::SaidFreeTier { + address, + used, + remaining, + limit, + paid_price, + payment_chains, + } => { if as_json { let value = serde_json::json!({ "kind": "said_free_tier", "address": address, - "chain": chain, + "used": used, "remaining": remaining, - "resets_at": resets_at, + "limit": limit, + "paid_price": paid_price, + "payment_chains": payment_chains, }); println!("{}", serde_json::to_string(&value)?); } else { println!("address: {address}"); - println!("chain: {chain}"); + println!("used: {used}"); println!("remaining: {remaining}"); - println!( - "resets_at: {}", - resets_at.map(|v| v.to_string()).unwrap_or_else(|| "-".into()) - ); + println!("limit: {limit}"); + println!("paid_price: {}", paid_price.unwrap_or_default()); + println!("payment_chains: {}", payment_chains.join(",")); } } Response::Error { message } => bail!("daemon error: {message}"), diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index bb308d43b..2c2b50d42 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -1456,9 +1456,11 @@ impl Server { match bridge.xchain_free_tier(&address).await { Ok(ft) => Response::SaidFreeTier { address: ft.address, - chain: ft.chain, + used: ft.used, remaining: ft.remaining, - resets_at: ft.resets_at, + 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}"), From c71db2c0c85e712b75282749759d893a6bd69eda Mon Sep 17 00:00:00 2001 From: Noam Rook Date: Mon, 8 Jun 2026 11:53:21 +0200 Subject: [PATCH 07/22] said: drop sponsor_register / sponsor_verify surface No concrete use case. The earlier plan tied sponsorship to PAK bounty winners but PAK lives on Percolator and has nothing to do with SAID; coupling the two was speculative. Removed: - bridge crate: sponsor_register / sponsor_verify methods and their input/result types; PaidGates.sponsor gate and matching env var COVENANT_SAID_ALLOW_PAID_SPONSOR. - ipc: SaidSponsorRegister / SaidSponsorVerify request variants. - daemon: said_sponsor_register / said_sponsor_verify handlers and dispatch arms. - cli: covenant said sponsor-register / sponsor-verify subcommands and their help lines. - TS package: SaidBridge.sponsorRegister / sponsorVerify, worker dispatch for sponsor-*, paid.sponsor in config.ts. Bridge surface is now register_agent + get_verified + submit_anchor + validate_work, plus the off-chain REST + xchain reads and send. Worker rebuilt cleanly; cargo workspace and TS typecheck both green; 21/21 said-bridge unit tests pass. --- agent-os/crates/covenant-ipc/src/lib.rs | 9 -- .../crates/covenant-said-bridge/src/config.rs | 6 +- .../covenant-said-bridge/src/instructions.rs | 50 -------- agent-os/crates/covenant/src/main.rs | 111 ------------------ agent-os/crates/covenantd/src/lib.rs | 51 -------- packages/said-bridge/src/config.ts | 2 - packages/said-bridge/src/index.ts | 29 ----- packages/said-bridge/src/worker.ts | 8 +- 8 files changed, 2 insertions(+), 264 deletions(-) diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index 8cddb1ccb..9e2c9a553 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -824,15 +824,6 @@ pub enum Request { passed: bool, evidence_uri: String, }, - /// Sponsor an external owner's `register_agent`. Behind COVENANT_SAID_ALLOW_PAID_SPONSOR. - SaidSponsorRegister { - sponsored_owner: String, - metadata_uri: String, - }, - /// Sponsor an external owner's `get_verified`. Behind COVENANT_SAID_ALLOW_PAID_SPONSOR. - SaidSponsorVerify { - sponsored_owner: String, - }, } fn default_recent_limit() -> usize { diff --git a/agent-os/crates/covenant-said-bridge/src/config.rs b/agent-os/crates/covenant-said-bridge/src/config.rs index 30ed8af11..7f0c24386 100644 --- a/agent-os/crates/covenant-said-bridge/src/config.rs +++ b/agent-os/crates/covenant-said-bridge/src/config.rs @@ -82,12 +82,11 @@ pub struct PaidGates { pub verify: bool, pub anchor: bool, pub validate_work: bool, - pub sponsor: bool, } impl PaidGates { pub fn any(&self) -> bool { - self.register || self.verify || self.anchor || self.validate_work || self.sponsor + self.register || self.verify || self.anchor || self.validate_work } pub fn summary(&self) -> String { @@ -96,7 +95,6 @@ impl PaidGates { if self.verify { on.push("verify"); } if self.anchor { on.push("anchor"); } if self.validate_work { on.push("validate_work"); } - if self.sponsor { on.push("sponsor"); } if on.is_empty() { "none".into() } else { on.join(",") } } } @@ -169,7 +167,6 @@ impl Config { 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")), - sponsor: parse_bool(get("COVENANT_SAID_ALLOW_PAID_SPONSOR")), }; let worker_command = get("COVENANT_SAID_WORKER_CMD") @@ -248,7 +245,6 @@ mod tests { assert!(cfg.paid.verify); assert!(!cfg.paid.register); assert!(!cfg.paid.validate_work); - assert!(!cfg.paid.sponsor); assert_eq!(cfg.paid.summary(), "verify,anchor"); } diff --git a/agent-os/crates/covenant-said-bridge/src/instructions.rs b/agent-os/crates/covenant-said-bridge/src/instructions.rs index e82ba389a..6d190e6a8 100644 --- a/agent-os/crates/covenant-said-bridge/src/instructions.rs +++ b/agent-os/crates/covenant-said-bridge/src/instructions.rs @@ -50,19 +50,6 @@ pub struct ValidationResult { pub signature: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SponsorRegisterInput { - pub sponsored_owner: String, - pub metadata_uri: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SponsorVerifyInput { - pub sponsored_owner: String, -} - fn validate_hex32(label: &str, hex: &str) -> Result<()> { if hex.len() != 64 { return Err(BridgeError::Invalid(format!( @@ -103,28 +90,6 @@ impl SaidBridge { } worker::invoke(self.config(), "validate-work", input).await } - - pub async fn sponsor_register( - &self, - input: &SponsorRegisterInput, - ) -> Result { - self.require_paid("sponsor_register", self.config().paid.sponsor)?; - if input.metadata_uri.trim().is_empty() { - return Err(BridgeError::Invalid("metadata_uri must not be empty".into())); - } - if input.sponsored_owner.trim().is_empty() { - return Err(BridgeError::Invalid("sponsored_owner must not be empty".into())); - } - worker::invoke(self.config(), "sponsor-register", input).await - } - - pub async fn sponsor_verify(&self, input: &SponsorVerifyInput) -> Result { - self.require_paid("sponsor_verify", self.config().paid.sponsor)?; - if input.sponsored_owner.trim().is_empty() { - return Err(BridgeError::Invalid("sponsored_owner must not be empty".into())); - } - worker::invoke(self.config(), "sponsor-verify", input).await - } } #[cfg(test)] @@ -167,19 +132,4 @@ mod tests { .unwrap_err(); assert!(matches!(err, BridgeError::Invalid(m) if m.contains("lowercase ASCII hex"))); } - - #[tokio::test] - async fn sponsor_register_rejects_empty_owner() { - let mut paid = crate::config::PaidGates::default(); - paid.sponsor = true; - let b = bridge_with(paid); - let err = b - .sponsor_register(&SponsorRegisterInput { - sponsored_owner: "".into(), - metadata_uri: "https://example.test".into(), - }) - .await - .unwrap_err(); - assert!(matches!(err, BridgeError::Invalid(m) if m.contains("sponsored_owner"))); - } } diff --git a/agent-os/crates/covenant/src/main.rs b/agent-os/crates/covenant/src/main.rs index fe6c29ea8..b21ef4266 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2201,12 +2201,6 @@ fn print_usage() { eprintln!( " covenant said validate-work --agent --task-hash {{--passed|--failed}} --evidence [--json] post a SAID validate_work record (paid gate VALIDATE)" ); - eprintln!( - " covenant said sponsor-register --owner --metadata-uri [--json] sponsor an external agent's SAID register (paid gate SPONSOR)" - ); - eprintln!( - " covenant said sponsor-verify --owner [--json] sponsor an external agent's SAID verify (paid gate SPONSOR)" - ); } struct MemoryReadJsonArgs { @@ -5381,111 +5375,6 @@ async fn main() -> Result<()> { other => bail!("unexpected response: {other:?}"), } } - "sponsor-register" => { - let mut sponsored_owner: Option = None; - let mut metadata_uri: Option = None; - let mut as_json = false; - let mut i = 2; - while i < args.len() { - match args[i].as_str() { - "--owner" => { - i += 1; - sponsored_owner = Some(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--owner needs a wallet") - })?); - } - "--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 sponsored_owner = sponsored_owner.ok_or_else(|| { - anyhow::anyhow!("covenant said sponsor-register requires --owner") - })?; - let metadata_uri = metadata_uri.ok_or_else(|| { - anyhow::anyhow!("covenant said sponsor-register requires --metadata-uri") - })?; - write_frame( - &mut stream, - &Request::SaidSponsorRegister { - sponsored_owner, - 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_sponsored_register", - "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:?}"), - } - } - "sponsor-verify" => { - let mut sponsored_owner: Option = None; - let mut as_json = false; - let mut i = 2; - while i < args.len() { - match args[i].as_str() { - "--owner" => { - i += 1; - sponsored_owner = Some(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--owner needs a wallet") - })?); - } - "--json" => as_json = true, - other => bail!("unknown flag '{other}'"), - } - i += 1; - } - let sponsored_owner = sponsored_owner.ok_or_else(|| { - anyhow::anyhow!("covenant said sponsor-verify requires --owner") - })?; - write_frame( - &mut stream, - &Request::SaidSponsorVerify { sponsored_owner }, - ) - .await?; - match read_frame::<_, Response>(&mut stream).await? { - Response::SaidVerified { signature, slot } => { - if as_json { - let value = serde_json::json!({ - "kind": "said_sponsored_verify", - "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:?}"), - } - } "lookup" => { let mut wallet: Option = None; let mut as_json = false; diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index 2c2b50d42..d995d6d75 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -1574,50 +1574,6 @@ impl Server { } } - pub(crate) async fn said_sponsor_register( - &self, - sponsored_owner: String, - 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::SponsorRegisterInput { - sponsored_owner, - metadata_uri, - }; - match bridge.sponsor_register(&input).await { - Ok(r) => Response::SaidOnChainRegistered { - agent_pda: r.agent_pda, - owner: r.owner, - signature: r.signature, - }, - Err(e) => Response::Error { - message: format!("said sponsor_register: {e}"), - }, - } - } - - pub(crate) async fn said_sponsor_verify(&self, sponsored_owner: 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::SponsorVerifyInput { sponsored_owner }; - match bridge.sponsor_verify(&input).await { - Ok(r) => Response::SaidVerified { - signature: r.signature, - slot: r.slot, - }, - Err(e) => Response::Error { - message: format!("said sponsor_verify: {e}"), - }, - } - } - pub(crate) async fn said_lookup(&self, wallet: String) -> Response { let Some(bridge) = self.said_bridge.as_ref() else { return Response::Error { @@ -2560,13 +2516,6 @@ impl Server { self.said_validate_work(agent, task_hash_hex, passed, evidence_uri) .await } - Request::SaidSponsorRegister { - sponsored_owner, - metadata_uri, - } => self.said_sponsor_register(sponsored_owner, metadata_uri).await, - Request::SaidSponsorVerify { sponsored_owner } => { - self.said_sponsor_verify(sponsored_owner).await - } Request::FlushReceipts { limit } => self.flush_receipts(limit, peer).await, Request::ReceiptBatches { limit } => self.receipt_batches(limit, peer).await, Request::PayX402 { diff --git a/packages/said-bridge/src/config.ts b/packages/said-bridge/src/config.ts index 589db6d25..5e8e15dee 100644 --- a/packages/said-bridge/src/config.ts +++ b/packages/said-bridge/src/config.ts @@ -15,7 +15,6 @@ export interface SaidConfig { verify: boolean; anchor: boolean; validateWork: boolean; - sponsor: boolean; }; } @@ -86,7 +85,6 @@ export function resolveSaidConfig(env: NodeJS.ProcessEnv): SaidConfig { verify: parseBool(env.COVENANT_SAID_ALLOW_PAID_VERIFY), anchor: parseBool(env.COVENANT_SAID_ALLOW_PAID_ANCHOR), validateWork: parseBool(env.COVENANT_SAID_ALLOW_PAID_VALIDATE), - sponsor: parseBool(env.COVENANT_SAID_ALLOW_PAID_SPONSOR), }, }; } diff --git a/packages/said-bridge/src/index.ts b/packages/said-bridge/src/index.ts index f38170411..890762759 100644 --- a/packages/said-bridge/src/index.ts +++ b/packages/said-bridge/src/index.ts @@ -68,11 +68,6 @@ interface SaidAgentLike { passed: boolean; evidenceUri: string; }): Promise<{ validationPda: string; signature: string }>; - sponsorRegister(args: { - sponsoredOwner: string; - metadataUri: string; - }): Promise<{ agentPda: string; signature: string }>; - sponsorVerify(args: { sponsoredOwner: string }): Promise<{ signature: string; slot?: number }>; } function loadSdk(): SaidSdkLike { @@ -178,28 +173,4 @@ export class SaidBridge { signature: result.signature, }; } - - async sponsorRegister(args: { sponsoredOwner: string; metadataUri: string }): Promise<{ - agentPda: string; - owner: string; - signature: string; - }> { - this.requirePaid('sponsor', 'sponsor-register'); - const agent = this.agent(); - const result = await agent.sponsorRegister(args); - return { - agentPda: result.agentPda, - owner: args.sponsoredOwner, - signature: result.signature, - }; - } - - async sponsorVerify(args: { - sponsoredOwner: string; - }): Promise<{ signature: string; slot: number }> { - this.requirePaid('sponsor', 'sponsor-verify'); - const agent = this.agent(); - const result = await agent.sponsorVerify(args); - return { signature: result.signature, slot: result.slot ?? 0 }; - } } diff --git a/packages/said-bridge/src/worker.ts b/packages/said-bridge/src/worker.ts index 50225d3dc..7bd01a58b 100644 --- a/packages/said-bridge/src/worker.ts +++ b/packages/said-bridge/src/worker.ts @@ -20,8 +20,6 @@ // get-verified — stdin: {} // submit-anchor — stdin: { anchorIndex, startSeq, endSeq, merkleRootHex } // validate-work — stdin: { agent, taskHashHex, passed, evidenceUri } -// sponsor-register — stdin: { sponsoredOwner, metadataUri } -// sponsor-verify — stdin: { sponsoredOwner } import { SaidBridge, resolveSaidConfig } from './index.js'; import { loadKeypairFromFile } from './keypair.js'; @@ -72,14 +70,10 @@ async function dispatch(bridge: SaidBridge, command: string): Promise { return bridge.submitAnchor(await parsePayload()); case 'validate-work': return bridge.validateWork(await parsePayload()); - case 'sponsor-register': - return bridge.sponsorRegister(await parsePayload()); - case 'sponsor-verify': - return bridge.sponsorVerify(await parsePayload()); default: throw new Error( `unknown command '${command}'. Expected: status | register-agent | get-verified | ` + - 'submit-anchor | validate-work | sponsor-register | sponsor-verify', + 'submit-anchor | validate-work', ); } } From 0310dd31529d144740df57c24661f1799f6945ff Mon Sep 17 00:00:00 2001 From: Noam Rook Date: Mon, 8 Jun 2026 12:07:18 +0200 Subject: [PATCH 08/22] said: trim docstrings, fix lookup schema, drop dead off-chain register Manual end-to-end against api.saidprotocol.com surfaced two bugs: 1. POST /api/agents returns 404. SAID has no public off-chain register endpoint, so 'covenant said register --off-chain' was dead code. Dropped the flag, the AgentCard / OffChainRegistration types, the register_off_chain bridge method, the SaidRegisterOffChain IPC variant, and the daemon handler. 'covenant said register' now only targets register_agent on chain (paid gate). 2. GET /api/agents/:wallet returns reputationScore as a float and has no verificationTier or stakeAmount fields. Replaced the AgentLookup struct with the real shape: wallet, pda, owner, name, description, metadataUri, isVerified, sponsored, reputationScore (f64), feedbackCount, activityCount, registeredAt. Verified against a real on-chain-synced agent. Also stripped em dashes and verbose module headers in the said-bridge crate and the TS package. 20/20 said-bridge unit tests pass, cargo check green across the workspace, ts typecheck clean, worker still emits a valid envelope for 'status'. --- agent-os/crates/covenant-ipc/src/lib.rs | 40 +--- .../covenant-said-bridge/src/agent_card.rs | 85 ++------- .../crates/covenant-said-bridge/src/anchor.rs | 16 +- .../crates/covenant-said-bridge/src/config.rs | 2 - .../crates/covenant-said-bridge/src/cursor.rs | 10 +- .../covenant-said-bridge/src/instructions.rs | 9 +- .../crates/covenant-said-bridge/src/lib.rs | 32 +--- .../crates/covenant-said-bridge/src/rest.rs | 7 +- .../crates/covenant-said-bridge/src/worker.rs | 7 +- .../crates/covenant-said-bridge/src/xchain.rs | 18 +- agent-os/crates/covenant/src/main.rs | 174 +++++------------- agent-os/crates/covenantd/src/lib.rs | 49 +---- packages/said-bridge/src/config.ts | 4 +- packages/said-bridge/src/index.ts | 9 +- packages/said-bridge/src/worker.ts | 24 +-- 15 files changed, 126 insertions(+), 360 deletions(-) diff --git a/agent-os/crates/covenant-ipc/src/lib.rs b/agent-os/crates/covenant-ipc/src/lib.rs index 9e2c9a553..51c01ee3d 100644 --- a/agent-os/crates/covenant-ipc/src/lib.rs +++ b/agent-os/crates/covenant-ipc/src/lib.rs @@ -762,23 +762,10 @@ pub enum Request { #[serde(default)] expires_at_unix: Option, }, - /// Resolved SAID bridge status. Read-only; no signer or RPC needed. SaidStatus, - /// Register an agent off-chain via SAID's REST API. Free, no SOL. - /// The card travels as a JSON string to keep the IPC surface decoupled - /// from the said-bridge crate's types. - SaidRegisterOffChain { - card_json: String, - }, - /// Look up a SAID agent by wallet (identity + verification + reputation). SaidLookup { wallet: String, }, - /// Anchor a Merkle-rooted audit slice into SAID. In `live = false` - /// (default) mode the payload is written to `said/anchor_pending.jsonl` - /// under `$COVENANT_HOME` and no SOL is spent. In live mode the - /// `COVENANT_SAID_ALLOW_PAID_ANCHOR` gate must be open and the worker - /// must have a signer. SaidAnchor { start_audit_index: u64, end_audit_index: u64, @@ -786,24 +773,17 @@ pub enum Request { #[serde(default)] live: bool, }, - /// Cursor snapshot: next anchor index + last confirmed index + recent rows. SaidAnchorStatus { #[serde(default = "default_recent_limit")] recent_limit: usize, }, - /// Poll the SAID xchain inbox for pending messages addressed to this - /// agent's wallet on the given chain. SaidInbox { chain: String, address: String, }, - /// Read the free-tier message quota for the given address. SaidFreeTier { address: String, }, - /// Send a cross-chain message through SAID's `/xchain/message`. Free - /// tier covers 10/day; beyond that SAID returns 402 and the daemon - /// surfaces the upstream error verbatim (paid path is x402 follow-up). SaidSend { source_chain: String, source_address: String, @@ -811,13 +791,10 @@ pub enum Request { target_address: String, payload_json: String, }, - /// Register the agent on SAID's program. Behind COVENANT_SAID_ALLOW_PAID_REGISTER. SaidRegisterOnChain { metadata_uri: String, }, - /// Pay 0.01 SOL for the verification badge. Behind COVENANT_SAID_ALLOW_PAID_VERIFY. SaidGetVerified, - /// Post a work-validation record. Behind COVENANT_SAID_ALLOW_PAID_VALIDATE. SaidValidateWork { agent: String, task_hash_hex: String, @@ -1087,18 +1064,19 @@ pub enum Response { paid_gates: String, has_signer: bool, }, - SaidRegistered { - wallet: String, - off_chain: bool, - }, SaidAgent { wallet: String, + pda: Option, + owner: Option, name: Option, + description: Option, + metadata_uri: Option, is_verified: bool, - verification_tier: u8, - stake_amount: u64, - reputation_score: u16, - total_interactions: u64, + sponsored: bool, + reputation_score: f64, + feedback_count: u64, + activity_count: u64, + registered_at: Option, }, SaidAnchored { anchor_index: u64, diff --git a/agent-os/crates/covenant-said-bridge/src/agent_card.rs b/agent-os/crates/covenant-said-bridge/src/agent_card.rs index 867995638..d179c2407 100644 --- a/agent-os/crates/covenant-said-bridge/src/agent_card.rs +++ b/agent-os/crates/covenant-said-bridge/src/agent_card.rs @@ -1,8 +1,6 @@ -//! Off-chain AgentCard registration against SAID's REST API. -//! -//! Free, instant, no SOL. Use this for the MVP path: register every -//! Covenant agent off-chain at boot, then upgrade to on-chain via the -//! worker once a paid-tx gate is opened. +//! 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}; @@ -10,61 +8,35 @@ use crate::client::SaidBridge; use crate::rest; use crate::Result; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentCard { - pub wallet: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata_uri: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub capabilities: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tags: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OffChainRegistration { - pub wallet: String, - pub off_chain: bool, - #[serde(default)] - pub created_at: Option, -} - #[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 verification_tier: u8, + pub sponsored: bool, + #[serde(default)] + pub reputation_score: f64, #[serde(default)] - pub stake_amount: u64, + pub feedback_count: u64, #[serde(default)] - pub reputation_score: u16, + pub activity_count: u64, #[serde(default)] - pub total_interactions: u64, + pub registered_at: Option, } impl SaidBridge { - /// Register an AgentCard off-chain via REST. No SOL spent. Idempotent - /// by wallet on SAID's side. - pub async fn register_off_chain(&self, card: &AgentCard) -> Result { - self.require_enabled()?; - let client = rest::build_client(self.config().rest_timeout)?; - rest::post_json(&client, &self.config().api_base_url, "/api/agents", card).await - } - - /// Look up an agent by Solana wallet address. Returns identity + - /// verification + reputation summary in one fetch. pub async fn lookup(&self, wallet: &str) -> Result { self.require_enabled()?; let client = rest::build_client(self.config().rest_timeout)?; @@ -72,26 +44,3 @@ impl SaidBridge { rest::get_json(&client, &self.config().api_base_url, &path).await } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn agent_card_serializes_camel_case() { - let card = AgentCard { - wallet: "AdChc…".into(), - name: "Covenant".into(), - description: Some("Open agent coordination".into()), - metadata_uri: Some("https://opencovenant.org/agent.json".into()), - homepage: None, - capabilities: vec!["code.review".into(), "code.write".into()], - tags: vec![], - }; - let json = serde_json::to_value(&card).unwrap(); - assert_eq!(json["metadataUri"], "https://opencovenant.org/agent.json"); - assert_eq!(json["capabilities"][0], "code.review"); - assert!(json.get("homepage").is_none()); - assert!(json.get("tags").is_none()); - } -} diff --git a/agent-os/crates/covenant-said-bridge/src/anchor.rs b/agent-os/crates/covenant-said-bridge/src/anchor.rs index 079d7863a..f8ec0f431 100644 --- a/agent-os/crates/covenant-said-bridge/src/anchor.rs +++ b/agent-os/crates/covenant-said-bridge/src/anchor.rs @@ -1,14 +1,11 @@ //! Anchor pipeline. //! -//! Submits sequential Merkle-rooted audit slices to SAID's `submit_anchor`. -//! Fixture mode writes the payload to a JSONL file and never spends SOL — -//! the default for any environment that hasn't opened -//! `COVENANT_SAID_ALLOW_PAID_ANCHOR=1`. +//! `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`. //! -//! The submitted payload mirrors SAID's instruction: -//! submit_anchor(anchor_index, start_seq, end_seq, merkle_root) -//! where `(start_seq, end_seq)` are derived from `AuditChainEntry.index` -//! (a sequential, hash-chained position) rather than settlement batch IDs. +//! `start_seq` and `end_seq` are audit-chain indices, not settlement +//! batch IDs. use std::path::PathBuf; use std::time::SystemTime; @@ -80,9 +77,6 @@ fn validate_range(req: &AnchorRequest) -> Result<()> { } impl SaidBridge { - /// Anchor a Merkle-rooted audit slice. In `Fixture` mode the call is - /// free; in `Live` mode the bridge must be enabled AND the anchor - /// paid-tx gate must be on, otherwise [`BridgeError::PaidGateClosed`]. pub async fn anchor( &self, cursor: &AnchorCursor, diff --git a/agent-os/crates/covenant-said-bridge/src/config.rs b/agent-os/crates/covenant-said-bridge/src/config.rs index 7f0c24386..6826a5063 100644 --- a/agent-os/crates/covenant-said-bridge/src/config.rs +++ b/agent-os/crates/covenant-said-bridge/src/config.rs @@ -74,8 +74,6 @@ fn default_worker_command() -> Vec { vec!["covenant-said-worker".to_owned()] } -/// Per-instruction paid-tx gates. Each one defaults off so the bridge -/// can ship and run REST-only without ever spending SOL. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct PaidGates { pub register: bool, diff --git a/agent-os/crates/covenant-said-bridge/src/cursor.rs b/agent-os/crates/covenant-said-bridge/src/cursor.rs index ac2716343..dd2b15511 100644 --- a/agent-os/crates/covenant-said-bridge/src/cursor.rs +++ b/agent-os/crates/covenant-said-bridge/src/cursor.rs @@ -1,10 +1,6 @@ -//! SQLite-backed anchor cursor. -//! -//! SAID's `submit_anchor` rejects any `anchor_index` that is not exactly -//! `AgentIdentity.last_anchor_index + 1`. The cursor durably tracks -//! every claimed slot so a daemon crash mid-submit doesn't lose the -//! position, and so two daemons can't claim the same slot under -//! ordinary single-host operation. +//! 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}; diff --git a/agent-os/crates/covenant-said-bridge/src/instructions.rs b/agent-os/crates/covenant-said-bridge/src/instructions.rs index 6d190e6a8..4ab0db7db 100644 --- a/agent-os/crates/covenant-said-bridge/src/instructions.rs +++ b/agent-os/crates/covenant-said-bridge/src/instructions.rs @@ -1,10 +1,5 @@ -//! Worker-driven SAID instructions. -//! -//! Every method here invokes `covenant-said-worker` over the JSON -//! envelope contract in [`crate::worker`]. The worker owns the SDK and -//! the funding key; this module owns the gating and the input -//! validation. Each instruction is behind its own paid-tx gate so an -//! operator can fund anchor cadence without unlocking sponsorship. +//! 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}; diff --git a/agent-os/crates/covenant-said-bridge/src/lib.rs b/agent-os/crates/covenant-said-bridge/src/lib.rs index ae86572f0..cf21e653e 100644 --- a/agent-os/crates/covenant-said-bridge/src/lib.rs +++ b/agent-os/crates/covenant-said-bridge/src/lib.rs @@ -1,28 +1,14 @@ -//! Bridge between Covenant and SAID Protocol (Solana Agent Identity Standard). +//! Bridge between Covenant and SAID Protocol on Solana. //! -//! SAID is the public agent-commons identity, reputation, and cross-chain -//! reach layer on Solana mainnet program `5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G`. -//! This crate is the local-side adapter: it registers a Covenant agent's -//! public identity, pushes Merkle-rooted audit slices into SAID's anchor -//! stream, emits `validate_work` records on completed FairScale-attested -//! jobs, and routes A2A messages over SAID's cross-chain hub. +//! SAID program: `5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G` (mainnet), +//! `ESPreFucjVwtDmZbhtL3JLJ9VxCethNEYtosMQhkcurv` (devnet). //! -//! Plane separation (versus the existing Covenant settlement program at -//! `cov9UDyp…`): SAID is the public identity + reputation surface that -//! external platforms read across 10 chains. Covenant settlement is the -//! internal CVNT-economic credit-account + slash-vault. They share no -//! signer, no stake pool, and no slash authority. -//! -//! The bridge is strictly opt-in. Every paid on-chain operation is also -//! gated behind a per-instruction `COVENANT_SAID_ALLOW_PAID_*` flag so -//! an operator can fund anchor cadence without unlocking sponsorship. -//! -//! The daemon holds no JS runtime and no SAID SDK. Off-chain registration -//! and cross-chain messaging happen in-process over REST; the four paid -//! on-chain instructions (`register_agent`, `get_verified`, -//! `submit_anchor`, `validate_work`) are delegated to the TypeScript -//! bridge worker at `@covenant/said-bridge` over the same JSON envelope -//! contract used by `@covenant/sap-bridge`. +//! 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)] diff --git a/agent-os/crates/covenant-said-bridge/src/rest.rs b/agent-os/crates/covenant-said-bridge/src/rest.rs index f7ca15130..22b72c8c0 100644 --- a/agent-os/crates/covenant-said-bridge/src/rest.rs +++ b/agent-os/crates/covenant-said-bridge/src/rest.rs @@ -1,8 +1,5 @@ -//! REST client for `api.saidprotocol.com`. -//! -//! Owns the free/off-chain surface (`POST /api/agents`, `GET /api/agents/:wallet`) -//! and the cross-chain pipes (`/xchain/inbox`, `/xchain/message`). On-chain -//! instructions go through the TS worker (`worker` module), not here. +//! REST client for `api.saidprotocol.com`. Off-chain and xchain only; +//! on-chain instructions go through the worker module. use std::time::Duration; diff --git a/agent-os/crates/covenant-said-bridge/src/worker.rs b/agent-os/crates/covenant-said-bridge/src/worker.rs index 308e35d4d..b1aece537 100644 --- a/agent-os/crates/covenant-said-bridge/src/worker.rs +++ b/agent-os/crates/covenant-said-bridge/src/worker.rs @@ -1,14 +1,11 @@ -//! Subprocess transport to the SAID bridge worker (`@covenant/said-bridge`). -//! -//! Mirrors the JSON envelope contract used by `covenant-sap-bridge`: +//! Subprocess transport to `@covenant/said-bridge`. JSON envelope: //! //! ```json //! { "ok": true, "data": } //! { "ok": false, "error": "", "name": "" } //! ``` //! -//! The worker resolves its own cluster, RPC, program id, and signer from -//! the inherited environment. The SAID owner keypair lives at +//! Worker config comes from the inherited env. Signer: //! `COVENANT_SAID_KEYPAIR`. #![allow(dead_code)] diff --git a/agent-os/crates/covenant-said-bridge/src/xchain.rs b/agent-os/crates/covenant-said-bridge/src/xchain.rs index f7adf2c2f..201eaa7f6 100644 --- a/agent-os/crates/covenant-said-bridge/src/xchain.rs +++ b/agent-os/crates/covenant-said-bridge/src/xchain.rs @@ -1,9 +1,6 @@ -//! Cross-chain inbox + outbound messaging via SAID's `/xchain` hub. -//! -//! The free tier covers 10 messages per agent per day at no cost. Beyond -//! that SAID returns 402 with an x402 challenge that the caller settles -//! via `covenant-x402-signer`. This module covers the unpaid surface; -//! the paid send is plumbed in P8. +//! 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}; @@ -93,9 +90,6 @@ impl SaidBridge { rest::get_json(&client, &self.config().api_base_url, &path).await } - /// Send a free-tier message. If SAID returns 402 (free tier exhausted) - /// the caller is responsible for settling via the x402 path; we surface - /// the 402 verbatim so the outer signer can pick it up. pub async fn xchain_send(&self, req: &SendRequest) -> Result { self.require_enabled()?; let client = rest::build_client(self.config().rest_timeout)?; @@ -111,13 +105,13 @@ mod tests { fn send_request_serializes_camel_case() { let req = SendRequest { source_chain: "solana".into(), - source_address: "AdChc…".into(), + source_address: "AdChc".into(), target_chain: "base".into(), - target_address: "0xabc…".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…"); + assert_eq!(json["targetAddress"], "0xabc"); } } diff --git a/agent-os/crates/covenant/src/main.rs b/agent-os/crates/covenant/src/main.rs index b21ef4266..39385093c 100644 --- a/agent-os/crates/covenant/src/main.rs +++ b/agent-os/crates/covenant/src/main.rs @@ -2169,37 +2169,34 @@ fn print_usage() { " covenant sap attest-agent --agent-pda --root [--type --expires] [--json] cross-party attestation via the verifier" ); eprintln!( - " covenant said status [--json] resolved SAID bridge status (cluster, program, api, paid gates)" + " covenant said status [--json] resolved SAID bridge config and paid gates" ); eprintln!( - " covenant said register --off-chain --wallet --name [--description --metadata-uri --homepage --capability* --tag*] [--json] register an AgentCard on SAID's public REST registry" + " covenant said lookup --wallet [--json] fetch a SAID agent's identity, verification, reputation" ); eprintln!( - " covenant said lookup --wallet [--json] fetch an agent's SAID identity + verification + reputation" + " covenant said anchor --start --end --root [--live] [--json] anchor an audit slice (fixture by default)" ); eprintln!( - " covenant said anchor --start --end --root [--live] [--json] anchor an audit slice (fixture by default; --live needs the paid gate)" + " covenant said anchor-status [--recent ] [--json] cursor snapshot: next index, last confirmed, recent rows" ); eprintln!( - " covenant said anchor-status [--recent ] [--json] cursor snapshot: next index, last confirmed, recent rows" + " covenant said inbox --address [--chain solana] [--json] poll pending xchain messages" ); eprintln!( - " covenant said inbox --address [--chain solana] [--json] poll pending xchain messages on SAID" + " covenant said free-tier --address [--json] check remaining free-tier xchain quota" ); eprintln!( - " covenant said free-tier --address [--json] check remaining free-tier xchain quota" + " covenant said send --source-address --target-chain --target-address --payload [--json] send an xchain message" ); eprintln!( - " covenant said send --source-address --target-chain --target-address --payload [--json] send an xchain message" + " covenant said register --metadata-uri [--json] call SAID register_agent (paid gate REGISTER)" ); eprintln!( - " covenant said register --on-chain --metadata-uri [--json] call SAID register_agent (paid gate REGISTER must be open)" + " covenant said verify [--json] buy the SAID verification badge (paid gate VERIFY)" ); 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)" + " covenant said validate-work --agent --task-hash {{--passed|--failed}} --evidence [--json] post a SAID validate_work record (paid gate VALIDATE)" ); } @@ -5130,138 +5127,48 @@ async fn main() -> Result<()> { } } "register" => { - let mut off_chain = false; - let mut on_chain = false; - let mut wallet: Option = None; - let mut name: Option = None; - let mut description: Option = None; let mut metadata_uri: Option = None; - let mut homepage: Option = None; - let mut capabilities: Vec = Vec::new(); - let mut tags: Vec = Vec::new(); let mut as_json = false; let mut i = 2; while i < args.len() { match args[i].as_str() { - "--off-chain" => off_chain = true, - "--on-chain" => on_chain = true, - "--wallet" => { - i += 1; - wallet = Some(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--wallet needs an address") - })?); - } - "--name" => { - i += 1; - name = Some(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--name needs a value") - })?); - } - "--description" => { - i += 1; - description = Some(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--description needs a value") - })?); - } "--metadata-uri" => { i += 1; metadata_uri = Some(args.get(i).cloned().ok_or_else(|| { anyhow::anyhow!("--metadata-uri needs a URL") })?); } - "--homepage" => { - i += 1; - homepage = Some(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--homepage needs a URL") - })?); - } - "--capability" => { - i += 1; - capabilities.push(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--capability needs a value") - })?); - } - "--tag" => { - i += 1; - tags.push(args.get(i).cloned().ok_or_else(|| { - anyhow::anyhow!("--tag needs a value") - })?); - } "--json" => as_json = true, other => bail!("unknown flag '{other}'"), } i += 1; } - if off_chain == on_chain { - bail!("covenant said register requires exactly one of --off-chain or --on-chain"); - } - if on_chain { - let metadata_uri = metadata_uri.ok_or_else(|| { - anyhow::anyhow!("covenant said register --on-chain 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_on_chain_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:?}"), - } - return Ok(()); - } - let wallet = wallet.ok_or_else(|| { - anyhow::anyhow!("covenant said register requires --wallet ") + let metadata_uri = metadata_uri.ok_or_else(|| { + anyhow::anyhow!("covenant said register requires --metadata-uri ") })?; - let name = name.ok_or_else(|| { - anyhow::anyhow!("covenant said register requires --name ") - })?; - let card = serde_json::json!({ - "wallet": wallet, - "name": name, - "description": description, - "metadataUri": metadata_uri, - "homepage": homepage, - "capabilities": capabilities, - "tags": tags, - }); - let card_json = serde_json::to_string(&card)?; write_frame( &mut stream, - &Request::SaidRegisterOffChain { card_json }, + &Request::SaidRegisterOnChain { metadata_uri }, ) .await?; match read_frame::<_, Response>(&mut stream).await? { - Response::SaidRegistered { wallet, off_chain } => { + Response::SaidOnChainRegistered { + agent_pda, + owner, + signature, + } => { if as_json { let value = serde_json::json!({ "kind": "said_registered", - "wallet": wallet, - "off_chain": off_chain, + "agent_pda": agent_pda, + "owner": owner, + "signature": signature, }); println!("{}", serde_json::to_string(&value)?); } else { - println!("wallet: {wallet}"); - println!("off_chain: {off_chain}"); + println!("agent_pda: {agent_pda}"); + println!("owner: {owner}"); + println!("signature: {signature}"); } } Response::Error { message } => bail!("daemon error: {message}"), @@ -5399,33 +5306,48 @@ async fn main() -> Result<()> { match read_frame::<_, Response>(&mut stream).await? { Response::SaidAgent { wallet, + pda, + owner, name, + description, + metadata_uri, is_verified, - verification_tier, - stake_amount, + sponsored, reputation_score, - total_interactions, + 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, - "verification_tier": verification_tier, - "stake_amount": stake_amount, + "sponsored": sponsored, "reputation_score": reputation_score, - "total_interactions": total_interactions, + "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!("verification_tier: {verification_tier}"); - println!("stake_amount: {stake_amount}"); + println!("sponsored: {sponsored}"); println!("reputation_score: {reputation_score}"); - println!("total_interactions: {total_interactions}"); + 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}"), diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index d995d6d75..e003fdd9c 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -211,9 +211,6 @@ pub fn sap_bridge_config_from_env() -> SapBridgeConfig { SapBridgeConfig::from_env(std::env::vars()) } -/// Resolve the SAID Protocol bridge config from `COVENANT_SAID_*` env. -/// Default `enabled: false`. Each paid on-chain instruction is gated by -/// its own `COVENANT_SAID_ALLOW_PAID_*` flag. pub fn said_bridge_config_from_env() -> SaidBridgeConfig { SaidBridgeConfig::from_env(std::env::vars()) } @@ -1227,10 +1224,6 @@ impl Server { self.sap_bridge.as_ref() } - /// Attach the SAID Protocol bridge. Symmetric to `with_sap_bridge`: - /// daemon `main` calls this once at boot with the bridge resolved - /// from `said_bridge_config_from_env`. A disabled-config bridge is - /// still attached; the `enabled` flag governs behavior. pub fn with_said_bridge(mut self, bridge: SaidBridge) -> Self { self.said_bridge = Some(bridge); self @@ -1268,32 +1261,6 @@ impl Server { } } - pub(crate) async fn said_register_off_chain(&self, card_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 card: covenant_said_bridge::agent_card::AgentCard = - match serde_json::from_str(&card_json) { - Ok(c) => c, - Err(e) => { - return Response::Error { - message: format!("invalid agent card JSON: {e}"), - } - } - }; - match bridge.register_off_chain(&card).await { - Ok(reg) => Response::SaidRegistered { - wallet: reg.wallet, - off_chain: reg.off_chain, - }, - Err(e) => Response::Error { - message: format!("said register_off_chain: {e}"), - }, - } - } - fn said_paths(&self) -> std::result::Result<(std::path::PathBuf, std::path::PathBuf), String> { let home = self .home @@ -1522,7 +1489,7 @@ impl Server { signature: r.signature, }, Err(e) => Response::Error { - message: format!("said register on-chain: {e}"), + message: format!("said register: {e}"), }, } } @@ -1583,12 +1550,17 @@ impl Server { 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, - verification_tier: a.verification_tier, - stake_amount: a.stake_amount, + sponsored: a.sponsored, reputation_score: a.reputation_score, - total_interactions: a.total_interactions, + feedback_count: a.feedback_count, + activity_count: a.activity_count, + registered_at: a.registered_at, }, Err(e) => Response::Error { message: format!("said lookup: {e}"), @@ -2471,9 +2443,6 @@ impl Server { .await } Request::SaidStatus => self.said_status(), - Request::SaidRegisterOffChain { card_json } => { - self.said_register_off_chain(card_json).await - } Request::SaidLookup { wallet } => self.said_lookup(wallet).await, Request::SaidAnchor { start_audit_index, diff --git a/packages/said-bridge/src/config.ts b/packages/said-bridge/src/config.ts index 5e8e15dee..a41a63222 100644 --- a/packages/said-bridge/src/config.ts +++ b/packages/said-bridge/src/config.ts @@ -1,6 +1,6 @@ // Resolves the SAID bridge config from environment variables. Mirrors -// `Config::from_env` in the Rust `covenant-said-bridge` crate — the -// daemon and worker must read the same variables to stay consistent. +// `Config::from_env` in `covenant-said-bridge` so the daemon and worker +// read the same variables. export type Cluster = 'devnet' | 'localnet' | 'mainnet'; diff --git a/packages/said-bridge/src/index.ts b/packages/said-bridge/src/index.ts index 890762759..bfd8f7826 100644 --- a/packages/said-bridge/src/index.ts +++ b/packages/said-bridge/src/index.ts @@ -1,9 +1,6 @@ -// @covenant/said-bridge -// -// Thin wrapper over the `said-sdk` npm package. The SDK and -// @solana/web3.js are peer dependencies and loaded lazily so consumers -// that only need `status()` / `resolveSaidConfig()` do not pay for the -// dependency tree. +// @covenant/said-bridge. Thin wrapper over said-sdk. The SDK and +// @solana/web3.js are peer deps loaded lazily so consumers that only +// need status() or resolveSaidConfig() skip the dependency tree. import { createRequire } from 'node:module'; diff --git a/packages/said-bridge/src/worker.ts b/packages/said-bridge/src/worker.ts index 7bd01a58b..44753d1a1 100644 --- a/packages/said-bridge/src/worker.ts +++ b/packages/said-bridge/src/worker.ts @@ -1,25 +1,19 @@ #!/usr/bin/env node -// Covenant SAID bridge worker. +// covenant-said-worker. Run as a subprocess by the Rust bridge; also +// usable from the shell. // -// The daemon (covenant-said-bridge crate) holds no JS runtime and no -// SDK, so it shells out to this worker for every on-chain operation. -// The worker is also a usable CLI on its own. -// -// Protocol: argv[2] is the command. A JSON payload may be supplied on -// stdin. Exactly one JSON object is written to stdout: +// argv[2] is the command. Payload on stdin. Exactly one envelope on stdout: // { "ok": true, "data": } // { "ok": false, "error": "", "name": "" } -// A non-zero exit code accompanies any { ok: false }. // -// Config: COVENANT_SAID_* env vars (see resolveSaidConfig). Signer: -// COVENANT_SAID_KEYPAIR points at a Solana CLI keypair JSON file. +// Config: COVENANT_SAID_*. Signer: COVENANT_SAID_KEYPAIR (Solana CLI JSON). // // Commands: -// status — resolved config snapshot + signer presence (no network) -// register-agent — stdin: { metadataUri } -// get-verified — stdin: {} -// submit-anchor — stdin: { anchorIndex, startSeq, endSeq, merkleRootHex } -// validate-work — stdin: { agent, taskHashHex, passed, evidenceUri } +// status no payload. Resolved config + signer presence. +// register-agent { metadataUri } +// get-verified {} +// submit-anchor { anchorIndex, startSeq, endSeq, merkleRootHex } +// validate-work { agent, taskHashHex, passed, evidenceUri } import { SaidBridge, resolveSaidConfig } from './index.js'; import { loadKeypairFromFile } from './keypair.js'; From 4df19b29410c292a5e0bdca2968f85fdce953ca2 Mon Sep 17 00:00:00 2001 From: Achille Wasque Date: Mon, 8 Jun 2026 15:19:08 +0200 Subject: [PATCH 09/22] docs: realign ipc-and-http-gateway line refs after said-bridge additions The said-bridge IPC verbs and crate shifted source lines without updating docs/ipc-and-http-gateway.md, leaving 211 stale line-ref citations that broke 159 `--scripts` validators (worst since the sap-bridge landing): - covenant/src/main.rs +699 (CLI dispatch + usage) - covenant-ipc/src/lib.rs +131 (Said* Request/Response variants) - covenantd/src/lib.rs +394 (said_* server handlers) Remap every citation to its current source line (prose verified unchanged), refresh the README status block to 25 crates / ~195k lines / 2556 tests / 388 live, add covenant-said-bridge to the crate-groups table, and bump the memory_compaction_plan validator's pinned schema-test line 7691 -> 8390. `bash agent-os/scripts/validate.sh --scripts` is green. --- README.md | 2 +- agent-os/README.md | 2 +- ...-plan-outcome-type-level-pin-line-refs.mjs | 2 +- docs/ipc-and-http-gateway.md | 422 +++++++++--------- 4 files changed, 214 insertions(+), 214 deletions(-) diff --git a/README.md b/README.md index 79229d03d..8148c3e07 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, ~195k lines, 2556 source-discovered Rust tests including 388 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/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/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 ` -