diff --git a/Cargo.lock b/Cargo.lock index c2ba21c..f1761cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,6 +352,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -3023,11 +3032,14 @@ name = "studio-types" version = "0.1.0" dependencies = [ "bech32", + "bs58", "chrono", "jsonschema", "schemars", "serde", "serde_json", + "sha2 0.10.9", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 118732b..cd91924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,8 +67,10 @@ schemars = { version = "1", features = ["chrono04"] } # Domain bech32 = "0.11" +bs58 = "0.5" chrono = { version = "0.4", features = ["serde"] } sha2 = "0.10" +url = "2" uuid = { version = "1", features = ["v4"] } # Configuration — defaults ← YAML ← SCARCED_* env (ludovic, 2026-08-01) diff --git a/crates/studio-api/src/endpoints/create_rfq.rs b/crates/studio-api/src/endpoints/create_rfq.rs index f4abb52..beba077 100644 --- a/crates/studio-api/src/endpoints/create_rfq.rs +++ b/crates/studio-api/src/endpoints/create_rfq.rs @@ -44,7 +44,13 @@ pub async fn handler( match studio_store::rfqs::insert(&state.db, &rfq).await { Ok(()) => { - tracing::info!(rfq_id = %rfq.id, buyer = %rfq.buyer_npub, "rfq captured"); + // Identity union: log whichever buyer key the capture carried. + let buyer = rfq + .buyer_npub + .as_deref() + .or(rfq.buyer_solana_pubkey.as_deref()) + .unwrap_or(""); + tracing::info!(rfq_id = %rfq.id, buyer, "rfq captured"); state.emit(crate::LifecycleBeat::DemandCaptured { rfq: Box::new(rfq.clone()), }); diff --git a/crates/studio-api/tests/project_api.rs b/crates/studio-api/tests/project_api.rs index cbbf325..b570e27 100644 --- a/crates/studio-api/tests/project_api.rs +++ b/crates/studio-api/tests/project_api.rs @@ -85,6 +85,7 @@ async fn contract(app: &axum::Router) -> String { { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } ]}, "channel": { "idle_timeout_seconds": 604_800 }, + "engagement_endpoint": "https://scarce.sh/api/v1/engagements/rfq-1", "expires_at": "2099-01-01T00:00:00Z" })), ) diff --git a/crates/studio-api/tests/quote_api.rs b/crates/studio-api/tests/quote_api.rs index 9493aba..758e1f8 100644 --- a/crates/studio-api/tests/quote_api.rs +++ b/crates/studio-api/tests/quote_api.rs @@ -78,6 +78,7 @@ fn quote_body(expires_at: &str) -> serde_json::Value { { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } ]}, "channel": { "idle_timeout_seconds": 604_800 }, + "engagement_endpoint": "https://scarce.sh/api/v1/engagements/rfq-1", "expires_at": expires_at }) } @@ -110,6 +111,17 @@ async fn quote_round_trip_with_defaults_applied() { assert!(created["gate_policy"]["edges"]["QUOTED->FUNDED"].is_array()); assert!(created["created_at"].is_string()); assert_eq!(created["lapsed_at"], serde_json::Value::Null); + // commission-flow seam: the funding target and the quote commitment the + // session terms hash-commit at accept are both on the record + assert_eq!( + created["engagement_endpoint"], + "https://scarce.sh/api/v1/engagements/rfq-1" + ); + assert_eq!( + created["quote_hash"].as_str().map(str::len), + Some(64), + "quote hash sealed at issue" + ); // buyer read is free (no bearer) and identical let (status, fetched) = send( diff --git a/crates/studio-api/tests/rfq_api.rs b/crates/studio-api/tests/rfq_api.rs index 8c50b14..75ba035 100644 --- a/crates/studio-api/tests/rfq_api.rs +++ b/crates/studio-api/tests/rfq_api.rs @@ -74,6 +74,57 @@ async fn post_get_round_trip() { assert_eq!(fetched, created, "GET returns exactly what POST created"); } +#[tokio::test] +async fn pay_intake_rfq_round_trips() { + // The pay-side shape (capability-request draft-00 slice 1): Solana-keyed + // buyer, no npub, brief from the intake interview. + let app = app().await; + + let (status, created) = send( + &app, + "POST", + "/api/v1/rfqs", + Some(serde_json::json!({ + "query": "solana priority fee forecast api", + "buyer_solana_pubkey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "brief": { + "example_exchange": { + "request": { "program_id": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" }, + "response": { "p50_lamports": 12000, "p90_lamports": 55000 } + }, + "freshness": { "kind": "cached", "ttl_seconds": 30 }, + "upstream_dependencies": [ + { "name": "helius rpc", "est_cost_per_call": { "amount": 10, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } } + ], + "volume": { "calls_per_month": 50_000, "avg_request_bytes": 128, "avg_response_bytes": 512 }, + "compute_class": "cpu", + "state": { "kind": "cache" }, + "interface": "request_response" + } + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + assert_eq!(created["buyer_npub"], serde_json::Value::Null); + assert_eq!(created["brief"]["freshness"]["kind"], "cached"); + + let id = created["id"].as_str().expect("id assigned"); + let (status, fetched) = send(&app, "GET", &format!("/api/v1/rfqs/{id}"), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched, created, "brief survives the store round trip"); + + // No identity at all refuses at the door. + let (status, body) = send( + &app, + "POST", + "/api/v1/rfqs", + Some(serde_json::json!({ "query": "anything" })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["errors"][0]["field"], "buyer_npub"); +} + #[tokio::test] async fn invalid_rfq_gets_422_with_field_errors() { let app = app().await; diff --git a/crates/studio-core/src/project.rs b/crates/studio-core/src/project.rs index 1ad2ee3..89f0549 100644 --- a/crates/studio-core/src/project.rs +++ b/crates/studio-core/src/project.rs @@ -101,8 +101,12 @@ mod tests { amount: 900_000_000, mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), }), - buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + buyer_npub: Some( + "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + ), + buyer_solana_pubkey: None, buyer_signature: None, + brief: None, created_at: ts("2026-08-01T14:00:00Z"), } } @@ -133,12 +137,16 @@ mod tests { }, gate_policy: studio_types::GatePolicy::studio_default(), policy_hash: "ab".repeat(32), + quote_hash: String::new(), + engagement_endpoint: + "https://scarce.sh/api/v1/engagements/3f6b2c1a-0000-4000-8000-000000000000".into(), expires_at: ts("2026-09-01T00:00:00Z"), status, created_at: ts("2026-08-01T15:00:00Z"), lapsed_at: None, accepted_at: (status == QuoteStatus::Accepted).then(|| ts("2026-08-01T16:00:00Z")), } + .with_commitment_hash() } fn links() -> ProjectLinks { diff --git a/crates/studio-core/src/quote.rs b/crates/studio-core/src/quote.rs index f176f1f..b4d6623 100644 --- a/crates/studio-core/src/quote.rs +++ b/crates/studio-core/src/quote.rs @@ -10,8 +10,10 @@ use studio_types::{FieldError, NewQuote, Quote, QuoteStatus}; use crate::gate::commitment_hash; /// Validate `new` against `now` and assemble the issued quote, including the -/// gate-policy commitment hash (PLAN.md §2.1(4)). The single path from -/// submission to `Quote` — handlers only supply `rfq_id`, `id`, and `now`. +/// gate-policy commitment hash (PLAN.md §2.1(4)) and the quote's own +/// commitment hash (capability-request draft-00 §4 — session terms hash-commit +/// the quote at accept). The single path from submission to `Quote` — +/// handlers only supply `rfq_id`, `id`, and `now`. pub fn issue( new: NewQuote, rfq_id: String, @@ -23,18 +25,21 @@ pub fn issue( id, rfq_id, policy_hash: commitment_hash(&new.gate_policy), + quote_hash: String::new(), price: new.price, milestones: new.milestones, timeline: new.timeline, payout_destination: new.payout_destination, channel: new.channel, + engagement_endpoint: new.engagement_endpoint, gate_policy: new.gate_policy, expires_at: new.expires_at, status: QuoteStatus::Quoted, created_at: now, lapsed_at: None, accepted_at: None, - }) + } + .with_commitment_hash()) } /// Why an acceptance was refused. Fail-closed like the gate engine: anything @@ -91,13 +96,14 @@ mod tests { grace_seconds: 172_800, idle_timeout_seconds: 3_600, }, + engagement_endpoint: "https://scarce.sh/api/v1/engagements/rfq-1".into(), gate_policy: GatePolicy::studio_default(), expires_at: ts("2026-08-08T15:00:00Z"), } } #[test] - fn issue_assigns_identity_time_status_and_policy_hash() { + fn issue_assigns_identity_time_status_and_both_hashes() { let now = ts("2026-08-01T15:00:00Z"); let quote = issue(valid(), "rfq-1".into(), "q-1".into(), now).unwrap(); assert_eq!(quote.id, "q-1"); @@ -109,6 +115,8 @@ mod tests { quote.policy_hash, commitment_hash(&GatePolicy::studio_default()) ); + // sealed at issue: the recorded hash is the recomputable commitment + assert_eq!(quote.quote_hash, quote.commitment_hash()); } #[test] diff --git a/crates/studio-core/src/rfq.rs b/crates/studio-core/src/rfq.rs index dc54057..29ae3bf 100644 --- a/crates/studio-core/src/rfq.rs +++ b/crates/studio-core/src/rfq.rs @@ -19,7 +19,9 @@ pub fn capture(new: NewRfq, id: String, now: DateTime) -> Result Result<()> { "INSERT INTO quotes (rfq_id, id, price_amount, price_mint, milestones, timeline, payout_destination, grace_seconds, idle_timeout_seconds, gate_policy, policy_hash, - expires_at, status, created_at, lapsed_at, - accepted_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + engagement_endpoint, expires_at, status, + created_at, lapsed_at, accepted_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", ) .bind("e.rfq_id) .bind("e.id) @@ -29,6 +29,7 @@ pub async fn insert(pool: &SqlitePool, quote: &Quote) -> Result<()> { .bind(quote.channel.idle_timeout_seconds as i64) .bind(serde_json::to_string("e.gate_policy).expect("gate policy serializes")) .bind("e.policy_hash) + .bind("e.engagement_endpoint) .bind(quote.expires_at.to_rfc3339()) .bind(status_str(quote.status)) .bind(quote.created_at.to_rfc3339()) @@ -114,6 +115,8 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { let status: String = row.get("status"); let lapsed_at: Option = row.get("lapsed_at"); let accepted_at: Option = row.get("accepted_at"); + // quote_hash is derived, never stored (studio-types Quote docs) — the + // sealing step below recomputes it from the commitment fields. Ok(Quote { id: row.get("id"), rfq_id: row.get("rfq_id"), @@ -130,6 +133,8 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { }, gate_policy: json("gate_policy", row.get("gate_policy"))?, policy_hash: row.get("policy_hash"), + engagement_endpoint: row.get("engagement_endpoint"), + quote_hash: String::new(), expires_at: ts("expires_at", row.get("expires_at"))?, status: match status.as_str() { "QUOTED" => QuoteStatus::Quoted, @@ -140,7 +145,8 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { created_at: ts("created_at", row.get("created_at"))?, lapsed_at: lapsed_at.map(|raw| ts("lapsed_at", raw)).transpose()?, accepted_at: accepted_at.map(|raw| ts("accepted_at", raw)).transpose()?, - }) + } + .with_commitment_hash()) } #[cfg(test)] @@ -165,9 +171,12 @@ mod tests { monetization: None, competition: vec![], budget_ceiling: None, - buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy" - .into(), + buyer_npub: Some( + "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + ), + buyer_solana_pubkey: None, buyer_signature: None, + brief: None, created_at: ts("2026-08-01T14:00:00Z"), }, ) @@ -210,13 +219,16 @@ mod tests { idle_timeout_seconds: 604_800, }, policy_hash: "policy-hash-set-at-issue-time".into(), + quote_hash: String::new(), gate_policy, + engagement_endpoint: format!("https://scarce.sh/api/v1/engagements/{rfq_id}"), expires_at: ts(expires_at), status: QuoteStatus::Quoted, created_at: ts("2026-08-01T15:00:00Z"), lapsed_at: None, accepted_at: None, } + .with_commitment_hash() } #[tokio::test] diff --git a/crates/studio-store/src/rfqs.rs b/crates/studio-store/src/rfqs.rs index 6f5def2..d7b2bfd 100644 --- a/crates/studio-store/src/rfqs.rs +++ b/crates/studio-store/src/rfqs.rs @@ -11,8 +11,9 @@ pub async fn insert(pool: &SqlitePool, rfq: &Rfq) -> Result<()> { sqlx::query( "INSERT INTO rfqs (id, query, product, monetization, competition, budget_amount, budget_mint, buyer_npub, - buyer_signature, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + buyer_solana_pubkey, buyer_signature, brief, + created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", ) .bind(&rfq.id) .bind(&rfq.query) @@ -22,7 +23,13 @@ pub async fn insert(pool: &SqlitePool, rfq: &Rfq) -> Result<()> { .bind(rfq.budget_ceiling.as_ref().map(|b| b.amount as i64)) .bind(rfq.budget_ceiling.as_ref().map(|b| b.mint.clone())) .bind(&rfq.buyer_npub) + .bind(&rfq.buyer_solana_pubkey) .bind(&rfq.buyer_signature) + .bind( + rfq.brief + .as_ref() + .map(|b| serde_json::to_string(b).expect("brief serializes")), + ) .bind(rfq.created_at.to_rfc3339()) .execute(pool) .await?; @@ -81,7 +88,15 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { } }, buyer_npub: row.get("buyer_npub"), + buyer_solana_pubkey: row.get("buyer_solana_pubkey"), buyer_signature: row.get("buyer_signature"), + brief: row + .get::, _>("brief") + .map(|raw| { + serde_json::from_str(&raw) + .map_err(|e| crate::StoreError::Corrupt(format!("rfqs.brief: {e}"))) + }) + .transpose()?, created_at: DateTime::parse_from_rfc3339(&created_at) .map_err(|e| crate::StoreError::Corrupt(format!("rfqs.created_at: {e}")))? .with_timezone(&Utc), @@ -103,8 +118,27 @@ mod tests { amount: 250_000_000, mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), }), - buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + buyer_npub: Some( + "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + ), + buyer_solana_pubkey: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into()), buyer_signature: Some("recorded-not-verified".into()), + brief: Some(studio_types::Brief { + example_exchange: studio_types::ExampleExchange { + request: serde_json::json!({ "program_id": "JUP6" }), + response: serde_json::json!({ "p50_lamports": 12000 }), + }, + freshness: studio_types::Freshness::Cached { ttl_seconds: 30 }, + upstream_dependencies: vec![], + volume: studio_types::VolumeBand { + calls_per_month: 50_000, + avg_request_bytes: 128, + avg_response_bytes: 512, + }, + compute_class: studio_types::ComputeClass::Cpu, + state: studio_types::StateRequirement::Cache, + interface: studio_types::InterfaceKind::RequestResponse, + }), created_at: DateTime::parse_from_rfc3339(created_at) .unwrap() .with_timezone(&Utc), diff --git a/crates/studio-store/src/workrooms.rs b/crates/studio-store/src/workrooms.rs index 45488ea..a4be0d7 100644 --- a/crates/studio-store/src/workrooms.rs +++ b/crates/studio-store/src/workrooms.rs @@ -76,9 +76,12 @@ mod tests { monetization: None, competition: vec![], budget_ceiling: None, - buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy" - .into(), + buyer_npub: Some( + "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + ), + buyer_solana_pubkey: None, buyer_signature: None, + brief: None, created_at: ts("2026-08-01T14:00:00Z"), }, ) diff --git a/crates/studio-types/Cargo.toml b/crates/studio-types/Cargo.toml index 45ec1a4..32db0b4 100644 --- a/crates/studio-types/Cargo.toml +++ b/crates/studio-types/Cargo.toml @@ -7,10 +7,13 @@ publish = false [dependencies] bech32 = { workspace = true } +bs58 = { workspace = true } chrono = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } +url = { workspace = true } [dev-dependencies] # Keeps schemas/*.json and the Rust validation honest with each other. diff --git a/crates/studio-types/src/brief.rs b/crates/studio-types/src/brief.rs new file mode 100644 index 0000000..3a0d9e7 --- /dev/null +++ b/crates/studio-types/src/brief.rs @@ -0,0 +1,313 @@ +//! Capability brief — the intake interview's structured output (jude's +//! capability-request draft-00, thread 6873a1ec; slice 1). +//! +//! The brief rides an RFQ when the demand comes through the pay-side +//! intake path: the buyer's *own* model runs the interview, so the brief +//! is the buyer's signed representation, never something the studio must +//! trust. Three things dominate micro-agent opex — freshness (a cron burns +//! money whether or not anyone calls), paid upstream dependencies, and +//! data volume — so those are the load-bearing fields. Deliberately absent: +//! the buyer's willingness-to-pay (ruling A: the reserve price stays +//! pay-side and never reaches studios pre-quote). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::rfq::{Amount, FieldError}; + +/// The capability brief carried by an RFQ from the pay intake path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars( + title = "Capability brief", + description = "Structured output of the pay-side intake interview. example_exchange doubles as the delivery acceptance check (the default endpoint-live gate validates against it); freshness decides the monetization shape (scheduled work breaks scale-to-zero economics); upstream_dependencies carry the dominant opex." +)] +pub struct Brief { + /// A mocked request/response of the endpoint the buyer wishes existed — + /// simultaneously the studio's estimation input and the delivery + /// acceptance test. + pub example_exchange: ExampleExchange, + pub freshness: Freshness, + /// Paid third-party APIs the capability would sit on. + #[serde(default)] + pub upstream_dependencies: Vec, + pub volume: VolumeBand, + pub compute_class: ComputeClass, + pub state: StateRequirement, + pub interface: InterfaceKind, +} + +/// The exchange the buyer wishes existed. Arbitrary JSON on both sides — +/// this is an example, not a schema. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExampleExchange { + pub request: serde_json::Value, + pub response: serde_json::Value, +} + +/// How fresh the answer must be. Not a sizing detail: `scheduled` flips the +/// monetization shape from per-call to retainer, so the intake interview +/// branches on it. +// NOTE: no deny_unknown_fields — serde silently ignores it under internal +// tagging; strictness is enforced at the Brief container boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Freshness { + Realtime, + Cached { + #[schemars(range(min = 1))] + ttl_seconds: u64, + }, + Scheduled { + /// Cron expression for the refresh job. + #[schemars(length(min = 1))] + cron: String, + }, +} + +/// One paid upstream the capability depends on. Cost per call is the one +/// number the buyer's agent should live-check rather than guess. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UpstreamDependency { + #[schemars(length(min = 1))] + pub name: String, + #[serde(default)] + pub est_cost_per_call: Option, +} + +/// Estimated traffic, for sizing. Byte averages may be zero (a bodiless GET +/// has no request bytes); the call count may not. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VolumeBand { + #[schemars(range(min = 1))] + pub calls_per_month: u64, + pub avg_request_bytes: u64, + pub avg_response_bytes: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ComputeClass { + Proxy, + Cpu, + Gpu, +} + +/// What the capability must remember between calls. +// NOTE: no deny_unknown_fields — see `Freshness`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum StateRequirement { + None, + Cache, + Durable { + #[schemars(range(min = 1))] + gib: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum InterfaceKind { + RequestResponse, + WebhookPush, + Dataset, +} + +impl Brief { + /// Structural validation, paths prefixed with `prefix` (the RFQ nests + /// the brief, so errors read `brief.freshness.cron` etc.). The brief is + /// agent-assembled, not human-typed — a present brief must be sound. + pub fn validate(&self, prefix: &str) -> Result<(), Vec> { + let mut errors = Vec::new(); + let mut push = |field: String, message: &str| { + errors.push(FieldError { + field, + message: message.into(), + }); + }; + + match &self.freshness { + Freshness::Realtime => {} + Freshness::Cached { ttl_seconds } => { + if *ttl_seconds == 0 { + push( + format!("{prefix}.freshness.ttl_seconds"), + "must be at least 1", + ); + } + } + Freshness::Scheduled { cron } => { + if cron.trim().is_empty() { + push( + format!("{prefix}.freshness.cron"), + "must be a non-empty cron expression", + ); + } + } + } + + for (i, dep) in self.upstream_dependencies.iter().enumerate() { + if dep.name.trim().is_empty() { + push( + format!("{prefix}.upstream_dependencies[{i}].name"), + "must be non-empty", + ); + } + if let Some(cost) = &dep.est_cost_per_call { + if cost.amount == 0 { + push( + format!("{prefix}.upstream_dependencies[{i}].est_cost_per_call.amount"), + "must be greater than zero when present", + ); + } + if cost.mint.trim().is_empty() { + push( + format!("{prefix}.upstream_dependencies[{i}].est_cost_per_call.mint"), + "must be a non-empty mint address when present", + ); + } + } + } + + if self.volume.calls_per_month == 0 { + push( + format!("{prefix}.volume.calls_per_month"), + "must be at least 1", + ); + } + + if let StateRequirement::Durable { gib } = &self.state { + if *gib == 0 { + push(format!("{prefix}.state.gib"), "must be at least 1"); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid() -> Brief { + Brief { + example_exchange: ExampleExchange { + request: serde_json::json!({ "program_id": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" }), + response: serde_json::json!({ "p50_lamports": 12000, "p90_lamports": 55000 }), + }, + freshness: Freshness::Cached { ttl_seconds: 30 }, + upstream_dependencies: vec![UpstreamDependency { + name: "helius rpc".into(), + est_cost_per_call: Some(Amount { + amount: 10, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }), + }], + volume: VolumeBand { + calls_per_month: 50_000, + avg_request_bytes: 0, + avg_response_bytes: 512, + }, + compute_class: ComputeClass::Cpu, + state: StateRequirement::Cache, + interface: InterfaceKind::RequestResponse, + } + } + + fn fields_of(brief: Brief) -> Vec { + brief + .validate("brief") + .unwrap_err() + .into_iter() + .map(|e| e.field) + .collect() + } + + #[test] + fn valid_brief_passes_and_zero_request_bytes_are_fine() { + assert!(valid().validate("brief").is_ok()); + } + + #[test] + fn zero_cache_ttl_is_rejected() { + let mut b = valid(); + b.freshness = Freshness::Cached { ttl_seconds: 0 }; + assert_eq!(fields_of(b), vec!["brief.freshness.ttl_seconds"]); + } + + #[test] + fn blank_cron_is_rejected() { + let mut b = valid(); + b.freshness = Freshness::Scheduled { cron: " ".into() }; + assert_eq!(fields_of(b), vec!["brief.freshness.cron"]); + } + + #[test] + fn upstream_dependency_errors_carry_indexed_paths() { + let mut b = valid(); + b.upstream_dependencies.push(UpstreamDependency { + name: " ".into(), + est_cost_per_call: Some(Amount { + amount: 0, + mint: "".into(), + }), + }); + let fields = fields_of(b); + for expected in [ + "brief.upstream_dependencies[1].name", + "brief.upstream_dependencies[1].est_cost_per_call.amount", + "brief.upstream_dependencies[1].est_cost_per_call.mint", + ] { + assert!(fields.contains(&expected.to_string()), "missing {expected}"); + } + } + + #[test] + fn zero_call_volume_and_zero_durable_gib_are_rejected() { + let mut b = valid(); + b.volume.calls_per_month = 0; + b.state = StateRequirement::Durable { gib: 0 }; + let fields = fields_of(b); + assert!(fields.contains(&"brief.volume.calls_per_month".to_string())); + assert!(fields.contains(&"brief.state.gib".to_string())); + } + + #[test] + fn wire_shape_round_trips_and_rejects_unknown_fields() { + let json = serde_json::json!({ + "example_exchange": { "request": null, "response": { "ok": true } }, + "freshness": { "kind": "scheduled", "cron": "0 * * * *" }, + "volume": { "calls_per_month": 100, "avg_request_bytes": 0, "avg_response_bytes": 64 }, + "compute_class": "proxy", + "state": { "kind": "durable", "gib": 2 }, + "interface": "dataset" + }); + let brief: Brief = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(brief.upstream_dependencies, vec![]); + assert_eq!(serde_json::from_value::(json).unwrap(), brief); + + // Unknown fields are rejected at the brief level. (Inside the + // kind-tagged enums serde cannot enforce deny_unknown_fields — a + // documented serde limitation of internal tagging — so strictness + // lives at the container boundary.) + let bad = serde_json::json!({ + "example_exchange": { "request": null, "response": null }, + "freshness": { "kind": "realtime" }, + "volume": { "calls_per_month": 1, "avg_request_bytes": 0, "avg_response_bytes": 0 }, + "compute_class": "cpu", + "state": { "kind": "none" }, + "interface": "request_response", + "surprise": 1 + }); + assert!(serde_json::from_value::(bad).is_err()); + } +} diff --git a/crates/studio-types/src/lib.rs b/crates/studio-types/src/lib.rs index 6edc2f2..8f0262f 100644 --- a/crates/studio-types/src/lib.rs +++ b/crates/studio-types/src/lib.rs @@ -7,6 +7,7 @@ //! drift from the code. Field validation lives next to the types; state //! machines and orchestration logic stay in `studio-core`. +pub mod brief; pub mod gate; pub mod project; pub mod quote; @@ -14,10 +15,14 @@ pub mod rfq; pub mod schemas; pub mod state; +pub use brief::{ + Brief, ComputeClass, ExampleExchange, Freshness, InterfaceKind, StateRequirement, + UpstreamDependency, VolumeBand, +}; pub use gate::{GatePolicy, GateSpec}; pub use project::{Project, ProjectLinks, ProjectMilestone, ProjectQuote, ProjectWorkroom}; pub use quote::{ ChannelParams, MilestoneSpec, NewQuote, PayoutDestination, Quote, QuoteStatus, Split, }; -pub use rfq::{validate_npub, Amount, FieldError, NewRfq, Rfq}; +pub use rfq::{validate_npub, validate_solana_pubkey, Amount, FieldError, NewRfq, Rfq}; pub use state::{Edge, EdgePattern, ProjectState}; diff --git a/crates/studio-types/src/quote.rs b/crates/studio-types/src/quote.rs index 34f6e62..80f9055 100644 --- a/crates/studio-types/src/quote.rs +++ b/crates/studio-types/src/quote.rs @@ -9,6 +9,7 @@ use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::gate::GatePolicy; use crate::rfq::{Amount, FieldError}; @@ -31,6 +32,11 @@ pub struct NewQuote { pub timeline: String, pub payout_destination: PayoutDestination, pub channel: ChannelParams, + /// The 402-gated URL acceptance opens the MPP session against (jude's + /// capability-request draft-00 §4): deposit = price, terms from `channel`, + /// payee = `payout_destination`. + #[schemars(length(min = 1), extend("format" = "uri"))] + pub engagement_endpoint: String, /// Defaults from studio config; buyers may strengthen per-project. #[serde(default = "GatePolicy::studio_default")] pub gate_policy: GatePolicy, @@ -118,11 +124,21 @@ pub struct Quote { pub timeline: String, pub payout_destination: PayoutDestination, pub channel: ChannelParams, + pub engagement_endpoint: String, pub gate_policy: GatePolicy, /// `studio-core::gate::commitment_hash(&gate_policy)`, precomputed at /// issue time. Recorded again (and enforced) at the FUNDED transition — /// PLAN.md §2.1(4). pub policy_hash: String, + /// SHA-256 (hex) over the canonical JSON of the quote's immutable + /// commitment fields, in this exact order: id, rfq_id, price, + /// milestones, timeline, payout_destination, channel, + /// engagement_endpoint, policy_hash, expires_at (lifecycle fields — + /// status, created_at, lapsed_at, accepted_at — are excluded). Same + /// canonicalization as policy_hash. Session terms hash-commit the quote + /// through this value at accept, so what is funded is provably what was + /// quoted. Derived from the fields, never stored — it cannot go stale. + pub quote_hash: String, pub expires_at: DateTime, pub status: QuoteStatus, pub created_at: DateTime, @@ -132,6 +148,25 @@ pub struct Quote { pub accepted_at: Option>, } +/// The immutable commitment fields of a quote, in the exact serialization +/// order `quote_hash` documents. A serialize-only view: adding a lifecycle +/// field to `Quote` cannot silently change the hash, and an external +/// verifier can rebuild this object from the published schema description +/// alone. +#[derive(Serialize)] +struct QuoteCommitment<'a> { + id: &'a str, + rfq_id: &'a str, + price: &'a Amount, + milestones: &'a [MilestoneSpec], + timeline: &'a str, + payout_destination: &'a PayoutDestination, + channel: &'a ChannelParams, + engagement_endpoint: &'a str, + policy_hash: &'a str, + expires_at: &'a DateTime, +} + impl Quote { /// The status as of `now`, fail-closed against sweep lag: a quote past /// `expires_at` reads LAPSED even if the sweep has not stamped it yet. @@ -143,6 +178,40 @@ impl Quote { } self } + + /// Compute the commitment hash from the immutable fields (see the + /// `quote_hash` field docs for the exact input). Lives here rather than + /// `studio-core` because the store derives it on every row read and + /// depends only on this crate — and pay-side verifiers get it from the + /// contract crate for free. + pub fn commitment_hash(&self) -> String { + let commitment = QuoteCommitment { + id: &self.id, + rfq_id: &self.rfq_id, + price: &self.price, + milestones: &self.milestones, + timeline: &self.timeline, + payout_destination: &self.payout_destination, + channel: &self.channel, + engagement_endpoint: &self.engagement_endpoint, + policy_hash: &self.policy_hash, + expires_at: &self.expires_at, + }; + let canonical = serde_json::to_vec(&commitment).expect("QuoteCommitment serializes"); + let digest = Sha256::digest(&canonical); + digest.iter().fold(String::with_capacity(64), |mut s, b| { + use std::fmt::Write; + write!(s, "{b:02x}").expect("writing to a String cannot fail"); + s + }) + } + + /// Fill `quote_hash` from the other fields — the single sealing step + /// every constructor path (issuance, row read) goes through. + pub fn with_commitment_hash(mut self) -> Quote { + self.quote_hash = self.commitment_hash(); + self + } } impl NewQuote { @@ -198,6 +267,24 @@ impl NewQuote { push("timeline", "must be non-empty"); } + match url::Url::parse(&self.engagement_endpoint) { + Err(_) => push( + "engagement_endpoint", + "must be an absolute URL (the 402-gated endpoint acceptance \ + opens the session against)", + ), + Ok(parsed) => { + if !matches!(parsed.scheme(), "http" | "https") { + push( + "engagement_endpoint", + "must use the http or https scheme (sessions open over HTTP 402)", + ); + } else if parsed.host_str().is_none() { + push("engagement_endpoint", "must include a host"); + } + } + } + match &self.payout_destination { PayoutDestination::Splits { splits } => { if splits.is_empty() { @@ -302,6 +389,7 @@ mod tests { grace_seconds: 172_800, idle_timeout_seconds: 604_800, }, + engagement_endpoint: "https://scarce.sh/api/v1/engagements/rfq-1".into(), gate_policy: GatePolicy::studio_default(), expires_at: ts("2026-08-08T15:00:00Z"), } @@ -451,6 +539,7 @@ mod tests { { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } ]}, "channel": { "idle_timeout_seconds": 3600 }, + "engagement_endpoint": "https://scarce.sh/api/v1/engagements/rfq-1", "expires_at": "2026-08-08T15:00:00Z" }); let q: NewQuote = serde_json::from_value(json).unwrap(); @@ -473,9 +562,8 @@ mod tests { } } - #[test] - fn effective_status_derives_lapsed_past_expiry() { - let q = Quote { + fn record() -> Quote { + Quote { id: "q-1".into(), rfq_id: "r-1".into(), price: Amount { @@ -489,14 +577,73 @@ mod tests { grace_seconds: 1, idle_timeout_seconds: 1, }, + engagement_endpoint: "https://scarce.sh/api/v1/engagements/r-1".into(), gate_policy: GatePolicy::studio_default(), policy_hash: "policy-hash-set-at-issue-time".into(), + quote_hash: String::new(), expires_at: ts("2026-08-02T00:00:00Z"), status: QuoteStatus::Quoted, created_at: ts("2026-08-01T00:00:00Z"), lapsed_at: None, accepted_at: None, - }; + } + .with_commitment_hash() + } + + #[test] + fn missing_or_malformed_engagement_endpoint_is_rejected() { + for bad in ["", "not a url", "ftp://scarce.sh/x", "scarce.sh/relative"] { + let mut q = valid(); + q.engagement_endpoint = bad.into(); + assert!( + errors_of(q).contains(&"engagement_endpoint".to_string()), + "should reject {bad:?}" + ); + } + // and the field is required on the wire, not defaulted + let mut json = serde_json::to_value(valid()).unwrap(); + json.as_object_mut().unwrap().remove("engagement_endpoint"); + assert!(serde_json::from_value::(json).is_err()); + } + + #[test] + fn commitment_hash_is_deterministic_and_covers_the_commitment_fields() { + let q = record(); + assert_eq!(q.quote_hash, q.commitment_hash()); + assert_eq!(q.quote_hash.len(), 64); + assert_eq!(q.quote_hash, record().quote_hash); + + // lifecycle fields do not move the hash… + let mut accepted = record(); + accepted.status = QuoteStatus::Accepted; + accepted.accepted_at = Some(ts("2026-08-01T12:00:00Z")); + assert_eq!(accepted.commitment_hash(), q.quote_hash); + + // …commitment fields do + for mutated in [ + { + let mut m = record(); + m.price.amount = 2; + m + }, + { + let mut m = record(); + m.engagement_endpoint = "https://scarce.sh/api/v1/engagements/other".into(); + m + }, + { + let mut m = record(); + m.policy_hash = "different".into(); + m + }, + ] { + assert_ne!(mutated.commitment_hash(), q.quote_hash); + } + } + + #[test] + fn effective_status_derives_lapsed_past_expiry() { + let q = record(); let live = q.clone().at(ts("2026-08-01T23:59:59Z")); assert_eq!(live.status, QuoteStatus::Quoted); diff --git a/crates/studio-types/src/rfq.rs b/crates/studio-types/src/rfq.rs index 822feeb..2d518f2 100644 --- a/crates/studio-types/src/rfq.rs +++ b/crates/studio-types/src/rfq.rs @@ -9,11 +9,18 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::brief::Brief; + /// Structural npub pattern for the JSON Schema. The bech32 charset excludes /// `1`, `b`, `i`, `o`; `validate()` additionally checks the checksum, which a /// regex cannot. pub const NPUB_PATTERN: &str = "^npub1[02-9ac-hj-np-z]{58}$"; +/// Structural Solana pubkey pattern for the JSON Schema (base58, 32 bytes +/// encodes to 32–44 chars); `validate()` additionally decodes, which a regex +/// cannot. +pub const SOLANA_PUBKEY_PATTERN: &str = "^[1-9A-HJ-NP-Za-km-z]{32,44}$"; + /// A buyer-submitted RFQ, before the studio assigns identity and time. /// Wire shape: `schemas/rfq.json` — generated from this type. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -38,9 +45,20 @@ pub struct NewRfq { /// Budget signal, not a commitment. #[serde(default)] pub budget_ceiling: Option, - /// Buyer identity — the Nostr npub it will later pay with. + /// Buyer identity, Nostr side. At least one of `buyer_npub` / + /// `buyer_solana_pubkey` is required — the pay intake path (jude's + /// draft-00) submits with the Solana key it will pay with, direct + /// captures keep using the npub. + #[serde(default)] #[schemars(regex(pattern = NPUB_PATTERN))] - pub buyer_npub: String, + pub buyer_npub: Option, + /// Buyer identity, Solana side — the ed25519 key the buyer will fund + /// the engagement with. Structural check here (base58, 32 bytes); + /// on-curve is implied by signature verification when it lands + /// (slice 2). + #[serde(default)] + #[schemars(regex(pattern = SOLANA_PUBKEY_PATTERN))] + pub buyer_solana_pubkey: Option, /// Reserved for the buyer-authored upgrade path (archy, 2026-08-01 M1 /// boundary): a SIWX-style signature over the submission by /// `buyer_npub`, making the RFQ counterparty-signed substrate instead @@ -49,6 +67,11 @@ pub struct NewRfq { #[serde(default)] #[schemars(length(min = 1))] pub buyer_signature: Option, + /// Capability brief from the pay-side intake interview. Optional — + /// direct captures stay frictionless; when present it must be + /// structurally sound (it is agent-assembled, not human-typed). + #[serde(default)] + pub brief: Option, } /// Token amount in minor units of `mint`. @@ -73,9 +96,13 @@ pub struct Rfq { pub monetization: Option, pub competition: Vec, pub budget_ceiling: Option, - pub buyer_npub: String, + /// At least one buyer identity is always present (capture refuses + /// otherwise); which one depends on the intake path. + pub buyer_npub: Option, + pub buyer_solana_pubkey: Option, /// Reserved (see `NewRfq::buyer_signature`); recorded, not verified. pub buyer_signature: Option, + pub brief: Option, /// RFC 3339, UTC, server-assigned at capture. pub created_at: chrono::DateTime, } @@ -103,12 +130,30 @@ impl NewRfq { }); } - if let Err(message) = validate_npub(&self.buyer_npub) { + if self.buyer_npub.is_none() && self.buyer_solana_pubkey.is_none() { errors.push(FieldError { field: "buyer_npub".into(), - message, + message: "at least one buyer identity is required \ + (buyer_npub or buyer_solana_pubkey)" + .into(), }); } + if let Some(npub) = &self.buyer_npub { + if let Err(message) = validate_npub(npub) { + errors.push(FieldError { + field: "buyer_npub".into(), + message, + }); + } + } + if let Some(pubkey) = &self.buyer_solana_pubkey { + if let Err(message) = validate_solana_pubkey(pubkey) { + errors.push(FieldError { + field: "buyer_solana_pubkey".into(), + message, + }); + } + } if let Some(budget) = &self.budget_ceiling { if budget.amount == 0 { @@ -134,6 +179,12 @@ impl NewRfq { } } + if let Some(brief) = &self.brief { + if let Err(brief_errors) = brief.validate("brief") { + errors.extend(brief_errors); + } + } + if errors.is_empty() { Ok(()) } else { @@ -159,6 +210,22 @@ pub fn validate_npub(npub: &str) -> Result<(), String> { Ok(()) } +/// Structural Solana pubkey check: base58 decodes to exactly 32 bytes. +/// Deliberately no on-curve check — that is what signature verification +/// proves (slice 2); a key that never signs never funds anything. +pub fn validate_solana_pubkey(pubkey: &str) -> Result<(), String> { + let bytes = bs58::decode(pubkey) + .into_vec() + .map_err(|_| "must be a base58 Solana pubkey".to_string())?; + if bytes.len() != 32 { + return Err(format!( + "must decode to 32 bytes, got {} — not an ed25519 pubkey", + bytes.len() + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -166,6 +233,9 @@ mod tests { // ruben's real npub — a known-good bech32 vector. const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + // A well-known 32-byte base58 vector (the USDC mint) — shape-valid. + const GOOD_SOLANA: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + fn minimal() -> NewRfq { NewRfq { query: "solana priority fee forecast api".into(), @@ -173,8 +243,10 @@ mod tests { monetization: None, competition: vec![], budget_ceiling: None, - buyer_npub: GOOD_NPUB.into(), + buyer_npub: Some(GOOD_NPUB.into()), + buyer_solana_pubkey: None, buyer_signature: None, + brief: None, } } @@ -183,6 +255,58 @@ mod tests { assert!(minimal().validate().is_ok()); } + #[test] + fn solana_only_identity_is_valid() { + let mut rfq = minimal(); + rfq.buyer_npub = None; + rfq.buyer_solana_pubkey = Some(GOOD_SOLANA.into()); + assert!(rfq.validate().is_ok()); + } + + #[test] + fn missing_both_identities_is_rejected() { + let mut rfq = minimal(); + rfq.buyer_npub = None; + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].field, "buyer_npub"); + assert!(errors[0].message.contains("buyer_solana_pubkey")); + } + + #[test] + fn bad_solana_pubkey_is_rejected_even_alongside_a_good_npub() { + // 0, O, I, l are outside the base58 alphabet; "abc" decodes short. + for bad in ["", "abc", "0OIl0OIl0OIl0OIl0OIl0OIl0OIl0OIl"] { + let mut rfq = minimal(); + rfq.buyer_solana_pubkey = Some(bad.into()); + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors[0].field, "buyer_solana_pubkey", "input: {bad}"); + } + } + + #[test] + fn invalid_brief_fails_the_rfq_with_prefixed_paths() { + let mut rfq = minimal(); + rfq.brief = Some(crate::brief::Brief { + example_exchange: crate::brief::ExampleExchange { + request: serde_json::Value::Null, + response: serde_json::Value::Null, + }, + freshness: crate::brief::Freshness::Cached { ttl_seconds: 0 }, + upstream_dependencies: vec![], + volume: crate::brief::VolumeBand { + calls_per_month: 1, + avg_request_bytes: 0, + avg_response_bytes: 0, + }, + compute_class: crate::brief::ComputeClass::Proxy, + state: crate::brief::StateRequirement::None, + interface: crate::brief::InterfaceKind::RequestResponse, + }); + let errors = rfq.validate().unwrap_err(); + assert_eq!(errors[0].field, "brief.freshness.ttl_seconds"); + } + #[test] fn empty_query_is_rejected_with_field_error() { let mut rfq = minimal(); @@ -197,7 +321,7 @@ mod tests { let truncated = &GOOD_NPUB[..GOOD_NPUB.len() - 2]; for bad in ["", "npub1notbech32!!!", "hello", truncated] { let mut rfq = minimal(); - rfq.buyer_npub = bad.into(); + rfq.buyer_npub = Some(bad.into()); let errors = rfq.validate().unwrap_err(); assert_eq!(errors[0].field, "buyer_npub", "input: {bad}"); } @@ -207,9 +331,10 @@ mod tests { fn nsec_hrp_is_rejected() { // Right shape, wrong HRP — must not attribute demand to a secret key. let mut rfq = minimal(); - rfq.buyer_npub = + rfq.buyer_npub = Some( bech32::encode::(bech32::Hrp::parse("nsec").unwrap(), &[7u8; 32]) - .unwrap(); + .unwrap(), + ); let errors = rfq.validate().unwrap_err(); assert_eq!(errors[0].field, "buyer_npub"); } @@ -236,8 +361,10 @@ mod tests { amount: 0, mint: "".into(), }), - buyer_npub: "nope".into(), + buyer_npub: Some("nope".into()), + buyer_solana_pubkey: None, buyer_signature: None, + brief: None, }; let errors = rfq.validate().unwrap_err(); let fields: Vec<_> = errors.iter().map(|e| e.field.as_str()).collect(); diff --git a/crates/studio-types/tests/quote_conformance.rs b/crates/studio-types/tests/quote_conformance.rs index eaa9515..a6aadf0 100644 --- a/crates/studio-types/tests/quote_conformance.rs +++ b/crates/studio-types/tests/quote_conformance.rs @@ -99,6 +99,7 @@ fn valid_quote() -> serde_json::Value { { "recipient": "CrewAgentB111111111111111111111111111111111", "bps": 3000 } ]}, "channel": { "grace_seconds": 172_800, "idle_timeout_seconds": 604_800 }, + "engagement_endpoint": "https://scarce.sh/api/v1/engagements/rfq-1", "expires_at": "2026-08-08T15:00:00Z" }) } diff --git a/migrations/0006_rfq_brief_quote_commitment.sql b/migrations/0006_rfq_brief_quote_commitment.sql new file mode 100644 index 0000000..ec256e4 --- /dev/null +++ b/migrations/0006_rfq_brief_quote_commitment.sql @@ -0,0 +1,79 @@ +-- Capability-request flow draft-00 slice 1 (jude's spec, thread 6873a1ec). +-- +-- rfqs: buyer identity becomes a union — the pay-side intake path +-- submits with the Solana key the buyer will fund the engagement with, +-- direct captures keep using the npub. At least one is required; the CHECK +-- keeps the invariant at the storage layer too. `brief` carries the intake +-- interview's structured output (JSON, validated upstream at capture). +-- +-- quotes: `engagement_endpoint` is the 402-gated URL acceptance opens the +-- MPP session against — required on every quote from now on. Existing rows +-- (local dev data only; scarce.sh is not deployed yet) are backfilled with +-- the production path shape so NOT NULL can hold. +-- +-- Both tables are rebuilt rather than altered: SQLite cannot relax NOT NULL +-- or add a CHECK in place, and both are projections — rebuildable by design. + +CREATE TABLE rfqs_new ( + id TEXT PRIMARY KEY, + query TEXT NOT NULL, + product TEXT, + monetization TEXT, + competition TEXT NOT NULL DEFAULT '[]', -- JSON array of strings + budget_amount INTEGER, -- minor units; NULL = no signal + budget_mint TEXT, + buyer_npub TEXT, + buyer_solana_pubkey TEXT, + buyer_signature TEXT, + brief TEXT, -- JSON capability brief + created_at TEXT NOT NULL, -- RFC 3339, UTC + CHECK (buyer_npub IS NOT NULL OR buyer_solana_pubkey IS NOT NULL) +) STRICT; + +INSERT INTO rfqs_new (id, query, product, monetization, competition, + budget_amount, budget_mint, buyer_npub, + buyer_signature, created_at) +SELECT id, query, product, monetization, competition, + budget_amount, budget_mint, buyer_npub, + buyer_signature, created_at +FROM rfqs; + +CREATE TABLE quotes_new ( + rfq_id TEXT PRIMARY KEY REFERENCES rfqs_new (id), + id TEXT NOT NULL UNIQUE, + price_amount INTEGER NOT NULL, + price_mint TEXT NOT NULL, + milestones TEXT NOT NULL, -- JSON array of milestone specs + timeline TEXT NOT NULL, + payout_destination TEXT NOT NULL, -- JSON, kind-tagged + grace_seconds INTEGER NOT NULL, + idle_timeout_seconds INTEGER NOT NULL, + gate_policy TEXT NOT NULL, -- JSON, hash-committed via policy_hash + policy_hash TEXT NOT NULL, -- sha256 hex of canonical gate_policy + engagement_endpoint TEXT NOT NULL, -- 402-gated URL acceptance funds against + expires_at TEXT NOT NULL, -- RFC 3339, UTC + status TEXT NOT NULL CHECK (status IN ('QUOTED', 'LAPSED', 'ACCEPTED')), + created_at TEXT NOT NULL, -- RFC 3339, UTC + lapsed_at TEXT, + accepted_at TEXT -- RFC 3339, UTC; set exactly once +) STRICT; + +INSERT INTO quotes_new (rfq_id, id, price_amount, price_mint, milestones, + timeline, payout_destination, grace_seconds, + idle_timeout_seconds, gate_policy, policy_hash, + engagement_endpoint, expires_at, status, created_at, + lapsed_at, accepted_at) +SELECT rfq_id, id, price_amount, price_mint, milestones, + timeline, payout_destination, grace_seconds, + idle_timeout_seconds, gate_policy, policy_hash, + 'https://scarce.sh/api/v1/engagements/' || rfq_id, + expires_at, status, created_at, lapsed_at, accepted_at +FROM quotes; + +DROP TABLE quotes; +DROP TABLE rfqs; +ALTER TABLE rfqs_new RENAME TO rfqs; +ALTER TABLE quotes_new RENAME TO quotes; + +CREATE INDEX idx_rfqs_created_at ON rfqs (created_at); +CREATE INDEX idx_quotes_status_expires_at ON quotes (status, expires_at); diff --git a/schemas/quote-record.json b/schemas/quote-record.json index f685693..e7e077a 100644 --- a/schemas/quote-record.json +++ b/schemas/quote-record.json @@ -303,6 +303,9 @@ "format": "date-time", "type": "string" }, + "engagement_endpoint": { + "type": "string" + }, "expires_at": { "format": "date-time", "type": "string" @@ -337,6 +340,10 @@ "price": { "$ref": "#/$defs/Amount" }, + "quote_hash": { + "description": "SHA-256 (hex) over the canonical JSON of the quote's immutable\ncommitment fields, in this exact order: id, rfq_id, price,\nmilestones, timeline, payout_destination, channel,\nengagement_endpoint, policy_hash, expires_at (lifecycle fields —\nstatus, created_at, lapsed_at, accepted_at — are excluded). Same\ncanonicalization as policy_hash. Session terms hash-commit the quote\nthrough this value at accept, so what is funded is provably what was\nquoted. Derived from the fields, never stored — it cannot go stale.", + "type": "string" + }, "rfq_id": { "type": "string" }, @@ -355,8 +362,10 @@ "timeline", "payout_destination", "channel", + "engagement_endpoint", "gate_policy", "policy_hash", + "quote_hash", "expires_at", "status", "created_at" diff --git a/schemas/quote.json b/schemas/quote.json index fb8b097..8dd6c2e 100644 --- a/schemas/quote.json +++ b/schemas/quote.json @@ -283,6 +283,12 @@ "channel": { "$ref": "#/$defs/ChannelParams" }, + "engagement_endpoint": { + "description": "The 402-gated URL acceptance opens the MPP session against (jude's\ncapability-request draft-00 §4): deposit = price, terms from `channel`,\npayee = `payout_destination`.", + "format": "uri", + "minLength": 1, + "type": "string" + }, "expires_at": { "description": "Past this instant the quote is LAPSED and cannot be accepted.", "format": "date-time", @@ -355,6 +361,7 @@ "timeline", "payout_destination", "channel", + "engagement_endpoint", "expires_at" ], "title": "Quote submission", diff --git a/schemas/rfq-record.json b/schemas/rfq-record.json index 9e93c0e..df41fde 100644 --- a/schemas/rfq-record.json +++ b/schemas/rfq-record.json @@ -21,12 +21,246 @@ "mint" ], "type": "object" + }, + "Brief": { + "additionalProperties": false, + "description": "Structured output of the pay-side intake interview. example_exchange doubles as the delivery acceptance check (the default endpoint-live gate validates against it); freshness decides the monetization shape (scheduled work breaks scale-to-zero economics); upstream_dependencies carry the dominant opex.", + "properties": { + "compute_class": { + "$ref": "#/$defs/ComputeClass" + }, + "example_exchange": { + "$ref": "#/$defs/ExampleExchange", + "description": "A mocked request/response of the endpoint the buyer wishes existed —\nsimultaneously the studio's estimation input and the delivery\nacceptance test." + }, + "freshness": { + "$ref": "#/$defs/Freshness" + }, + "interface": { + "$ref": "#/$defs/InterfaceKind" + }, + "state": { + "$ref": "#/$defs/StateRequirement" + }, + "upstream_dependencies": { + "default": [], + "description": "Paid third-party APIs the capability would sit on.", + "items": { + "$ref": "#/$defs/UpstreamDependency" + }, + "type": "array" + }, + "volume": { + "$ref": "#/$defs/VolumeBand" + } + }, + "required": [ + "example_exchange", + "freshness", + "volume", + "compute_class", + "state", + "interface" + ], + "title": "Capability brief", + "type": "object" + }, + "ComputeClass": { + "enum": [ + "proxy", + "cpu", + "gpu" + ], + "type": "string" + }, + "ExampleExchange": { + "additionalProperties": false, + "description": "The exchange the buyer wishes existed. Arbitrary JSON on both sides —\nthis is an example, not a schema.", + "properties": { + "request": true, + "response": true + }, + "required": [ + "request", + "response" + ], + "type": "object" + }, + "Freshness": { + "description": "How fresh the answer must be. Not a sizing detail: `scheduled` flips the\nmonetization shape from per-call to retainer, so the intake interview\nbranches on it.", + "oneOf": [ + { + "properties": { + "kind": { + "const": "realtime", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "cached", + "type": "string" + }, + "ttl_seconds": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "kind", + "ttl_seconds" + ], + "type": "object" + }, + { + "properties": { + "cron": { + "description": "Cron expression for the refresh job.", + "minLength": 1, + "type": "string" + }, + "kind": { + "const": "scheduled", + "type": "string" + } + }, + "required": [ + "kind", + "cron" + ], + "type": "object" + } + ] + }, + "InterfaceKind": { + "enum": [ + "request_response", + "webhook_push", + "dataset" + ], + "type": "string" + }, + "StateRequirement": { + "description": "What the capability must remember between calls.", + "oneOf": [ + { + "properties": { + "kind": { + "const": "none", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "cache", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "gib": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "kind": { + "const": "durable", + "type": "string" + } + }, + "required": [ + "kind", + "gib" + ], + "type": "object" + } + ] + }, + "UpstreamDependency": { + "additionalProperties": false, + "description": "One paid upstream the capability depends on. Cost per call is the one\nnumber the buyer's agent should live-check rather than guess.", + "properties": { + "est_cost_per_call": { + "anyOf": [ + { + "$ref": "#/$defs/Amount" + }, + { + "type": "null" + } + ], + "default": null + }, + "name": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "VolumeBand": { + "additionalProperties": false, + "description": "Estimated traffic, for sizing. Byte averages may be zero (a bodiless GET\nhas no request bytes); the call count may not.", + "properties": { + "avg_request_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "avg_response_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "calls_per_month": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "calls_per_month", + "avg_request_bytes", + "avg_response_bytes" + ], + "type": "object" } }, "$id": "https://scarce.studio/schemas/rfq-record.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "A captured demand record — what `POST /api/v1/rfqs` returns.", "properties": { + "brief": { + "anyOf": [ + { + "$ref": "#/$defs/Brief" + }, + { + "type": "null" + } + ] + }, "budget_ceiling": { "anyOf": [ { @@ -38,7 +272,11 @@ ] }, "buyer_npub": { - "type": "string" + "description": "At least one buyer identity is always present (capture refuses\notherwise); which one depends on the intake path.", + "type": [ + "string", + "null" + ] }, "buyer_signature": { "description": "Reserved (see `NewRfq::buyer_signature`); recorded, not verified.", @@ -47,6 +285,12 @@ "null" ] }, + "buyer_solana_pubkey": { + "type": [ + "string", + "null" + ] + }, "competition": { "items": { "type": "string" @@ -81,7 +325,6 @@ "id", "query", "competition", - "buyer_npub", "created_at" ], "title": "RFQ record", diff --git a/schemas/rfq.json b/schemas/rfq.json index 7e57e98..37004ab 100644 --- a/schemas/rfq.json +++ b/schemas/rfq.json @@ -21,6 +21,230 @@ "mint" ], "type": "object" + }, + "Brief": { + "additionalProperties": false, + "description": "Structured output of the pay-side intake interview. example_exchange doubles as the delivery acceptance check (the default endpoint-live gate validates against it); freshness decides the monetization shape (scheduled work breaks scale-to-zero economics); upstream_dependencies carry the dominant opex.", + "properties": { + "compute_class": { + "$ref": "#/$defs/ComputeClass" + }, + "example_exchange": { + "$ref": "#/$defs/ExampleExchange", + "description": "A mocked request/response of the endpoint the buyer wishes existed —\nsimultaneously the studio's estimation input and the delivery\nacceptance test." + }, + "freshness": { + "$ref": "#/$defs/Freshness" + }, + "interface": { + "$ref": "#/$defs/InterfaceKind" + }, + "state": { + "$ref": "#/$defs/StateRequirement" + }, + "upstream_dependencies": { + "default": [], + "description": "Paid third-party APIs the capability would sit on.", + "items": { + "$ref": "#/$defs/UpstreamDependency" + }, + "type": "array" + }, + "volume": { + "$ref": "#/$defs/VolumeBand" + } + }, + "required": [ + "example_exchange", + "freshness", + "volume", + "compute_class", + "state", + "interface" + ], + "title": "Capability brief", + "type": "object" + }, + "ComputeClass": { + "enum": [ + "proxy", + "cpu", + "gpu" + ], + "type": "string" + }, + "ExampleExchange": { + "additionalProperties": false, + "description": "The exchange the buyer wishes existed. Arbitrary JSON on both sides —\nthis is an example, not a schema.", + "properties": { + "request": true, + "response": true + }, + "required": [ + "request", + "response" + ], + "type": "object" + }, + "Freshness": { + "description": "How fresh the answer must be. Not a sizing detail: `scheduled` flips the\nmonetization shape from per-call to retainer, so the intake interview\nbranches on it.", + "oneOf": [ + { + "properties": { + "kind": { + "const": "realtime", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "cached", + "type": "string" + }, + "ttl_seconds": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "kind", + "ttl_seconds" + ], + "type": "object" + }, + { + "properties": { + "cron": { + "description": "Cron expression for the refresh job.", + "minLength": 1, + "type": "string" + }, + "kind": { + "const": "scheduled", + "type": "string" + } + }, + "required": [ + "kind", + "cron" + ], + "type": "object" + } + ] + }, + "InterfaceKind": { + "enum": [ + "request_response", + "webhook_push", + "dataset" + ], + "type": "string" + }, + "StateRequirement": { + "description": "What the capability must remember between calls.", + "oneOf": [ + { + "properties": { + "kind": { + "const": "none", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "cache", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "gib": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "kind": { + "const": "durable", + "type": "string" + } + }, + "required": [ + "kind", + "gib" + ], + "type": "object" + } + ] + }, + "UpstreamDependency": { + "additionalProperties": false, + "description": "One paid upstream the capability depends on. Cost per call is the one\nnumber the buyer's agent should live-check rather than guess.", + "properties": { + "est_cost_per_call": { + "anyOf": [ + { + "$ref": "#/$defs/Amount" + }, + { + "type": "null" + } + ], + "default": null + }, + "name": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "VolumeBand": { + "additionalProperties": false, + "description": "Estimated traffic, for sizing. Byte averages may be zero (a bodiless GET\nhas no request bytes); the call count may not.", + "properties": { + "avg_request_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "avg_response_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "calls_per_month": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "calls_per_month", + "avg_request_bytes", + "avg_response_bytes" + ], + "type": "object" } }, "$id": "https://scarce.studio/schemas/rfq.json", @@ -28,6 +252,18 @@ "additionalProperties": false, "description": "A demand record captured from a pay.sh catalog miss. Capture is deliberately frictionless: only the query and the buyer identity are required (DESIGN.md §2 — never tax the order book). This is the wire shape of POST /api/v1/rfqs; the studio assigns id and created_at.", "properties": { + "brief": { + "anyOf": [ + { + "$ref": "#/$defs/Brief" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Capability brief from the pay-side intake interview. Optional —\ndirect captures stay frictionless; when present it must be\nstructurally sound (it is agent-assembled, not human-typed)." + }, "budget_ceiling": { "anyOf": [ { @@ -41,9 +277,13 @@ "description": "Budget signal, not a commitment." }, "buyer_npub": { - "description": "Buyer identity — the Nostr npub it will later pay with.", + "default": null, + "description": "Buyer identity, Nostr side. At least one of `buyer_npub` /\n`buyer_solana_pubkey` is required — the pay intake path (jude's\ndraft-00) submits with the Solana key it will pay with, direct\ncaptures keep using the npub.", "pattern": "^npub1[02-9ac-hj-np-z]{58}$", - "type": "string" + "type": [ + "string", + "null" + ] }, "buyer_signature": { "default": null, @@ -54,6 +294,15 @@ "null" ] }, + "buyer_solana_pubkey": { + "default": null, + "description": "Buyer identity, Solana side — the ed25519 key the buyer will fund\nthe engagement with. Structural check here (base58, 32 bytes);\non-curve is implied by signature verification when it lands\n(slice 2).", + "pattern": "^[1-9A-HJ-NP-Za-km-z]{32,44}$", + "type": [ + "string", + "null" + ] + }, "competition": { "default": [], "description": "Competing or adjacent offerings the buyer knows about.", @@ -85,8 +334,7 @@ } }, "required": [ - "query", - "buyer_npub" + "query" ], "title": "RFQ submission", "type": "object" diff --git a/src/mirror.rs b/src/mirror.rs index 55cfbc6..d720d2e 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -80,15 +80,22 @@ async fn mirror_one( // The workroom is private — the buyer must be a member to see // it. Best-effort with its own loud log: a failed add must not // lose the contract post or the evidence row above. - match studio_buzz::pubkey_hex(&rfq.buyer_npub) { - Ok(buyer_hex) => { - if let Err(e) = buzz.add_member(created.channel_id, &buyer_hex).await { - tracing::error!(error = %e, rfq_id = %rfq.id, - channel_id = %created.channel_id, "buyer not added to workroom"); + // Membership is npub-keyed; a Solana-only buyer (pay intake + // path) reaches the workroom via the operator until the + // npub↔Ed25519 binding lands. + match &rfq.buyer_npub { + Some(npub) => match studio_buzz::pubkey_hex(npub) { + Ok(buyer_hex) => { + if let Err(e) = buzz.add_member(created.channel_id, &buyer_hex).await { + tracing::error!(error = %e, rfq_id = %rfq.id, + channel_id = %created.channel_id, "buyer not added to workroom"); + } } - } - Err(e) => tracing::error!(error = %e, rfq_id = %rfq.id, - "buyer npub does not decode; not added to workroom"), + Err(e) => tracing::error!(error = %e, rfq_id = %rfq.id, + "buyer npub does not decode; not added to workroom"), + }, + None => tracing::info!(rfq_id = %rfq.id, + "buyer has no npub (solana-keyed rfq); not added to workroom"), } let project_url = format!("{public_url}/project/{}", rfq.id); buzz.post( @@ -118,6 +125,15 @@ fn workroom_about(rfq: &Rfq, quote: &Quote) -> String { ) } +/// Whichever buyer identity the capture carried (at least one always is — +/// capture refuses otherwise). +fn buyer_label(rfq: &Rfq) -> &str { + rfq.buyer_npub + .as_deref() + .or(rfq.buyer_solana_pubkey.as_deref()) + .unwrap_or("") +} + fn budget_line(rfq: &Rfq) -> String { match &rfq.budget_ceiling { Some(amount) => format!("{} (mint `{}`)", amount.amount, amount.mint), @@ -130,7 +146,7 @@ fn demand_post(rfq: &Rfq) -> String { "📥 demand captured — rfq `{}`\n> {}\nbuyer `{}` · budget {}", rfq.id, rfq.query, - rfq.buyer_npub, + buyer_label(rfq), budget_line(rfq), ) } @@ -156,7 +172,7 @@ fn accepted_post(rfq: &Rfq, quote: &Quote) -> String { .accepted_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "?".into()), - rfq.buyer_npub, + buyer_label(rfq), quote.price.amount, quote.price.mint, ) @@ -196,8 +212,12 @@ mod tests { monetization: None, competition: vec![], budget_ceiling: None, - buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + buyer_npub: Some( + "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + ), + buyer_solana_pubkey: None, buyer_signature: None, + brief: None, created_at: ts("2026-08-01T14:00:00Z"), } } @@ -228,12 +248,16 @@ mod tests { }, gate_policy: studio_types::GatePolicy::studio_default(), policy_hash: "hash".into(), + quote_hash: String::new(), + engagement_endpoint: + "https://scarce.sh/api/v1/engagements/9e342a83-429b-4887-9cae-6ddecd78f7c5".into(), expires_at: ts("2026-09-01T00:00:00Z"), status: QuoteStatus::Accepted, created_at: ts("2026-08-01T15:00:00Z"), lapsed_at: None, accepted_at: Some(ts("2026-08-01T16:00:00Z")), } + .with_commitment_hash() } #[test] @@ -275,7 +299,7 @@ mod tests { matches!(&calls[1], MockCall::CreateChannel { name, .. } if name.starts_with("proj-solana-priority")) ); // the buyer is added to the (private) workroom before the contract post - let expected_buyer = studio_buzz::pubkey_hex(&rfq().buyer_npub).unwrap(); + let expected_buyer = studio_buzz::pubkey_hex(rfq().buyer_npub.as_deref().unwrap()).unwrap(); assert!( matches!(&calls[2], MockCall::AddMember { channel_id, pubkey_hex } if *channel_id != ops && *pubkey_hex == expected_buyer)