feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+228 -25
View File
@@ -37,10 +37,12 @@ pub struct RestAdapter {
pub struct OutboundHttpPolicy {
allowed_hosts: Vec<String>,
denied_hosts: Vec<String>,
max_request_bytes: usize,
max_response_bytes: usize,
}
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
const DEFAULT_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
impl Default for RestAdapter {
fn default() -> Self {
@@ -152,12 +154,19 @@ impl RestAdapter {
.timeout(Duration::from_millis(request.timeout_ms));
if let Some(body) = &request.body {
enforce_request_body_limit(body, self.policy.max_request_bytes)?;
builder = builder.json(body);
}
if let Some(context) = trusted_context {
context.mark_dispatch_started();
}
let response = builder.send().await?;
let status = response.status();
let headers = normalize_headers(response.headers());
if status.is_redirection() {
return Err(RestAdapterError::RedirectNotAllowed);
}
let body = decode_body(response, self.policy.max_response_bytes).await?;
if !status.is_success() {
@@ -195,10 +204,13 @@ fn upstream_outcome(error: &RestAdapterError) -> UpstreamOutcome {
UpstreamOutcome::ServerError
}
RestAdapterError::UnexpectedStatus { .. } => UpstreamOutcome::UnexpectedStatus,
RestAdapterError::Transport(error) if error.is_timeout() => UpstreamOutcome::Timeout,
RestAdapterError::Transport(_) => UpstreamOutcome::TransportError,
RestAdapterError::Transport { timeout: true, .. } => UpstreamOutcome::Timeout,
RestAdapterError::Transport { .. } => UpstreamOutcome::TransportError,
RestAdapterError::ResponseTooLarge { .. } => UpstreamOutcome::ResponseTooLarge,
RestAdapterError::TargetNotAllowed { .. } => UpstreamOutcome::Rejected,
RestAdapterError::RequestTooLarge { .. } => UpstreamOutcome::InvalidRequest,
RestAdapterError::TargetNotAllowed { .. } | RestAdapterError::RedirectNotAllowed => {
UpstreamOutcome::Rejected
}
RestAdapterError::WindowExpired => UpstreamOutcome::WindowExpired,
RestAdapterError::InvalidSseEvent => UpstreamOutcome::InvalidResponse,
RestAdapterError::InvalidBaseUrl { .. }
@@ -215,6 +227,7 @@ impl Default for OutboundHttpPolicy {
Self {
allowed_hosts: Vec::new(),
denied_hosts: Vec::new(),
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
}
}
@@ -226,6 +239,25 @@ impl OutboundHttpPolicy {
denied_hosts: Vec<String>,
max_response_bytes: usize,
) -> Result<Self, RestAdapterError> {
Self::try_new_with_limits(
allowed_hosts,
denied_hosts,
DEFAULT_MAX_REQUEST_BYTES,
max_response_bytes,
)
}
pub fn try_new_with_limits(
allowed_hosts: Vec<String>,
denied_hosts: Vec<String>,
max_request_bytes: usize,
max_response_bytes: usize,
) -> Result<Self, RestAdapterError> {
if max_request_bytes == 0 {
return Err(RestAdapterError::InvalidConfiguration {
details: "outbound request limit must be greater than zero".to_owned(),
});
}
if max_response_bytes == 0 {
return Err(RestAdapterError::InvalidConfiguration {
details: "outbound response limit must be greater than zero".to_owned(),
@@ -234,6 +266,7 @@ impl OutboundHttpPolicy {
Ok(Self {
allowed_hosts: validate_host_patterns(allowed_hosts)?,
denied_hosts: validate_host_patterns(denied_hosts)?,
max_request_bytes,
max_response_bytes,
})
}
@@ -250,9 +283,14 @@ impl OutboundHttpPolicy {
self
}
pub fn with_max_request_bytes(mut self, max_request_bytes: usize) -> Self {
self.max_request_bytes = max_request_bytes;
self
}
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
url: base_url.to_owned(),
url: "url".to_owned(),
})?;
self.validate_url(&url)
}
@@ -263,18 +301,18 @@ impl OutboundHttpPolicy {
|| url.password().is_some()
{
return Err(RestAdapterError::TargetNotAllowed {
target: url.to_string(),
target: "url".to_owned(),
});
}
let host = url
.host_str()
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
target: url.to_string(),
target: "host".to_owned(),
})?;
self.validate_host(host)?;
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
return Err(RestAdapterError::TargetNotAllowed {
target: host.to_owned(),
target: "host".to_owned(),
});
}
if let Ok(address) = host.parse::<IpAddr>()
@@ -282,7 +320,7 @@ impl OutboundHttpPolicy {
&& !is_public_ip(address)
{
return Err(RestAdapterError::TargetNotAllowed {
target: host.to_owned(),
target: "ip".to_owned(),
});
}
Ok(())
@@ -290,16 +328,29 @@ impl OutboundHttpPolicy {
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
let host = normalize_host(host);
let denied = self
.denied_hosts
.iter()
.any(|pattern| host_matches(pattern, &host));
if denied {
return Err(RestAdapterError::TargetNotAllowed { target: host });
if self.is_denied_host(&host) {
return Err(RestAdapterError::TargetNotAllowed {
target: "host".to_owned(),
});
}
Ok(())
}
fn is_denied_host(&self, host: &str) -> bool {
let host = normalize_host(host);
self.denied_hosts
.iter()
.any(|pattern| host_matches(pattern, &host))
}
fn is_denied_ip(&self, address: IpAddr) -> bool {
ip_match_hosts(address).iter().any(|host| {
self.denied_hosts
.iter()
.any(|pattern| host_matches(pattern, host))
})
}
fn is_explicitly_allowed(&self, host: &str) -> bool {
let host = normalize_host(host);
self.allowed_hosts
@@ -326,12 +377,15 @@ impl Resolve for PolicyDnsResolver {
.await
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
let addresses = resolved
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
.filter(|address| {
!policy.is_denied_ip(address.ip())
&& (explicitly_allowed || is_public_ip(address.ip()))
})
.collect::<Vec<SocketAddr>>();
if addresses.is_empty() {
return Err(boxed_io_error(format!(
"outbound target {host} did not resolve to an allowed address"
)));
return Err(boxed_io_error(
"outbound target did not resolve to an allowed address".to_owned(),
));
}
Ok(Box::new(addresses.into_iter()) as Addrs)
})
@@ -396,6 +450,26 @@ fn is_public_ip(address: IpAddr) -> bool {
}
}
fn ip_match_hosts(address: IpAddr) -> Vec<String> {
match address {
IpAddr::V4(address) => vec![address.to_string()],
IpAddr::V6(address) => {
let mut hosts = vec![address.to_string()];
if let Some(mapped) = address.to_ipv4_mapped() {
hosts.push(mapped.to_string());
} else {
let segments = address.segments();
if segments[..6].iter().all(|segment| *segment == 0) {
let [a, b] = segments[6].to_be_bytes();
let [c, d] = segments[7].to_be_bytes();
hosts.push(Ipv4Addr::new(a, b, c, d).to_string());
}
}
hosts
}
}
}
fn is_public_ipv4(address: Ipv4Addr) -> bool {
let octets = address.octets();
!(address.is_private()
@@ -439,12 +513,12 @@ fn is_public_ipv6(address: Ipv6Addr) -> bool {
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
let base_url =
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
url: target.base_url.clone(),
url: "url".to_owned(),
})?;
let path = substitute_path_params(&target.path_template, &request.path_params);
let mut url = base_url.join(path.trim_start_matches('/')).map_err(|_| {
RestAdapterError::InvalidBaseUrl {
url: target.base_url.clone(),
url: "url".to_owned(),
}
})?;
@@ -475,24 +549,49 @@ fn build_headers(
let mut headers = HeaderMap::new();
for (name, value) in &target.static_headers {
insert_header(&mut headers, name, value)?;
insert_header(&mut headers, name, value, HeaderSource::StaticTarget)?;
}
for (name, value) in &request.headers {
insert_header(&mut headers, name, value)?;
let trusted = request
.trusted_header_names
.iter()
.any(|trusted| trusted.eq_ignore_ascii_case(name));
insert_header(
&mut headers,
name,
value,
HeaderSource::PreparedRequest { trusted },
)?;
}
Ok(headers)
}
fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), RestAdapterError> {
#[derive(Clone, Copy)]
enum HeaderSource {
StaticTarget,
PreparedRequest { trusted: bool },
}
fn insert_header(
headers: &mut HeaderMap,
name: &str,
value: &str,
source: HeaderSource,
) -> Result<(), RestAdapterError> {
let header_name =
HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName {
header: name.to_owned(),
})?;
if is_reserved_correlation_header(&header_name) {
if is_ignored_reserved_header(&header_name) {
return Ok(());
}
if is_forbidden_reserved_header(&header_name, source) {
return Err(RestAdapterError::InvalidHeaderName {
header: header_name.as_str().to_owned(),
});
}
let header_value =
HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue {
header: name.to_owned(),
@@ -502,7 +601,7 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
Ok(())
}
fn is_reserved_correlation_header(name: &HeaderName) -> bool {
fn is_ignored_reserved_header(name: &HeaderName) -> bool {
matches!(
name.as_str(),
"traceparent"
@@ -514,6 +613,22 @@ fn is_reserved_correlation_header(name: &HeaderName) -> bool {
)
}
fn is_forbidden_reserved_header(name: &HeaderName, source: HeaderSource) -> bool {
let framing = matches!(
name.as_str(),
"host" | "content-length" | "transfer-encoding" | "connection" | "upgrade"
);
let credential_or_safety = matches!(
name.as_str(),
"authorization" | "cookie" | "idempotency-key"
);
let untrusted_credential_or_safety = match source {
HeaderSource::StaticTarget => credential_or_safety,
HeaderSource::PreparedRequest { trusted } => credential_or_safety && !trusted,
};
framing || untrusted_credential_or_safety
}
fn set_span_parent_from_traceparent(span: &Span, traceparent: &str) -> bool {
let mut parts = traceparent.split('-');
let (Some("00"), Some(trace_id), Some(parent_id), Some(flags), None) = (
@@ -612,6 +727,59 @@ async fn decode_body(
}
}
fn enforce_request_body_limit(
body: &Value,
max_request_bytes: usize,
) -> Result<(), RestAdapterError> {
if max_request_bytes == 0 {
return Err(RestAdapterError::InvalidConfiguration {
details: "outbound request limit must be greater than zero".to_owned(),
});
}
let mut writer = LimitWriter::new(max_request_bytes);
let result = serde_json::to_writer(&mut writer, body);
if writer.exceeded {
return Err(RestAdapterError::RequestTooLarge {
limit_bytes: max_request_bytes,
});
}
result.map_err(|_| RestAdapterError::InvalidHeaderValue {
header: "body".to_owned(),
})?;
Ok(())
}
struct LimitWriter {
written: usize,
limit: usize,
exceeded: bool,
}
impl LimitWriter {
fn new(limit: usize) -> Self {
Self {
written: 0,
limit,
exceeded: false,
}
}
}
impl io::Write for LimitWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.written.saturating_add(buf.len()) > self.limit {
self.exceeded = true;
return Err(io::Error::other("request body exceeds configured limit"));
}
self.written += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
@@ -633,3 +801,38 @@ fn to_reqwest_method(method: HttpMethod) -> reqwest::Method {
HttpMethod::Delete => reqwest::Method::DELETE,
}
}
#[cfg(test)]
mod tests {
use super::{OutboundHttpPolicy, ip_match_hosts};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn denied_resolved_ip_overrides_explicitly_allowed_hostname() {
let policy = OutboundHttpPolicy::try_new_with_limits(
vec!["api.example.test".to_owned()],
vec!["169.254.169.254".to_owned()],
4 * 1024 * 1024,
4 * 1024 * 1024,
)
.unwrap();
assert!(policy.is_explicitly_allowed("api.example.test"));
assert!(policy.is_denied_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
}
#[test]
fn denied_resolved_ip_checks_ipv4_mapped_ipv6_alias() {
let policy = OutboundHttpPolicy::try_new_with_limits(
vec!["api.example.test".to_owned()],
vec!["127.0.0.1".to_owned()],
4 * 1024 * 1024,
4 * 1024 * 1024,
)
.unwrap();
let mapped = IpAddr::V6(Ipv6Addr::from(0xffff_7f00_0001u128));
assert!(ip_match_hosts(mapped).contains(&"127.0.0.1".to_owned()));
assert!(policy.is_denied_ip(mapped));
}
}
+15 -2
View File
@@ -13,14 +13,18 @@ pub enum RestAdapterError {
InvalidHeaderName { header: String },
#[error("invalid header value for {header}")]
InvalidHeaderValue { header: String },
#[error("outbound target is not allowed: {target}")]
#[error("outbound target is not allowed")]
TargetNotAllowed { target: String },
#[error("outbound redirects are not allowed")]
RedirectNotAllowed,
#[error("rest request exceeds the configured limit of {limit_bytes} bytes")]
RequestTooLarge { limit_bytes: usize },
#[error("rest response exceeds the configured limit of {limit_bytes} bytes")]
ResponseTooLarge { limit_bytes: usize },
#[error("invalid outbound HTTP configuration: {details}")]
InvalidConfiguration { details: String },
#[error("request failed")]
Transport(#[from] reqwest::Error),
Transport { timeout: bool, connect: bool },
#[error("sse collection window expired before stream completed")]
WindowExpired,
#[error("rest endpoint returned status {status}")]
@@ -28,3 +32,12 @@ pub enum RestAdapterError {
#[error("sse stream produced malformed event payload")]
InvalidSseEvent,
}
impl From<reqwest::Error> for RestAdapterError {
fn from(value: reqwest::Error) -> Self {
Self::Transport {
timeout: value.is_timeout(),
connect: value.is_connect(),
}
}
}
+40 -2
View File
@@ -4,7 +4,7 @@ mod model;
use async_trait::async_trait;
use crank_core::{
AdapterResponse, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter,
AdapterResponse, DispatchEvidence, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter,
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
};
@@ -33,6 +33,7 @@ impl ProtocolAdapter for RestAdapter {
path_params: prepared.path_params.clone(),
query_params: prepared.query_params.clone(),
headers: prepared.headers.clone(),
trusted_header_names: prepared.trusted_header_names.clone(),
body: prepared.body.clone(),
timeout_ms: prepared.timeout_ms,
};
@@ -55,6 +56,43 @@ fn rest_target(target: &Target) -> Result<&RestTarget, ProtocolAdapterError> {
impl From<RestAdapterError> for ProtocolAdapterError {
fn from(value: RestAdapterError) -> Self {
ProtocolAdapterError::Message(value.to_string())
match value {
RestAdapterError::InvalidBaseUrl { .. }
| RestAdapterError::InvalidConfiguration { .. } => Self::InvalidConfiguration,
RestAdapterError::InvalidPathParameter { .. }
| RestAdapterError::InvalidQueryParameter { .. }
| RestAdapterError::InvalidHeaderName { .. }
| RestAdapterError::InvalidHeaderValue { .. } => Self::InvalidPreparedRequest,
RestAdapterError::RedirectNotAllowed => Self::TargetRejected,
RestAdapterError::TargetNotAllowed { .. } => Self::TargetRejected,
RestAdapterError::RequestTooLarge { .. } => Self::RequestTooLarge,
RestAdapterError::ResponseTooLarge { .. } => Self::ResponseTooLarge {
dispatch: DispatchEvidence::MayHaveDispatched,
},
RestAdapterError::Transport { timeout, connect } if timeout => Self::Timeout {
dispatch: if connect {
DispatchEvidence::NotDispatched
} else {
DispatchEvidence::MayHaveDispatched
},
},
RestAdapterError::Transport { connect, .. } => Self::Transport {
dispatch: if connect {
DispatchEvidence::NotDispatched
} else {
DispatchEvidence::MayHaveDispatched
},
},
RestAdapterError::WindowExpired => Self::Timeout {
dispatch: DispatchEvidence::MayHaveDispatched,
},
RestAdapterError::UnexpectedStatus { status, .. } => Self::UnexpectedStatus {
status,
dispatch: DispatchEvidence::MayHaveDispatched,
},
RestAdapterError::InvalidSseEvent => Self::InvalidResponse {
dispatch: DispatchEvidence::MayHaveDispatched,
},
}
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -11,6 +11,8 @@ pub struct RestRequest {
pub query_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub trusted_header_names: BTreeSet<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
pub timeout_ms: u64,
@@ -1,3 +1,4 @@
mod integration {
mod client;
mod outbound_security;
}
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use axum::{
Json, Router,
@@ -9,7 +9,8 @@ use axum::{
};
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
use crank_core::{
HttpMethod, PreparedRequest, ProtocolAdapter, RestTarget, RuntimeRequestContext, Target,
DispatchEvidence, HttpMethod, PreparedRequest, ProtocolAdapter, ProtocolAdapterError,
RestTarget, RuntimeRequestContext, Target,
};
use opentelemetry::{
global,
@@ -38,6 +39,7 @@ async fn executes_rest_request_and_normalizes_json_response() {
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let response = adapter.execute(&target, &request).await.unwrap();
@@ -55,6 +57,77 @@ async fn executes_rest_request_and_normalizes_json_response() {
);
}
#[tokio::test]
async fn prepared_auth_profile_header_is_allowed_after_runtime_resolution() {
let base_url = spawn_test_server().await;
let adapter = test_adapter();
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/users/{user_id}".to_owned(),
static_headers: BTreeMap::new(),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::new(),
headers: BTreeMap::from([(
"authorization".to_owned(),
"Bearer resolved-auth-profile-token".to_owned(),
)]),
trusted_header_names: BTreeSet::from(["authorization".to_owned()]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
};
let response = adapter.execute(&target, &request).await.unwrap();
assert_eq!(
response.body["authorization"],
"Bearer resolved-auth-profile-token"
);
}
#[tokio::test]
async fn untrusted_prepared_credential_header_is_rejected_before_dispatch() {
let base_url = spawn_test_server().await;
let adapter = test_adapter();
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/users/{user_id}".to_owned(),
static_headers: BTreeMap::new(),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::new(),
headers: BTreeMap::from([(
"authorization".to_owned(),
"Bearer user-mapped-secret".to_owned(),
)]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let error = adapter.execute(&target, &request).await.unwrap_err();
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
assert!(!format!("{error:?} {error}").contains("user-mapped-secret"));
}
#[test]
fn transport_error_debug_is_redacted() {
let error = RestAdapterError::Transport {
timeout: false,
connect: true,
};
let rendered = format!("{error:?} {error}");
assert!(!rendered.contains("http://"));
assert!(!rendered.contains("story19-transport-canary"));
assert!(rendered.contains("Transport"));
}
#[tokio::test]
async fn protocol_context_overrides_mapped_correlation_headers() {
let base_url = spawn_test_server().await;
@@ -134,6 +207,7 @@ async fn current_trace_context_overrides_mapped_traceparent() {
)]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let response = test_adapter()
@@ -171,6 +245,7 @@ async fn user_configured_propagation_headers_are_removed_without_trusted_context
headers: BTreeMap::new(),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let response = test_adapter().execute(&target, &request).await.unwrap();
@@ -196,17 +271,54 @@ async fn returns_unexpected_status_with_normalized_body() {
headers: BTreeMap::new(),
body: None,
timeout_ms: 1_000,
..RestRequest::default()
};
let error = adapter.execute(&target, &request).await.unwrap_err();
assert!(matches!(
error,
&error,
RestAdapterError::UnexpectedStatus {
status: 502,
body: Value::Object(_)
}
));
assert!(matches!(
ProtocolAdapterError::from(error),
ProtocolAdapterError::UnexpectedStatus {
status: 502,
dispatch: DispatchEvidence::MayHaveDispatched,
}
));
}
#[tokio::test]
async fn connect_failure_is_known_not_dispatched() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let target = RestTarget {
base_url: format!("http://{address}"),
method: HttpMethod::Post,
path_template: "/write".to_owned(),
static_headers: BTreeMap::new(),
};
let request = RestRequest {
path_params: BTreeMap::new(),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: Some(json!({"value": 1})),
timeout_ms: 1_000,
..RestRequest::default()
};
let error = test_adapter().execute(&target, &request).await.unwrap_err();
assert!(matches!(
ProtocolAdapterError::from(error),
ProtocolAdapterError::Transport {
dispatch: DispatchEvidence::NotDispatched,
}
));
}
#[test]
@@ -244,10 +356,7 @@ async fn does_not_follow_redirects() {
.await
.unwrap_err();
assert!(matches!(
error,
RestAdapterError::UnexpectedStatus { status: 303, .. }
));
assert!(matches!(error, RestAdapterError::RedirectNotAllowed));
}
#[tokio::test]
@@ -281,6 +390,7 @@ fn empty_request() -> RestRequest {
headers: BTreeMap::new(),
body: None,
timeout_ms: 1_000,
..RestRequest::default()
}
}
@@ -334,6 +444,9 @@ async fn create_user(
.get("tracestate")
.and_then(|value| value.to_str().ok());
let baggage = headers.get("baggage").and_then(|value| value.to_str().ok());
let authorization = headers
.get("authorization")
.and_then(|value| value.to_str().ok());
let mut response = json!({
"id": user_id,
@@ -370,6 +483,12 @@ async fn create_user(
if let Some(baggage) = baggage {
response.insert("baggage".to_owned(), Value::String(baggage.to_owned()));
}
if let Some(authorization) = authorization {
response.insert(
"authorization".to_owned(),
Value::String(authorization.to_owned()),
);
}
Json(Value::Object(response.clone()))
}
@@ -0,0 +1,194 @@
use std::{
collections::BTreeMap,
net::SocketAddr,
process::Command,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
use axum::{
Json, Router,
extract::State,
http::StatusCode,
routing::{any, post},
};
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
use crank_core::{HttpMethod, ProtocolAdapterError, RestTarget};
use serde_json::{Value, json};
use tokio::net::TcpListener;
#[tokio::test]
async fn forbidden_credential_and_framing_headers_fail_before_bytes_leave() {
let observed_requests = Arc::new(AtomicUsize::new(0));
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
let adapter = RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]));
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/capture".to_owned(),
static_headers: BTreeMap::from([
(
"authorization".to_owned(),
"Bearer story19-static-secret".to_owned(),
),
("host".to_owned(), "metadata.internal".to_owned()),
]),
};
let error = adapter
.execute(&target, &json_request(json!({"value": "must-not-leave"})))
.await
.expect_err("forbidden headers must reject before dispatch");
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
let rendered = format!("{error:?} {error}");
assert!(!rendered.contains("story19-static-secret"));
assert!(!rendered.contains("metadata.internal"));
}
#[tokio::test]
async fn request_body_limit_fails_before_bytes_leave() {
let observed_requests = Arc::new(AtomicUsize::new(0));
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
let adapter = RestAdapter::with_policy(
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_request_bytes(16),
);
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/capture".to_owned(),
static_headers: BTreeMap::new(),
};
let error = adapter
.execute(
&target,
&json_request(json!({"payload": "story19-body-canary-that-exceeds-limit"})),
)
.await
.expect_err("oversized request body must fail before dispatch");
assert!(matches!(
error,
RestAdapterError::RequestTooLarge { limit_bytes: 16 }
));
let rendered = format!("{error:?} {error}");
assert_eq!(
ProtocolAdapterError::from(error),
ProtocolAdapterError::RequestTooLarge
);
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
assert!(!rendered.contains("story19-body-canary"));
}
#[tokio::test]
async fn proxy_environment_is_ignored_by_default() {
const CHILD_ENV: &str = "CRANK_OUTBOUND_PROXY_CHILD";
const TARGET_ENV: &str = "CRANK_OUTBOUND_PROXY_TARGET";
if std::env::var_os(CHILD_ENV).is_some() {
let target = std::env::var(TARGET_ENV).expect("target url passed by parent");
let adapter = RestAdapter::default();
let request = json_request(json!({"payload": "proxy-env-canary"}));
let rest_target = RestTarget {
base_url: target,
method: HttpMethod::Post,
path_template: "/capture".to_owned(),
static_headers: BTreeMap::new(),
};
let _ = adapter.execute(&rest_target, &request).await.expect_err(
"unresolvable target should fail locally instead of being sent through proxy env",
);
return;
}
let observed_requests = Arc::new(AtomicUsize::new(0));
let proxy_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
let current_exe = std::env::current_exe().expect("current test binary");
let status = Command::new(current_exe)
.arg("--exact")
.arg("integration::outbound_security::proxy_environment_is_ignored_by_default")
.arg("--nocapture")
.env(CHILD_ENV, "1")
.env(TARGET_ENV, "http://public.example.test/capture")
.env("HTTP_PROXY", &proxy_url)
.env("HTTPS_PROXY", &proxy_url)
.env("ALL_PROXY", &proxy_url)
.status()
.expect("spawn child proxy regression");
assert!(status.success());
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
}
#[test]
fn rejected_target_diagnostic_is_redacted() {
let error = OutboundHttpPolicy::default()
.validate_base_url("http://user:story19-url-secret@127.0.0.1:8080/private")
.expect_err("userinfo and private target must be rejected");
let rendered = format!("{error:?} {error}");
assert!(!rendered.contains("story19-url-secret"));
assert!(!rendered.contains("127.0.0.1"));
assert!(!rendered.contains("/private"));
}
#[test]
fn deny_rule_wins_over_explicit_allow() {
let policy = OutboundHttpPolicy::try_new(
vec!["api.example.test".to_owned()],
vec!["api.example.test".to_owned()],
4 * 1024 * 1024,
)
.expect("valid policy");
let error = policy
.validate_base_url("https://api.example.test/users")
.expect_err("deny must override allow");
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
}
fn json_request(body: Value) -> RestRequest {
RestRequest {
path_params: BTreeMap::new(),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: Some(body),
timeout_ms: 1_000,
..RestRequest::default()
}
}
async fn spawn_counting_server(observed_requests: Arc<AtomicUsize>) -> String {
let app = Router::new()
.route("/capture", post(capture))
.fallback(any(capture_any))
.with_state(observed_requests);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
});
format!("http://{address}")
}
async fn capture(State(observed_requests): State<Arc<AtomicUsize>>) -> Json<Value> {
observed_requests.fetch_add(1, Ordering::SeqCst);
Json(json!({"ok": true}))
}
async fn capture_any(State(observed_requests): State<Arc<AtomicUsize>>) -> StatusCode {
observed_requests.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
+6 -2
View File
@@ -2,8 +2,9 @@
use argon2::{Algorithm, Params, Version};
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
};
use rand::RngExt;
#[derive(Debug, thiserror::Error)]
pub enum HashPasswordError {
@@ -14,7 +15,10 @@ pub enum HashPasswordError {
}
pub fn hash_password(password: &str, pepper: &str) -> Result<String, HashPasswordError> {
let salt = SaltString::generate(&mut OsRng);
let mut salt_bytes = [0u8; 16];
rand::rng().fill(&mut salt_bytes);
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|error| HashPasswordError::Hash(error.to_string()))?;
let password = format!("{password}{pepper}");
password_hasher()?
.hash_password(password.as_bytes(), &salt)
+2 -1
View File
@@ -6,5 +6,6 @@ pub use hashing::{HashPasswordError, hash_password, verify_password};
pub use password_provider::PasswordIdentityProvider;
pub use session_cookie::{
SESSION_COOKIE_NAME, SessionCookie, SessionCookieError, cleared_session_cookie,
create_session_cookie, extract_session_token, hash_session_secret, session_cookie,
create_csrf_token, create_session_cookie, extract_session_token, hash_csrf_token,
hash_session_secret, session_cookie,
};
@@ -5,7 +5,7 @@ use crank_core::{
use crank_registry::PostgresRegistry;
use tracing::debug;
use crate::hashing::verify_password;
use crate::hashing::{hash_password, verify_password};
#[derive(Clone)]
pub struct PasswordIdentityProvider {
@@ -36,15 +36,23 @@ impl IdentityProvider for PasswordIdentityProvider {
&self,
payload: crank_core::LoginPayload,
) -> Result<LoginOutcome, IdentityError> {
let user = self
let Some(user) = self
.registry
.get_auth_user_by_email(&payload.email)
.await
.map_err(|error| IdentityError::Internal(error.to_string()))?
.ok_or(IdentityError::BadCredentials)?;
else {
let _ = hash_password(&payload.password, &self.password_pepper);
return Err(IdentityError::BadCredentials);
};
if user.user.status != crank_core::UserStatus::Active {
return Err(IdentityError::AccountDisabled);
let _ = verify_password(
&payload.password,
&self.password_pepper,
&user.password_hash,
);
return Err(IdentityError::BadCredentials);
}
if !verify_password(
@@ -40,11 +40,36 @@ pub fn hash_session_secret(
session_id: &UserSessionId,
session_value: &str,
session_secret: &str,
) -> String {
hash_scoped_secret("session", session_id, session_value, session_secret)
}
pub fn create_csrf_token() -> String {
let mut secret_bytes = [0_u8; 32];
rand::rng().fill(&mut secret_bytes);
URL_SAFE_NO_PAD.encode(secret_bytes)
}
pub fn hash_csrf_token(
session_id: &UserSessionId,
csrf_token: &str,
session_secret: &str,
) -> String {
hash_scoped_secret("csrf", session_id, csrf_token, session_secret)
}
fn hash_scoped_secret(
scope: &str,
session_id: &UserSessionId,
secret_value: &str,
session_secret: &str,
) -> String {
let mut digest = Sha256::new();
digest.update(scope.as_bytes());
digest.update(b":");
digest.update(session_id.as_str().as_bytes());
digest.update(b":");
digest.update(session_value.as_bytes());
digest.update(secret_value.as_bytes());
digest.update(b":");
digest.update(session_secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest.finalize())
@@ -83,8 +108,8 @@ mod tests {
use axum_extra::extract::cookie::CookieJar;
use super::{
SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token,
session_cookie,
SESSION_COOKIE_NAME, cleared_session_cookie, create_csrf_token, create_session_cookie,
extract_session_token, hash_csrf_token, hash_session_secret, session_cookie,
};
#[test]
@@ -113,4 +138,15 @@ mod tests {
assert!(session.session_id.as_str().starts_with("sess_"));
assert!(session.value.contains('.'));
}
#[test]
fn csrf_hash_is_scoped_from_session_hash() {
let session = create_session_cookie(24).unwrap();
let csrf = create_csrf_token();
assert_eq!(csrf.len(), 43);
assert_ne!(
hash_csrf_token(&session.session_id, &csrf, "secret"),
hash_session_secret(&session.session_id, &csrf, "secret")
);
}
}
+361 -21
View File
@@ -1,7 +1,10 @@
use std::sync::Arc;
use axum::{
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
http::{
HeaderMap, StatusCode,
header::{AUTHORIZATION, ORIGIN},
},
response::{IntoResponse, Response},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -43,11 +46,14 @@ pub(super) async fn require_machine_access(
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<VerifiedMachineCredential, MachineAccessError> {
let secret =
bearer_token(headers).ok_or(MachineAccessError::Denied(StatusCode::UNAUTHORIZED))?;
let secret = bearer_token(headers).ok_or_else(|| {
record_machine_access_denial("missing_or_ambiguous_bearer", None);
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
})?;
let credential = resolve_machine_credential(state, path, secret).await?;
if !allows_scope(&credential.scopes, required_scope) {
record_machine_access_denial("scope", credential.platform_api_key_id.as_ref());
return Err(MachineAccessError::Denied(StatusCode::FORBIDDEN));
}
@@ -60,7 +66,10 @@ pub(super) async fn require_approval_access(
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> {
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?;
let secret = bearer_token(headers).ok_or_else(|| {
record_unknown_approval_access_denial("missing_or_ambiguous_bearer");
StatusCode::UNAUTHORIZED
})?;
let secret_hash = hash_access_secret(secret);
let Some(api_key) = observe_db_query(
DbOperation::MachineAccessRead,
@@ -75,10 +84,16 @@ pub(super) async fn require_approval_access(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
else {
record_unknown_approval_access_denial("unknown_or_inactive");
return Err(StatusCode::UNAUTHORIZED);
};
if !approval_allows_scope(&api_key.api_key.scopes, required_scope) {
record_approval_access_denial(&api_key, "scope");
return Err(StatusCode::FORBIDDEN);
}
if !approval_allows_origin(&api_key.api_key.allowed_origins, headers) {
record_approval_access_denial(&api_key, "origin");
return Err(StatusCode::FORBIDDEN);
}
@@ -92,13 +107,20 @@ pub(super) async fn require_approval_access(
),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|error| match error {
crank_registry::RegistryError::PlatformApiKeyInactive { .. } => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
})?;
Ok(api_key)
}
pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
let mut values = headers.get_all(AUTHORIZATION).iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some() {
return None;
}
let (scheme, token) = value.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("Bearer") || token.is_empty() {
return None;
@@ -114,18 +136,6 @@ pub(super) fn credential_allows_security_level(
security_level_rank(credential.max_security_level) >= security_level_rank(required_level)
}
pub(super) fn serialize_security_level(level: OperationSecurityLevel) -> &'static str {
match level {
OperationSecurityLevel::Standard => "standard",
}
}
pub(super) fn serialize_machine_access_mode(mode: crank_core::MachineAccessMode) -> &'static str {
match mode {
crank_core::MachineAccessMode::StaticAgentKey => "static_agent_key",
}
}
pub(super) fn hash_access_secret(secret: &str) -> String {
let digest = Sha256::digest(secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
@@ -145,7 +155,10 @@ async fn resolve_machine_credential(
.verify_bearer_token(&path.workspace_slug, &path.agent_slug, token)
.await
.map_err(|_| MachineAccessError::Unavailable)?
.ok_or(MachineAccessError::Denied(StatusCode::UNAUTHORIZED))
.ok_or_else(|| {
record_machine_access_denial("unknown_or_inactive", None);
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
})
}
async fn verify_static_agent_key(
@@ -164,7 +177,13 @@ async fn verify_static_agent_key(
)
.instrument(read_span.clone())
.await
.map_err(|_| MachineAccessError::Unavailable);
.map_err(|error| match error {
crank_registry::RegistryError::PlatformApiKeyInactive { .. } => {
record_machine_access_denial("unknown_or_inactive", None);
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
}
_ => MachineAccessError::Unavailable,
});
let api_key = match api_key_result {
Ok(api_key) => {
StageOutcome::Success.record(&read_span);
@@ -189,7 +208,13 @@ async fn verify_static_agent_key(
.touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at)
.instrument(touch_span.clone())
.await
.map_err(|_| MachineAccessError::Unavailable);
.map_err(|error| match error {
crank_registry::RegistryError::PlatformApiKeyInactive { .. } => {
record_machine_access_denial("unknown_or_inactive", None);
MachineAccessError::Denied(StatusCode::UNAUTHORIZED)
}
_ => MachineAccessError::Unavailable,
});
match touch_result {
Ok(()) => {
StageOutcome::Success.record(&touch_span);
@@ -207,9 +232,46 @@ async fn verify_static_agent_key(
machine_access_mode: crank_core::MachineAccessMode::StaticAgentKey,
max_security_level: crank_core::OperationSecurityLevel::Standard,
scopes: api_key.api_key.scopes,
platform_api_key_id: Some(api_key.api_key.id),
}))
}
fn record_machine_access_denial(
reason: &'static str,
platform_api_key_id: Option<&crank_core::PlatformApiKeyId>,
) {
let (request_id, trace_id) = crank_observability::current_request_correlation();
let credential_ref = platform_api_key_id
.map(crank_core::PlatformApiKeyId::as_str)
.unwrap_or("unknown");
tracing::warn!(
target: "crank::audit",
action = "credential.platform_api_key.access_denied",
credential_type = "platform_api_key.mcp_client",
credential_ref = credential_ref,
outcome = "denied",
reason = reason,
request_id = request_id.as_deref().unwrap_or(""),
trace_id = trace_id.as_deref().unwrap_or(""),
"mcp platform key access denied"
);
}
fn record_unknown_approval_access_denial(reason: &'static str) {
let (request_id, trace_id) = crank_observability::current_request_correlation();
tracing::warn!(
target: "crank::audit",
action = "credential.platform_api_key.access_denied",
credential_type = "platform_api_key.approval",
credential_ref = "unknown",
outcome = "denied",
reason = reason,
request_id = request_id.as_deref().unwrap_or(""),
trace_id = trace_id.as_deref().unwrap_or(""),
"approval platform key access denied"
);
}
fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeyScope) -> bool {
match required_scope {
PlatformApiKeyScope::Read => scopes.iter().any(|scope| {
@@ -249,8 +311,286 @@ fn approval_allows_scope(
})
}
fn approval_allows_origin(allowed_origins: &[String], headers: &HeaderMap) -> bool {
if allowed_origins.is_empty() {
return true;
}
let mut origins = headers.get_all(ORIGIN).iter();
let Some(origin) = origins.next() else {
return true;
};
if origins.next().is_some() {
return false;
};
let Ok(origin) = origin.to_str() else {
return false;
};
allowed_origins
.iter()
.any(|allowed_origin| allowed_origin == origin)
}
fn record_approval_access_denial(
api_key: &crank_registry::PlatformApiKeyRecord,
reason: &'static str,
) {
let (request_id, trace_id) = crank_observability::current_request_correlation();
let agent_id = api_key
.api_key
.agent_id
.as_ref()
.map(|id| id.as_str())
.unwrap_or("");
tracing::warn!(
target: "crank::audit",
action = "credential.platform_api_key.access_denied",
credential_type = "platform_api_key.approval",
credential_ref = %api_key.api_key.id,
workspace_id = %api_key.api_key.workspace_id,
agent_id = agent_id,
outcome = "denied",
reason = reason,
request_id = request_id.as_deref().unwrap_or(""),
trace_id = trace_id.as_deref().unwrap_or(""),
"approval platform key access denied"
);
}
fn security_level_rank(level: OperationSecurityLevel) -> u8 {
match level {
OperationSecurityLevel::Standard => 0,
}
}
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
};
use axum::http::{
HeaderMap, HeaderValue,
header::{AUTHORIZATION, ORIGIN},
};
use crank_core::{
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, WorkspaceId,
};
use crank_registry::PlatformApiKeyRecord;
use time::OffsetDateTime;
use tracing::{
Event, Id, Metadata, Subscriber,
field::{Field, Visit},
span::{Attributes, Record},
};
use super::{
approval_allows_origin, bearer_token, record_approval_access_denial,
record_machine_access_denial,
};
#[test]
fn approval_origin_policy_allows_non_browser_clients_without_origin() {
let allowed_origins = vec!["https://allowed.example.test".to_owned()];
let headers = HeaderMap::new();
assert!(approval_allows_origin(&allowed_origins, &headers));
}
#[test]
fn approval_origin_policy_rejects_ambiguous_or_malformed_origin() {
let allowed_origins = vec!["https://allowed.example.test".to_owned()];
let mut duplicate = HeaderMap::new();
duplicate.append(
ORIGIN,
HeaderValue::from_static("https://allowed.example.test"),
);
duplicate.append(
ORIGIN,
HeaderValue::from_static("https://evil.example.test"),
);
assert!(!approval_allows_origin(&allowed_origins, &duplicate));
let mut malformed = HeaderMap::new();
malformed.insert(ORIGIN, HeaderValue::from_bytes(b"\xff").unwrap());
assert!(!approval_allows_origin(&allowed_origins, &malformed));
}
#[test]
fn bearer_token_rejects_duplicate_authorization_headers() {
let mut headers = HeaderMap::new();
headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer first"));
headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer second"));
assert_eq!(bearer_token(&headers), None);
}
#[tokio::test]
async fn machine_access_denial_audit_uses_known_key_id_only_when_resolved() {
let events = Arc::new(Mutex::new(Vec::new()));
let subscriber = CapturingSubscriber {
events: Arc::clone(&events),
};
let dispatch = tracing::Dispatch::new(subscriber);
let _guard = tracing::dispatcher::set_default(&dispatch);
let key_id = PlatformApiKeyId::new("pk_mcp_scope_audit");
crank_observability::with_request_correlation(
"req_mcp_scope_audit".to_owned(),
"0af7651916cd43dd8448eb211c80319c".to_owned(),
async {
record_machine_access_denial("scope", Some(&key_id));
record_machine_access_denial("unknown_or_inactive", None);
},
)
.await;
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
assert_eq!(
events[0].get("credential_ref").map(String::as_str),
Some("pk_mcp_scope_audit")
);
assert_eq!(events[0].get("reason").map(String::as_str), Some("scope"));
assert_eq!(
events[1].get("credential_ref").map(String::as_str),
Some("unknown")
);
assert_eq!(
events[1].get("reason").map(String::as_str),
Some("unknown_or_inactive")
);
for event in events.iter() {
assert_eq!(
event.get("credential_type").map(String::as_str),
Some("platform_api_key.mcp_client")
);
assert_eq!(event.get("outcome").map(String::as_str), Some("denied"));
assert_eq!(
event.get("request_id").map(String::as_str),
Some("req_mcp_scope_audit")
);
assert_eq!(
event.get("trace_id").map(String::as_str),
Some("0af7651916cd43dd8448eb211c80319c")
);
}
}
#[tokio::test]
async fn known_approval_access_denial_emits_bounded_audit_event() {
let events = Arc::new(Mutex::new(Vec::new()));
let subscriber = CapturingSubscriber {
events: Arc::clone(&events),
};
let dispatch = tracing::Dispatch::new(subscriber);
let _guard = tracing::dispatcher::set_default(&dispatch);
let record = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new("pk_approval_audit"),
workspace_id: WorkspaceId::new("ws_audit"),
agent_id: Some(AgentId::new("agent_audit")),
key_kind: PlatformApiKeyKind::Approval,
name: "approval-audit".to_owned(),
prefix: "crk_appr_audit".to_owned(),
scopes: vec![PlatformApiKeyScope::ReadPending],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: vec!["https://allowed.example.test".to_owned()],
},
};
crank_observability::with_request_correlation(
"req_approval_audit".to_owned(),
"0af7651916cd43dd8448eb211c80319c".to_owned(),
async {
record_approval_access_denial(&record, "origin");
},
)
.await;
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(
event.get("action").map(String::as_str),
Some("credential.platform_api_key.access_denied")
);
assert_eq!(
event.get("credential_type").map(String::as_str),
Some("platform_api_key.approval")
);
assert_eq!(
event.get("credential_ref").map(String::as_str),
Some("pk_approval_audit")
);
assert_eq!(event.get("outcome").map(String::as_str), Some("denied"));
assert_eq!(event.get("reason").map(String::as_str), Some("origin"));
assert_eq!(
event.get("request_id").map(String::as_str),
Some("req_approval_audit")
);
assert_eq!(
event.get("trace_id").map(String::as_str),
Some("0af7651916cd43dd8448eb211c80319c")
);
let serialized = format!("{event:?}");
assert!(!serialized.contains("secret"));
assert!(!serialized.contains("allowed.example.test"));
}
struct CapturingSubscriber {
events: Arc<Mutex<Vec<BTreeMap<String, String>>>>,
}
impl Subscriber for CapturingSubscriber {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.target() == "crank::audit"
}
fn new_span(&self, _span: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _span: &Id, _values: &Record<'_>) {}
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
fn event(&self, event: &Event<'_>) {
if event.metadata().target() != "crank::audit" {
return;
}
let mut visitor = FieldCaptureVisitor::default();
event.record(&mut visitor);
self.events.lock().unwrap().push(visitor.fields);
}
fn enter(&self, _span: &Id) {}
fn exit(&self, _span: &Id) {}
}
#[derive(Default)]
struct FieldCaptureVisitor {
fields: BTreeMap<String, String>,
}
impl Visit for FieldCaptureVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.fields
.insert(field.name().to_owned(), value.to_owned());
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.fields
.insert(field.name().to_owned(), format!("{value:?}"));
}
}
}
+195 -361
View File
@@ -13,32 +13,29 @@ use axum::{
routing::{get, post},
};
use crank_core::{
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
CorrelationContext, InvocationLevel, InvocationSource, InvocationStatus, OperationApprovalMode,
PlatformApiKeyScope, SecretId,
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
CorrelationContext, ExecutionOrigin, InvocationLevel, InvocationSource, InvocationStatus,
PlatformApiKeyScope, SecretId, SecretStatus,
};
use crank_registry::{
CreateApprovalRequest, DecideApprovalRequest, ExpireApprovalRequest, PostgresRegistry,
ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, PostgresRegistry,
PublishedAgentTool,
};
use crank_runtime::{
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
RuntimeOperation, RuntimeRequestContext, SecretCrypto,
ExecutionAuthorization, RequestRateLimiter, ResolvedAuth, RuntimeError,
RuntimeExecutionRequest, RuntimeExecutor, RuntimeRequestContext, SecretCrypto,
};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use futures_util::stream;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_json::{Value, json};
use time::OffsetDateTime;
use tokio::sync::Semaphore;
use tracing::{Instrument, info};
use tracing::{Instrument, info, warn};
use crate::{
access::{
credential_allows_security_level, serialize_machine_access_mode, serialize_security_level,
},
access::credential_allows_security_level,
approval_execution::{execute_approved_request, spawn_approval_recovery},
approval_response::approval_required_response,
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
catalog::PublishedToolCatalog,
jsonrpc::{
@@ -49,10 +46,7 @@ use crate::{
rate_limit::{rate_limited_jsonrpc_response, rate_limited_status_response},
request_context::{RequestContext, apply_request_context},
session::{ActiveSessionMetrics, SessionState, SharedSessionStore, spawn_session_cleanup},
tool_error::{
ToolErrorContract, generic_tool_error_contract, runtime_error_code,
tool_error_contract_from_runtime, tool_error_text, tool_error_value,
},
tool_error::{execution_locale, tool_error_contract_from_failure},
tool_search::handle_catalog_tool_call,
transport::{
AllowedOrigins, ResponseMode, json_response, negotiate_post_response_mode,
@@ -61,17 +55,30 @@ use crate::{
with_request_id_header,
},
};
mod approval_policy;
mod invocation_history;
mod metrics;
mod request;
mod response;
mod stages;
mod tool_resolution;
use self::approval_policy::{
ApprovalPolicyContext, ApprovalPolicyResult, maybe_handle_approval_policy,
};
use self::metrics::{ActiveStreamGuard, McpRequestMetrics};
use self::request::{
ApprovalDecisionPayload, InitializeParams, ToolCallParams, ToolsListParams, paginate_tools_list,
};
pub(super) use self::response::{
success_tool_response, take_confirmation_token, tool_error_response,
};
use self::stages::{
enforce_traced_rate_limit, require_traced_approval_access, require_traced_machine_access,
};
pub(super) use self::tool_resolution::{resolve_generated_tool, runtime_operation};
#[cfg(test)]
use invocation_history::observe_invocation_history_outcome;
use invocation_history::persist_invocation_for_key;
pub(super) use invocation_history::{InvocationRecord, persist_invocation};
const TRANSPORT_SESSION_TTL_MS: u64 = 86_400_000;
@@ -92,28 +99,6 @@ pub(super) struct AppState {
allowed_origins: AllowedOrigins,
}
#[derive(Debug, Serialize, Deserialize)]
struct InitializeParams {
#[serde(rename = "protocolVersion")]
protocol_version: String,
#[serde(default)]
capabilities: Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct ToolCallParams {
name: String,
#[serde(default)]
arguments: Value,
}
#[derive(Debug, Deserialize)]
struct ApprovalDecisionPayload {
approve: String,
#[serde(default)]
note: Option<String>,
}
#[derive(Clone)]
pub(super) struct ResolvedToolCall {
tool: PublishedAgentTool,
@@ -123,6 +108,7 @@ struct ToolCallExecution {
tool: PublishedAgentTool,
arguments: Value,
confirmation_token: Option<String>,
platform_api_key_id: Option<crank_core::PlatformApiKeyId>,
}
#[derive(Clone, Debug, Deserialize)]
@@ -346,7 +332,10 @@ async fn list_pending_approvals(
)
.await
{
Ok(items) => Json(json!({ "items": items })).into_response(),
Ok(items) => Json(json!({
"items": items.into_iter().map(safe_approval_record).collect::<Vec<_>>()
}))
.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
@@ -461,6 +450,21 @@ async fn decide_approval_request(
};
let approval_id = ApprovalRequestId::new(path.approval_id);
let expected_record = match observe_db_query(
DbOperation::ApprovalRead,
state.registry.get_approval_request_for_agent(
&key.api_key.workspace_id,
agent_id,
&approval_id,
),
)
.await
{
Ok(Some(record)) => record,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
match observe_db_query(
DbOperation::ApprovalWrite,
state
@@ -469,9 +473,12 @@ async fn decide_approval_request(
workspace_id: &key.api_key.workspace_id,
agent_id,
approval_id: &approval_id,
operation_id: &expected_record.approval.operation_id,
operation_version: expected_record.approval.operation_version,
request_payload: &expected_record.approval.request_payload,
status,
decided_at: OffsetDateTime::now_utc(),
decided_by_key_id: &key.api_key.id,
decided_by_key_id: Some(&key.api_key.id),
response_payload: Some(json!({ "approve": payload.approve })),
decision_note: payload.note.as_deref(),
}),
@@ -502,11 +509,11 @@ async fn decide_approval_request(
)
.await
{
Ok(record) => Json(json!(record)).into_response(),
Ok(record) => Json(json!(safe_approval_record(record))).into_response(),
Err(response) => response,
}
}
Ok(Some(record)) => Json(json!(record)).into_response(),
Ok(Some(record)) => Json(json!(safe_approval_record(record))).into_response(),
Ok(None) => {
terminal_decision_response(&state, &key.api_key.workspace_id, agent_id, &approval_id)
.await
@@ -535,7 +542,7 @@ async fn approval_record_response(
{
expire_approval_response(state, workspace_id, agent_id, approval_id).await
}
Ok(Some(record)) => Json(json!(record)).into_response(),
Ok(Some(record)) => Json(json!(safe_approval_record(record))).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
@@ -570,7 +577,7 @@ async fn terminal_decision_response(
| ApprovalRequestStatus::Expired
) =>
{
Json(json!(record)).into_response()
Json(json!(safe_approval_record(record))).into_response()
}
Ok(Some(_)) => StatusCode::CONFLICT.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
@@ -578,6 +585,17 @@ async fn terminal_decision_response(
}
}
fn safe_approval_record(mut record: ApprovalRequestRecord) -> ApprovalRequestRecord {
record.approval.request_payload =
crank_core::sanitize_invocation_preview(&record.approval.request_payload);
record.approval.response_payload = record
.approval
.response_payload
.as_ref()
.map(crank_core::sanitize_invocation_preview);
record
}
async fn expire_approval_response(
state: &Arc<AppState>,
workspace_id: &crank_core::WorkspaceId,
@@ -597,7 +615,7 @@ async fn expire_approval_response(
)
.await
{
Ok(Some(record)) => Json(json!(record)).into_response(),
Ok(Some(record)) => Json(json!(safe_approval_record(record))).into_response(),
Ok(None) => StatusCode::CONFLICT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
@@ -843,10 +861,39 @@ async fn mcp_post_response(
{
Ok(catalog) => {
let definitions = catalog_tool_definitions(&catalog);
let list_params: ToolsListParams = match serde_json::from_value(params(message))
{
Ok(value) => value,
Err(_) => {
return transport_response(
StatusCode::OK,
jsonrpc_error(
request_id(message),
-32602,
"invalid tools/list parameters",
),
response_mode,
None,
Some(&session.protocol_version),
);
}
};
let page = match paginate_tools_list(definitions, list_params) {
Ok(page) => page,
Err(message_text) => {
return transport_response(
StatusCode::OK,
jsonrpc_error(request_id(message), -32602, message_text),
response_mode,
None,
Some(&session.protocol_version),
);
}
};
transport_response(
StatusCode::OK,
jsonrpc_result(request_id(message), json!({ "tools": definitions })),
jsonrpc_result(request_id(message), page),
response_mode,
None,
Some(&session.protocol_version),
@@ -940,22 +987,38 @@ pub(super) async fn handle_tool_call(
) -> Response {
let transport_request_id = transport_correlation.request_id().as_str();
if !credential_allows_security_level(credential, resolved.tool.operation.security_level) {
let failure = crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::AuthorizationDenied,
transport_correlation.clone(),
);
persist_invocation_for_key(
&state,
&resolved.tool,
credential.platform_api_key_id.as_ref(),
InvocationRecord {
request_id: Some(transport_request_id),
trace_id: Some(transport_correlation.trace_id().as_str()),
tool_name: &resolved.tool.tool_name,
status: InvocationStatus::Error,
level: InvocationLevel::Warn,
message: failure.error_code().as_str(),
status_code: None,
error_kind: Some(failure.error_code().as_str()),
execution_stage: Some(failure.stage()),
execution_error_code: Some(failure.error_code()),
retryability: Some(failure.retryability()),
outcome_certainty: Some(failure.outcome_certainty()),
duration: Duration::ZERO,
request_preview: crank_core::sanitize_invocation_preview(&arguments),
response_preview: Value::Null,
},
)
.await;
return tool_error_response(
message,
response_mode,
&session.protocol_version,
generic_tool_error_contract(
"machine_access_insufficient",
format!(
"machine access mode {} does not satisfy {} operation security",
serialize_machine_access_mode(credential.machine_access_mode),
serialize_security_level(resolved.tool.operation.security_level),
),
transport_request_id,
transport_correlation.trace_id().as_str(),
false,
Some("Используйте ключ агента с достаточным уровнем доступа."),
),
tool_error_contract_from_failure(&failure, execution_locale(message)),
);
}
@@ -968,6 +1031,7 @@ pub(super) async fn handle_tool_call(
tool: resolved.tool,
arguments,
confirmation_token,
platform_api_key_id: credential.platform_api_key_id.clone(),
},
transport_correlation,
)
@@ -1016,10 +1080,7 @@ async fn resolve_runtime_auth_for_task(
registry.get_auth_profile(workspace_id, auth_profile_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load auth profile",
details: error.to_string(),
})?
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?
.ok_or_else(|| RuntimeError::MissingAuthProfile {
auth_profile_id: auth_profile_id.as_str().to_owned(),
})?;
@@ -1044,39 +1105,47 @@ async fn resolve_auth_profile(
registry.get_secret(workspace_id, secret_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load secret",
details: error.to_string(),
})?
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?
.ok_or_else(|| RuntimeError::MissingSecret {
secret_id: secret_id.as_str().to_owned(),
})?;
if secret.secret.status != SecretStatus::Active {
return Err(RuntimeError::InvalidAuthSecretValue {
secret_id: secret_id.as_str().to_owned(),
reason: "secret is not active".to_owned(),
});
}
let version = observe_db_query(
DbOperation::SecretRead,
registry.get_current_secret_version(workspace_id, secret_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load current secret version",
details: error.to_string(),
})?
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?
.ok_or_else(|| RuntimeError::MissingSecretVersion {
secret_id: secret_id.as_str().to_owned(),
version: secret.secret.current_version,
})?;
let plaintext = secret_crypto.decrypt(
let plaintext = secret_crypto.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)?;
observe_db_query(
if observe_db_query(
DbOperation::SecretTouch,
registry.touch_secret(workspace_id, secret_id, &used_at),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "touch secret",
details: error.to_string(),
})?;
.is_err()
{
warn!(
target: "crank::audit",
action = "credential.secret.touch_failed",
credential_type = "secret",
credential_ref = %secret_id.as_str(),
outcome = "metadata_touch_failed",
"secret last-used metadata was not updated"
);
}
secrets.insert(SecretId::new(secret_id.as_str()), plaintext);
}
@@ -1092,6 +1161,7 @@ async fn handle_base_tool_call(
transport_correlation: &CorrelationContext,
) -> Response {
let transport_request_id = transport_correlation.request_id().as_str();
let platform_api_key_id = execution.platform_api_key_id;
let tool = execution.tool;
let arguments = execution.arguments;
let operation = runtime_operation(&tool);
@@ -1103,15 +1173,16 @@ async fn handle_base_tool_call(
.is_some_and(|policy| policy.required)
{
let approval_span = Stage::ApprovalCheck.span();
let response = maybe_handle_approval_policy(
&state,
let response = maybe_handle_approval_policy(ApprovalPolicyContext {
state: &state,
session,
message,
response_mode,
&tool,
&arguments,
tool: &tool,
arguments: &arguments,
platform_api_key_id: platform_api_key_id.as_ref(),
transport_correlation,
)
})
.instrument(approval_span.clone())
.await;
if let Some(result) = response {
@@ -1144,30 +1215,45 @@ async fn handle_base_tool_call(
if let Some(token) = execution.confirmation_token {
runtime_request_context = runtime_request_context.with_confirmation_token(token);
}
let request_preview = build_request_preview(&state.runtime, &operation, &arguments);
let started_at = Instant::now();
let resolved_auth =
resolve_operation_auth(&state, &tool.workspace_id, &operation.execution_config).await;
let result = match resolved_auth {
Ok(resolved_auth) => {
state
.runtime
.execute_request(
RuntimeExecutionRequest::new(&operation, &arguments)
.with_optional_auth(resolved_auth.as_ref())
.with_context(&runtime_request_context),
)
.await
match RuntimeExecutionRequest::try_new(
&tool.workspace_id,
ExecutionOrigin::AgentSnapshot,
Some(&tool.agent_id),
&operation,
&arguments,
ExecutionAuthorization::Authorized,
resolved_auth.as_ref(),
&runtime_request_context,
Instant::now()
+ Duration::from_millis(operation.execution_config.timeout_ms.max(1)),
) {
Ok(request) => state.runtime.execute_outcome(request).await,
Err(_) => Err(crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::RuntimeInternal,
transport_correlation.clone(),
)),
}
}
Err(error) => Err(error),
Err(error) => Err(crank_runtime::normalize_runtime_error(
&error,
transport_correlation,
)),
};
match result {
Ok(output) => {
persist_invocation(
Ok(success) => {
let request_preview = success.request_preview;
let output = success.output;
persist_invocation_for_key(
&state,
&tool,
platform_api_key_id.as_ref(),
InvocationRecord {
request_id: Some(transport_request_id),
trace_id: Some(transport_correlation.trace_id().as_str()),
@@ -1177,6 +1263,10 @@ async fn handle_base_tool_call(
message: "agent tool call completed",
status_code: None,
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Runtime),
execution_error_code: None,
retryability: Some(crank_core::Retryability::Never),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
duration: started_at.elapsed(),
request_preview,
response_preview: output.clone(),
@@ -1186,21 +1276,26 @@ async fn handle_base_tool_call(
success_tool_response(message, response_mode, &session.protocol_version, output)
}
Err(error) => {
persist_invocation(
Err(failure) => {
persist_invocation_for_key(
&state,
&tool,
platform_api_key_id.as_ref(),
InvocationRecord {
request_id: Some(transport_request_id),
trace_id: Some(transport_correlation.trace_id().as_str()),
tool_name: &tool.tool_name,
status: InvocationStatus::Error,
level: InvocationLevel::Error,
message: runtime_error_code(&error),
status_code: None,
error_kind: Some(runtime_error_code(&error)),
message: failure.error_code().as_str(),
status_code: failure.upstream_status(),
error_kind: Some(failure.error_code().as_str()),
execution_stage: Some(failure.stage()),
execution_error_code: Some(failure.error_code()),
retryability: Some(failure.retryability()),
outcome_certainty: Some(failure.outcome_certainty()),
duration: started_at.elapsed(),
request_preview,
request_preview: Value::Null,
response_preview: Value::Null,
},
)
@@ -1210,188 +1305,12 @@ async fn handle_base_tool_call(
message,
response_mode,
&session.protocol_version,
tool_error_contract_from_runtime(
&error,
transport_request_id,
transport_correlation.trace_id().as_str(),
),
tool_error_contract_from_failure(&failure, execution_locale(message)),
)
}
}
}
enum ApprovalPolicyResult {
Required(Response),
Error(Response),
}
async fn maybe_handle_approval_policy(
state: &Arc<AppState>,
session: &SessionState,
message: &Value,
response_mode: ResponseMode,
tool: &PublishedAgentTool,
arguments: &Value,
transport_correlation: &CorrelationContext,
) -> Option<ApprovalPolicyResult> {
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
if !policy.required {
return None;
}
match policy.mode {
OperationApprovalMode::Custom => {
maybe_create_custom_pending_approval(
state,
session,
message,
response_mode,
tool,
arguments,
transport_correlation,
)
.await
}
OperationApprovalMode::Elicitation => Some(handle_elicitation_approval(
session,
message,
response_mode,
tool,
arguments,
policy.elicitation_message.as_deref(),
transport_correlation,
)),
}
}
async fn maybe_create_custom_pending_approval(
state: &Arc<AppState>,
session: &SessionState,
message: &Value,
response_mode: ResponseMode,
tool: &PublishedAgentTool,
arguments: &Value,
transport_correlation: &CorrelationContext,
) -> Option<ApprovalPolicyResult> {
let transport_request_id = transport_correlation.request_id().as_str();
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
let now = OffsetDateTime::now_utc();
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
let approval = ApprovalRequest {
id: approval_id,
workspace_id: tool.workspace_id.clone(),
agent_id: tool.agent_id.clone(),
operation_id: tool.operation.id.clone(),
operation_version: tool.operation.version,
status: ApprovalRequestStatus::Pending,
risk_level: policy.risk_level,
request_payload: arguments.clone(),
response_payload: None,
created_at: now,
expires_at,
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
let persisted_approval = match observe_db_query(
DbOperation::ApprovalWrite,
state
.registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
}),
)
.await
{
Ok(approval) => approval,
Err(error) => {
return Some(ApprovalPolicyResult::Error(internal_jsonrpc_error(
message, error,
)));
}
};
let response_payload = approval_required_response(tool, &persisted_approval.approval, policy);
persist_invocation(
state,
tool,
InvocationRecord {
request_id: Some(transport_request_id),
trace_id: Some(transport_correlation.trace_id().as_str()),
tool_name: &tool.tool_name,
status: InvocationStatus::Ok,
level: InvocationLevel::Info,
message: "agent tool call is waiting for human approval",
status_code: None,
error_kind: None,
duration: Duration::from_millis(0),
request_preview: arguments.clone(),
response_preview: response_payload.clone(),
},
)
.await;
Some(ApprovalPolicyResult::Required(success_tool_response(
message,
response_mode,
&session.protocol_version,
response_payload,
)))
}
fn handle_elicitation_approval(
session: &SessionState,
message: &Value,
response_mode: ResponseMode,
tool: &PublishedAgentTool,
arguments: &Value,
elicitation_message: Option<&str>,
transport_correlation: &CorrelationContext,
) -> ApprovalPolicyResult {
let transport_request_id = transport_correlation.request_id().as_str();
if !session.supports_elicitation {
return ApprovalPolicyResult::Error(tool_error_response(
message,
response_mode,
&session.protocol_version,
generic_tool_error_contract(
"approval_elicitation_not_supported",
"operation requires MCP Elicitation, but the MCP client did not advertise elicitation capability",
transport_request_id,
transport_correlation.trace_id().as_str(),
false,
Some(
"Выберите Custom MCP Approval или подключите MCP-клиент с поддержкой elicitation.",
),
),
));
}
let payload_preview = tool
.operation
.execution_config
.approval_policy
.as_ref()
.and_then(|policy| policy.show_payload_preview.then(|| arguments.clone()))
.unwrap_or(Value::Null);
ApprovalPolicyResult::Required(success_tool_response(
message,
response_mode,
&session.protocol_version,
json!({
"status": "elicitation_required",
"message": elicitation_message.unwrap_or("Confirm operation execution."),
"tool": tool.tool_name,
"payload_preview": payload_preview,
"note": "This MCP client advertised elicitation support. Full elicitation/create continuation is handled by compatible client integrations.",
}),
))
}
async fn handle_initialize(
state: Arc<AppState>,
path: &AgentRoutePath,
@@ -1548,91 +1467,6 @@ fn internal_jsonrpc_error(message: &Value, _error: impl std::fmt::Display) -> Re
)
}
pub(super) fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
let Value::Object(object) = arguments else {
return None;
};
object
.remove("_crank_confirmation_token")
.and_then(|value| value.as_str().map(str::to_owned))
.filter(|value| !value.trim().is_empty())
}
pub(super) fn build_request_preview(
runtime: &RuntimeExecutor,
operation: &RuntimeOperation,
arguments: &Value,
) -> Value {
match runtime.prepare_request(operation, arguments) {
Ok(prepared) => json!({
"path": prepared.path_params,
"query": prepared.query_params,
"headers": prepared.headers,
"body": prepared.body.unwrap_or(Value::Null)
}),
Err(_) => Value::Null,
}
}
fn success_tool_response(
message: &Value,
response_mode: ResponseMode,
protocol_version: &str,
output: Value,
) -> Response {
transport_response(
StatusCode::OK,
jsonrpc_result(
request_id(message),
json!({
"content": [
{
"type": "text",
"text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned())
}
],
"structuredContent": output,
"isError": false
}),
),
response_mode,
None,
Some(protocol_version),
)
}
pub(super) fn tool_error_response(
message: &Value,
response_mode: ResponseMode,
protocol_version: &str,
error: ToolErrorContract,
) -> Response {
let error_message = tool_error_text(&error);
let error_value = tool_error_value(&error);
transport_response(
StatusCode::OK,
jsonrpc_result(
request_id(message),
json!({
"content": [
{
"type": "text",
"text": error_message
}
],
"structuredContent": {
"error": error_value
},
"isError": true
}),
),
response_mode,
None,
Some(protocol_version),
)
}
fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime {
let delta = time::Duration::milliseconds(i64::try_from(millis).unwrap_or(i64::MAX));
@@ -0,0 +1,203 @@
use std::{sync::Arc, time::Duration};
use axum::response::Response;
use crank_core::{
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, CorrelationContext, InvocationLevel,
InvocationStatus, OperationApprovalMode,
};
use crank_registry::{CreateApprovalRequest, PublishedAgentTool};
use crank_trace::{DbOperation, observe_db_query};
use serde_json::{Value, json};
use time::OffsetDateTime;
use super::{
AppState, InvocationRecord, internal_jsonrpc_error, persist_invocation_for_key,
response::{success_tool_response, tool_error_response},
};
use crate::{
approval_response::{
approval_history_request_preview, approval_history_response_preview,
approval_required_response,
},
session::SessionState,
tool_error::{McpControlError, control_tool_error_contract, execution_locale},
transport::ResponseMode,
};
pub(super) enum ApprovalPolicyResult {
Required(Response),
Error(Response),
}
pub(super) struct ApprovalPolicyContext<'a> {
pub state: &'a Arc<AppState>,
pub session: &'a SessionState,
pub message: &'a Value,
pub response_mode: ResponseMode,
pub tool: &'a PublishedAgentTool,
pub arguments: &'a Value,
pub platform_api_key_id: Option<&'a crank_core::PlatformApiKeyId>,
pub transport_correlation: &'a CorrelationContext,
}
pub(super) async fn maybe_handle_approval_policy(
context: ApprovalPolicyContext<'_>,
) -> Option<ApprovalPolicyResult> {
let policy = context
.tool
.operation
.execution_config
.approval_policy
.as_ref()?;
if !policy.required {
return None;
}
match policy.mode {
OperationApprovalMode::Custom => maybe_create_custom_pending_approval(&context).await,
OperationApprovalMode::Elicitation => Some(handle_elicitation_approval(
context.session,
context.message,
context.response_mode,
context.tool,
context.arguments,
policy.elicitation_message.as_deref(),
context.transport_correlation,
)),
}
}
async fn maybe_create_custom_pending_approval(
context: &ApprovalPolicyContext<'_>,
) -> Option<ApprovalPolicyResult> {
let ApprovalPolicyContext {
state,
session,
message,
response_mode,
tool,
arguments,
platform_api_key_id,
transport_correlation,
} = context;
let transport_request_id = transport_correlation.request_id().as_str();
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
let now = OffsetDateTime::now_utc();
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
let approval = ApprovalRequest {
id: approval_id,
workspace_id: tool.workspace_id.clone(),
agent_id: tool.agent_id.clone(),
operation_id: tool.operation.id.clone(),
operation_version: tool.operation.version,
status: ApprovalRequestStatus::Pending,
risk_level: policy.risk_level,
request_id: Some(transport_correlation.request_id().as_str().to_owned()),
trace_id: Some(transport_correlation.trace_id().as_str().to_owned()),
request_payload: (*arguments).clone(),
response_payload: None,
created_at: now,
expires_at,
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
let persisted_approval = match observe_db_query(
DbOperation::ApprovalWrite,
state
.registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
}),
)
.await
{
Ok(approval) => approval,
Err(error) => {
return Some(ApprovalPolicyResult::Error(internal_jsonrpc_error(
message, error,
)));
}
};
let response_payload = approval_required_response(tool, &persisted_approval.approval, policy);
persist_invocation_for_key(
state,
tool,
*platform_api_key_id,
InvocationRecord {
request_id: Some(transport_request_id),
trace_id: Some(transport_correlation.trace_id().as_str()),
tool_name: &tool.tool_name,
status: InvocationStatus::Ok,
level: InvocationLevel::Info,
message: "agent tool call is waiting for human approval",
status_code: None,
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Admission),
execution_error_code: None,
retryability: Some(crank_core::Retryability::RequiresConfirmation),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
duration: Duration::from_millis(0),
request_preview: approval_history_request_preview(&persisted_approval.approval),
response_preview: approval_history_response_preview(&response_payload),
},
)
.await;
Some(ApprovalPolicyResult::Required(success_tool_response(
message,
*response_mode,
&session.protocol_version,
response_payload,
)))
}
fn handle_elicitation_approval(
session: &SessionState,
message: &Value,
response_mode: ResponseMode,
tool: &PublishedAgentTool,
arguments: &Value,
elicitation_message: Option<&str>,
transport_correlation: &CorrelationContext,
) -> ApprovalPolicyResult {
let transport_request_id = transport_correlation.request_id().as_str();
if !session.supports_elicitation {
return ApprovalPolicyResult::Error(tool_error_response(
message,
response_mode,
&session.protocol_version,
control_tool_error_contract(
McpControlError::ApprovalElicitationNotSupported,
transport_request_id,
transport_correlation.trace_id().as_str(),
execution_locale(message),
),
));
}
let payload_preview = tool
.operation
.execution_config
.approval_policy
.as_ref()
.and_then(|policy| policy.show_payload_preview.then(|| arguments.clone()))
.unwrap_or(Value::Null);
ApprovalPolicyResult::Required(success_tool_response(
message,
response_mode,
&session.protocol_version,
json!({
"status": "elicitation_required",
"message": elicitation_message.unwrap_or("Confirm operation execution."),
"tool": tool.tool_name,
"payload_preview": payload_preview,
"note": "This MCP client advertised elicitation support. Full elicitation/create continuation is handled by compatible client integrations.",
}),
))
}
@@ -22,6 +22,10 @@ pub(crate) struct InvocationRecord<'a> {
pub(crate) message: &'a str,
pub(crate) status_code: Option<u16>,
pub(crate) error_kind: Option<&'a str>,
pub(crate) execution_stage: Option<crank_core::ExecutionStage>,
pub(crate) execution_error_code: Option<crank_core::ExecutionErrorCode>,
pub(crate) retryability: Option<crank_core::Retryability>,
pub(crate) outcome_certainty: Option<crank_core::OutcomeCertainty>,
pub(crate) duration: Duration,
pub(crate) request_preview: Value,
pub(crate) response_preview: Value,
@@ -31,12 +35,23 @@ pub(crate) async fn persist_invocation(
state: &Arc<AppState>,
tool: &PublishedAgentTool,
record: InvocationRecord<'_>,
) -> InvocationHistoryWriteOutcome {
persist_invocation_for_key(state, tool, None, record).await
}
pub(crate) async fn persist_invocation_for_key(
state: &Arc<AppState>,
tool: &PublishedAgentTool,
platform_api_key_id: Option<&crank_core::PlatformApiKeyId>,
record: InvocationRecord<'_>,
) -> InvocationHistoryWriteOutcome {
let log = InvocationLog {
id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())),
workspace_id: tool.workspace_id.clone(),
agent_id: Some(tool.agent_id.clone()),
platform_api_key_id: platform_api_key_id.cloned(),
operation_id: tool.operation.id.clone(),
operation_version: Some(tool.operation.version),
source: InvocationSource::AgentToolCall,
level: record.level,
status: record.status,
@@ -47,6 +62,10 @@ pub(crate) async fn persist_invocation(
status_code: record.status_code,
duration_ms: u64::try_from(record.duration.as_millis()).unwrap_or(u64::MAX),
error_kind: record.error_kind.map(ToOwned::to_owned),
execution_stage: record.execution_stage,
execution_error_code: record.execution_error_code,
retryability: record.retryability,
outcome_certainty: record.outcome_certainty,
request_preview: record.request_preview,
response_preview: record.response_preview,
created_at: OffsetDateTime::now_utc(),
@@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct InitializeParams {
#[serde(rename = "protocolVersion")]
pub(super) protocol_version: String,
#[serde(default)]
pub(super) capabilities: Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ToolCallParams {
pub(super) name: String,
#[serde(default)]
pub(super) arguments: Value,
}
#[derive(Debug, Deserialize)]
pub(super) struct ToolsListParams {
#[serde(default)]
pub(super) cursor: Option<String>,
#[serde(default)]
pub(super) limit: Option<usize>,
}
pub(super) fn paginate_tools_list(
definitions: Vec<Value>,
params: ToolsListParams,
) -> Result<Value, &'static str> {
const MAX_TOOLS_LIST_LIMIT: usize = 100;
let start = match params.cursor {
Some(cursor) if cursor.is_empty() => 0,
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| "invalid tools/list cursor")?,
None => 0,
};
if start > definitions.len() {
return Err("tools/list cursor is out of range");
}
let limit = params
.limit
.unwrap_or(definitions.len())
.clamp(1, MAX_TOOLS_LIST_LIMIT);
let end = start.saturating_add(limit).min(definitions.len());
let page = definitions[start..end].to_vec();
let mut result = json!({ "tools": page });
if end < definitions.len() {
result["nextCursor"] = json!(end.to_string());
}
Ok(result)
}
#[derive(Debug, Deserialize)]
pub(super) struct ApprovalDecisionPayload {
pub(super) approve: String,
#[serde(default)]
pub(super) note: Option<String>,
}
@@ -0,0 +1,77 @@
use axum::{http::StatusCode, response::Response};
use serde_json::{Value, json};
use crate::{
jsonrpc::{jsonrpc_result, request_id},
tool_error::{ToolErrorContract, tool_error_text, tool_error_value},
transport::{ResponseMode, transport_response},
};
pub(crate) fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
let Value::Object(object) = arguments else {
return None;
};
object
.remove("_crank_confirmation_token")
.and_then(|value| value.as_str().map(str::to_owned))
.filter(|value| !value.trim().is_empty())
}
pub(crate) fn success_tool_response(
message: &Value,
response_mode: ResponseMode,
protocol_version: &str,
output: Value,
) -> Response {
transport_response(
StatusCode::OK,
jsonrpc_result(
request_id(message),
json!({
"content": [
{
"type": "text",
"text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned())
}
],
"structuredContent": output,
"isError": false
}),
),
response_mode,
None,
Some(protocol_version),
)
}
pub(crate) fn tool_error_response(
message: &Value,
response_mode: ResponseMode,
protocol_version: &str,
error: ToolErrorContract,
) -> Response {
let error_message = tool_error_text(&error);
let error_value = tool_error_value(&error);
transport_response(
StatusCode::OK,
jsonrpc_result(
request_id(message),
json!({
"content": [
{
"type": "text",
"text": error_message
}
],
"structuredContent": {
"error": error_value
},
"isError": true
}),
),
response_mode,
None,
Some(protocol_version),
)
}
+12 -11
View File
@@ -22,7 +22,7 @@ use super::{
observe_invocation_history_outcome, tool_error_response,
};
use crate::jsonrpc::{CURRENT_PROTOCOL_VERSION, jsonrpc_error};
use crate::tool_error::generic_tool_error_contract;
use crate::tool_error::{McpControlError, control_tool_error_contract};
use crate::transport::transport_response;
#[tokio::test]
@@ -33,13 +33,11 @@ async fn tool_error_response_includes_structured_context() {
&message,
ResponseMode::Json,
CURRENT_PROTOCOL_VERSION,
generic_tool_error_contract(
"streaming_payload_error",
"request root must be an object",
control_tool_error_contract(
McpControlError::CatalogRevisionChanged,
"req-1",
"0af7651916cd43dd8448eb211c80319c",
false,
Some("Проверьте параметры вызова инструмента."),
crank_core::ExecutionLocale::Ru,
),
);
assert_eq!(
@@ -55,13 +53,16 @@ async fn tool_error_response_includes_structured_context() {
assert_eq!(
payload["result"]["structuredContent"]["error"],
json!({
"code": "streaming_payload_error",
"error_code": "streaming_payload_error",
"message": "request root must be an object",
"recoverable": false,
"code": "agent_catalog_result_stale",
"error_code": "agent_catalog_result_stale",
"message": "Версия каталога изменилась.",
"stage": "authorization",
"retryability": "after_delay",
"outcome_certainty": "certain",
"recoverable": true,
"request_id": "req-1",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"suggested_action": "Проверьте параметры вызова инструмента."
"suggested_action": "Повторите search_tools и вызовите инструмент с новой версией каталога."
})
);
}
@@ -9,16 +9,13 @@ use crank_core::{CorrelationContext, RequestId, TraceContext};
use crank_registry::{ApprovalRequestRecord, FinishApprovalRequest};
use crank_runtime::{RuntimeExecutionRequest, RuntimeRequestContext};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use serde_json::json;
use serde_json::{Value, json};
use time::OffsetDateTime;
use tracing::{Instrument, warn};
use crate::{
app::{
AgentRoutePath, AppState, InvocationRecord, build_request_preview, persist_invocation,
resolve_operation_auth, runtime_operation,
},
tool_error::{runtime_error_code, safe_runtime_error_message},
use crate::app::{
AgentRoutePath, AppState, InvocationRecord, persist_invocation, resolve_operation_auth,
runtime_operation,
};
const RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
@@ -184,11 +181,6 @@ pub(super) async fn execute_approved_request(
};
let operation = runtime_operation(&tool);
let request_preview = build_request_preview(
&state.runtime,
&operation,
&approval.approval.request_payload,
);
let started_at = Instant::now();
let runtime_request_context = RuntimeRequestContext::from_correlation(&correlation)
.with_response_cache_scope(
@@ -205,42 +197,86 @@ pub(super) async fn execute_approved_request(
resolve_operation_auth(state, &tool.workspace_id, &operation.execution_config).await;
let result = match resolved_auth {
Ok(resolved_auth) => {
state
.runtime
.execute_request(
RuntimeExecutionRequest::new(&operation, &approval.approval.request_payload)
.with_optional_auth(resolved_auth.as_ref())
.with_context(&runtime_request_context),
)
.await
match RuntimeExecutionRequest::try_new(
&tool.workspace_id,
crank_core::ExecutionOrigin::AgentSnapshot,
Some(&tool.agent_id),
&operation,
&approval.approval.request_payload,
crank_runtime::ExecutionAuthorization::Authorized,
resolved_auth.as_ref(),
&runtime_request_context,
Instant::now()
+ std::time::Duration::from_millis(
operation.execution_config.timeout_ms.max(1),
),
) {
Ok(request) => state.runtime.execute_outcome(request).await,
Err(_) => Err(crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::RuntimeInternal,
correlation.clone(),
)),
}
}
Err(error) => Err(error),
Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
};
let (status, response_payload, invocation_status, invocation_level, message, error_kind) =
match result {
Ok(output) => (
let (
status,
response_payload,
invocation_status,
invocation_level,
message,
error_kind,
execution_stage,
execution_error_code,
retryability,
outcome_certainty,
upstream_status,
request_preview,
) = match result {
Ok(success) => {
let request_preview = success.request_preview;
(
ApprovalRequestStatus::Completed,
output,
success.output,
InvocationStatus::Ok,
InvocationLevel::Info,
"approved tool call completed",
None,
),
Err(error) => (
ApprovalRequestStatus::Failed,
json!({
"error": {
"code": runtime_error_code(&error),
"message": safe_runtime_error_message(&error),
}
}),
InvocationStatus::Error,
InvocationLevel::Error,
"approved tool call failed",
Some(runtime_error_code(&error)),
),
};
Some(crank_core::ExecutionStage::Runtime),
None,
Some(crank_core::Retryability::Never),
Some(crank_core::OutcomeCertainty::Certain),
None,
request_preview,
)
}
Err(failure) => (
ApprovalRequestStatus::Failed,
json!({
"error": {
"code": failure.error_code().as_str(),
"message": failure.error_code().message(crank_core::ExecutionLocale::Ru),
"stage": failure.stage().as_str(),
"retryability": failure.retryability().as_str(),
"outcome_certainty": failure.outcome_certainty().as_str(),
"request_id": request_id,
"trace_id": correlation.trace_id().as_str(),
}
}),
InvocationStatus::Error,
InvocationLevel::Error,
"approved tool call failed",
Some(failure.error_code().as_str()),
Some(failure.stage()),
Some(failure.error_code()),
Some(failure.retryability()),
Some(failure.outcome_certainty()),
failure.upstream_status(),
Value::Null,
),
};
persist_invocation(
state,
@@ -252,8 +288,12 @@ pub(super) async fn execute_approved_request(
status: invocation_status,
level: invocation_level,
message,
status_code: None,
status_code: upstream_status,
error_kind,
execution_stage,
execution_error_code,
retryability,
outcome_certainty,
duration: started_at.elapsed(),
request_preview,
response_preview: response_payload.clone(),
@@ -275,8 +315,47 @@ pub(super) async fn execute_approved_request(
}),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or_else(|| StatusCode::CONFLICT.into_response())
.map_err(|_| approved_completion_persistence_error(&correlation))?
.ok_or_else(|| approved_completion_conflict_error(&correlation))
}
fn approved_completion_conflict_error(correlation: &CorrelationContext) -> Response {
(
StatusCode::CONFLICT,
axum::Json(json!({
"error": {
"code": "approval_state_conflict",
"stage": "mandatory_persistence",
"retryability": "manual_reconcile",
"outcome_certainty": "outcome_unknown",
"request_id": correlation.request_id().as_str(),
"trace_id": correlation.trace_id().as_str(),
}
})),
)
.into_response()
}
fn approved_completion_persistence_error(correlation: &CorrelationContext) -> Response {
let failure = crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::PersistenceUnavailable,
correlation.clone(),
)
.with_dispatch_uncertainty();
(
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(json!({
"error": {
"code": failure.error_code().as_str(),
"stage": failure.stage().as_str(),
"retryability": failure.retryability().as_str(),
"outcome_certainty": failure.outcome_certainty().as_str(),
"request_id": failure.correlation().request_id().as_str(),
"trace_id": failure.correlation().trace_id().as_str(),
}
})),
)
.into_response()
}
async fn finish_unavailable_approval(
@@ -1,4 +1,4 @@
use crank_core::{ApprovalRequest, OperationApprovalPolicy};
use crank_core::{ApprovalRequest, OperationApprovalPolicy, sanitize_invocation_preview};
use crank_registry::PublishedAgentTool;
use serde_json::{Value, json};
@@ -30,9 +30,25 @@ pub(super) fn approval_required_response(
"expires_at": approval.expires_at,
"risk_level": approval.risk_level,
"payload_preview": if policy.show_payload_preview {
approval.request_payload.clone()
sanitize_invocation_preview(&approval.request_payload)
} else {
Value::Null
},
})
}
pub(super) fn approval_history_request_preview(approval: &ApprovalRequest) -> Value {
json!({
"status": "approval_required",
"approval_id": approval.id.as_str(),
"risk_level": approval.risk_level,
})
}
pub(super) fn approval_history_response_preview(response: &Value) -> Value {
json!({
"status": response.get("status").cloned().unwrap_or(Value::Null),
"approval_id": response.get("approval_id").cloned().unwrap_or(Value::Null),
"risk_level": response.get("risk_level").cloned().unwrap_or(Value::Null),
})
}
+2 -53
View File
@@ -108,14 +108,7 @@ impl PublishedToolCatalog {
agent_slug: &str,
) -> Result<(), RegistryError> {
let key = CatalogKey::new(workspace_slug, agent_slug);
let should_refresh = {
let guard = self.cached.read().await;
match guard.get(&key).and_then(|entry| entry.loaded_at) {
Some(loaded_at) => loaded_at.elapsed() >= self.refresh_interval,
None => true,
}
};
let should_refresh = true;
if !should_refresh {
return Ok(());
@@ -133,32 +126,11 @@ impl PublishedToolCatalog {
}
};
let _refresh_guard = refresh_lock.lock().await;
let still_stale = {
let guard = self.cached.read().await;
match guard.get(&key).and_then(|entry| entry.loaded_at) {
Some(loaded_at) => loaded_at.elapsed() >= self.refresh_interval,
None => true,
}
};
let still_stale = true;
if !still_stale {
return Ok(());
}
if let Some((catalog, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
let metrics =
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &catalog.tools);
self.store_local_catalog(
key,
CachedCatalog {
loaded_at: Instant::now().checked_sub(age),
catalog,
metrics,
},
)
.await;
return Ok(());
}
let db_span = DbOperation::CatalogLoad.span();
let catalog_result = self
.registry
@@ -224,29 +196,6 @@ impl PublishedToolCatalog {
previous_count
}
async fn load_shared_snapshot(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> Option<(PublishedAgentCatalog, Duration)> {
if self.refresh_interval.is_zero() {
return None;
}
let key = catalog_snapshot_key(workspace_slug, agent_slug);
let value = match self
.coordination_store
.get_value(CacheScope::Coordination, &key)
.await
{
Ok(value) => value?,
Err(_) => return None,
};
let snapshot = serde_json::from_value::<CatalogSnapshot>(value.payload).ok()?;
let age = Duration::from_millis(now_unix_ms().saturating_sub(snapshot.generated_at_ms));
(age < self.refresh_interval).then_some((snapshot.catalog, age))
}
async fn store_shared_snapshot(
&self,
workspace_slug: &str,
+141 -181
View File
@@ -1,5 +1,7 @@
use crank_adapter_rest::RestAdapterError;
use crank_runtime::RuntimeError;
use crank_core::{
CorrelationContext, ExecutionFailure, ExecutionLocale, RequestId, Retryability, TraceContext,
};
use crank_runtime::{RuntimeError, normalize_runtime_error};
use serde::Serialize;
use serde_json::{Value, json};
@@ -7,6 +9,9 @@ use serde_json::{Value, json};
pub struct ToolErrorContract {
pub code: &'static str,
pub error_code: &'static str,
pub stage: &'static str,
pub retryability: &'static str,
pub outcome_certainty: &'static str,
pub message: String,
pub recoverable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -21,32 +26,95 @@ pub fn tool_error_contract_from_runtime(
error: &RuntimeError,
request_id: &str,
trace_id: &str,
locale: ExecutionLocale,
) -> ToolErrorContract {
let error_code = runtime_error_code(error);
let correlation = correlation_from_safe_ids(request_id, trace_id);
project_failure(&normalize_runtime_error(error, &correlation), locale)
}
pub fn tool_error_contract_from_failure(
failure: &ExecutionFailure,
locale: ExecutionLocale,
) -> ToolErrorContract {
project_failure(failure, locale)
}
fn project_failure(failure: &ExecutionFailure, locale: ExecutionLocale) -> ToolErrorContract {
let code = failure.error_code();
let retryability = failure.retryability();
let message = if let Some(challenge) = failure.confirmation() {
confirmation_message(
code.message(locale),
challenge.token(),
challenge.expires_in_ms(),
locale,
)
} else {
code.message(locale).to_owned()
};
ToolErrorContract {
code: error_code,
error_code,
message: safe_runtime_error_message(error),
recoverable: is_recoverable_runtime_error(error),
suggested_action: suggested_action(error),
upstream_status: upstream_status(error),
request_id: request_id.to_owned(),
trace_id: trace_id.to_owned(),
code: code.as_str(),
error_code: code.as_str(),
stage: failure.stage().as_str(),
retryability: retryability.as_str(),
outcome_certainty: failure.outcome_certainty().as_str(),
message,
recoverable: matches!(
retryability,
Retryability::Safe | Retryability::AfterDelay | Retryability::RequiresConfirmation
),
suggested_action: suggested_action(retryability, locale),
upstream_status: failure.upstream_status(),
request_id: failure.correlation().request_id().to_string(),
trace_id: failure.correlation().trace_id().to_string(),
}
}
pub fn generic_tool_error_contract(
error_code: &'static str,
message: impl Into<String>,
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum McpControlError {
CatalogRevisionChanged,
ApprovalElicitationNotSupported,
}
pub fn control_tool_error_contract(
error: McpControlError,
request_id: &str,
trace_id: &str,
recoverable: bool,
suggested_action: Option<&'static str>,
locale: ExecutionLocale,
) -> ToolErrorContract {
let (error_code, message, recoverable, suggested_action) = match (error, locale) {
(McpControlError::CatalogRevisionChanged, ExecutionLocale::Ru) => (
"agent_catalog_result_stale",
"Версия каталога изменилась.",
true,
Some("Повторите search_tools и вызовите инструмент с новой версией каталога."),
),
(McpControlError::CatalogRevisionChanged, ExecutionLocale::En) => (
"agent_catalog_result_stale",
"The catalog revision changed.",
true,
Some("Run search_tools again and call the tool with the new catalog revision."),
),
(McpControlError::ApprovalElicitationNotSupported, ExecutionLocale::Ru) => (
"approval_elicitation_not_supported",
"Клиент не поддерживает MCP Elicitation, обязательный для этой операции.",
false,
Some("Используйте Custom MCP Approval или клиент с поддержкой elicitation."),
),
(McpControlError::ApprovalElicitationNotSupported, ExecutionLocale::En) => (
"approval_elicitation_not_supported",
"The client does not support MCP Elicitation required by this operation.",
false,
Some("Use Custom MCP Approval or an elicitation-capable client."),
),
};
ToolErrorContract {
code: error_code,
error_code,
message: message.into(),
stage: "authorization",
retryability: if recoverable { "after_delay" } else { "never" },
outcome_certainty: "certain",
message: message.to_owned(),
recoverable,
suggested_action,
upstream_status: None,
@@ -55,6 +123,19 @@ pub fn generic_tool_error_contract(
}
}
pub fn execution_locale(message: &Value) -> ExecutionLocale {
let locale = message
.pointer("/params/_meta/locale")
.or_else(|| message.pointer("/params/locale"))
.and_then(Value::as_str)
.unwrap_or("en");
if locale.eq_ignore_ascii_case("ru") || locale.to_ascii_lowercase().starts_with("ru-") {
ExecutionLocale::Ru
} else {
ExecutionLocale::En
}
}
pub fn tool_error_text(error: &ToolErrorContract) -> String {
match error.suggested_action {
Some(action) => format!("{} {}", error.message, action),
@@ -65,9 +146,12 @@ pub fn tool_error_text(error: &ToolErrorContract) -> String {
pub fn tool_error_value(error: &ToolErrorContract) -> Value {
serde_json::to_value(error).unwrap_or_else(|_| {
json!({
"code": "runtime_error",
"error_code": "runtime_error",
"message": "Не удалось выполнить инструмент.",
"code": "runtime_internal",
"error_code": "runtime_internal",
"stage": "runtime",
"retryability": "never",
"outcome_certainty": "certain",
"message": "Внутренняя ошибка выполнения.",
"recoverable": false,
"request_id": error.request_id,
"trace_id": error.trace_id
@@ -75,178 +159,54 @@ pub fn tool_error_value(error: &ToolErrorContract) -> Value {
})
}
pub fn runtime_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "schema_validation_error",
RuntimeError::Mapping(_) => "mapping_error",
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status, .. }) => {
upstream_status_code(*status)
}
RuntimeError::RestAdapter(RestAdapterError::Transport(_)) => "upstream_transport_error",
RuntimeError::RestAdapter(_) => "adapter_execution_error",
RuntimeError::ProtocolAdapter(_) => "adapter_execution_error",
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable",
RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress",
RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown",
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"secret_not_found"
}
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
}
fn correlation_from_safe_ids(request_id: &str, trace_id: &str) -> CorrelationContext {
let request_id = RequestId::parse(request_id).unwrap_or_else(|_| RequestId::generate());
let trace_context = TraceContext::from_span_parts(trace_id, "0000000000000001", false)
.unwrap_or_else(|_| TraceContext::generate());
CorrelationContext::new(request_id, trace_context)
}
fn upstream_status_code(status: u16) -> &'static str {
match status {
401 | 403 => "upstream_auth_error",
404 => "upstream_not_found",
408 | 429 => "upstream_rate_limited",
500..=599 => "upstream_server_error",
_ => "upstream_status_error",
}
}
pub(crate) fn safe_runtime_error_message(error: &RuntimeError) -> String {
match error {
RuntimeError::Schema(_) => "Входные параметры не прошли проверку схемы.".to_owned(),
RuntimeError::Mapping(_) => {
"Не удалось сопоставить параметры инструмента с API-запросом.".to_owned()
}
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status, .. }) => {
format!("Внешний API вернул HTTP {}.", status)
}
RuntimeError::RestAdapter(RestAdapterError::Transport(_)) => {
"Не удалось подключиться к внешнему API.".to_owned()
}
RuntimeError::RestAdapter(_) => "Не удалось выполнить запрос к внешнему API.".to_owned(),
RuntimeError::ProtocolAdapter(_) => "Не удалось выполнить протокольный адаптер.".to_owned(),
RuntimeError::UnsupportedProtocol { .. } => {
"Протокол операции не поддерживается.".to_owned()
}
RuntimeError::ConcurrencyLimitExceeded { .. } => {
"Сервис временно перегружен и не может выполнить инструмент.".to_owned()
}
RuntimeError::UnsupportedExecutionMode { .. } => {
"Режим выполнения операции не поддерживается.".to_owned()
}
RuntimeError::InvalidPreparedRequest { .. } => {
"Не удалось подготовить корректный API-запрос.".to_owned()
}
RuntimeError::ConfirmationRequired {
confirmation_token,
expires_in_ms,
..
} => format!(
"Операция требует подтверждения. Повторите вызов с _crank_confirmation_token=\"{}\" в течение {} секунд.",
confirmation_token,
fn confirmation_message(
base_message: &str,
token: &str,
expires_in_ms: u64,
locale: ExecutionLocale,
) -> String {
match locale {
ExecutionLocale::Ru => format!(
"{base_message} Повторите вызов с _crank_confirmation_token=\"{token}\" в течение {} секунд.",
expires_in_ms / 1000
),
ExecutionLocale::En => format!(
"{base_message} Repeat the call with _crank_confirmation_token=\"{token}\" within {} seconds.",
expires_in_ms / 1000
),
RuntimeError::InvalidConfirmationToken { .. } => {
"Токен подтверждения недействителен, истек или уже был использован.".to_owned()
}
RuntimeError::ConfirmationStoreUnavailable { .. } => {
"Хранилище подтверждений временно недоступно.".to_owned()
}
RuntimeError::IdempotencyStoreUnavailable { .. } => {
"Хранилище идемпотентности временно недоступно.".to_owned()
}
RuntimeError::IdempotencyInProgress { .. } => {
"Операция с этим ключом идемпотентности уже выполняется.".to_owned()
}
RuntimeError::IdempotencyConflict { .. } => {
"Ключ идемпотентности уже использован с другими параметрами.".to_owned()
}
RuntimeError::IdempotencyOutcomeUnknown { .. } => {
"Результат предыдущего выполнения неизвестен; автоматический повтор заблокирован."
.to_owned()
}
RuntimeError::MissingAuthProfile { .. } => "Профиль авторизации не найден.".to_owned(),
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"Секрет авторизации не найден.".to_owned()
}
RuntimeError::InvalidAuthSecretValue { .. } => {
"Секрет авторизации имеет неподходящий формат.".to_owned()
}
RuntimeError::SecretCrypto { .. } => {
"Не удалось расшифровать секрет авторизации.".to_owned()
}
}
}
fn is_recoverable_runtime_error(error: &RuntimeError) -> bool {
matches!(
error,
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
status: 408 | 429 | 500..=599,
..
}) | RuntimeError::RestAdapter(RestAdapterError::Transport(_))
| RuntimeError::ConcurrencyLimitExceeded { .. }
| RuntimeError::SecretCrypto { .. }
| RuntimeError::ConfirmationRequired { .. }
| RuntimeError::IdempotencyStoreUnavailable { .. }
| RuntimeError::IdempotencyInProgress { .. }
)
}
fn suggested_action(error: &RuntimeError) -> Option<&'static str> {
match error {
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
status: 401 | 403, ..
}) => Some("Проверьте настройки авторизации внешнего API."),
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status: 404, .. }) => {
Some("Проверьте путь endpoint-а и параметры запроса.")
const fn suggested_action(
retryability: Retryability,
locale: ExecutionLocale,
) -> Option<&'static str> {
match (retryability, locale) {
(Retryability::AfterDelay | Retryability::Safe, ExecutionLocale::Ru) => {
Some("Повторите запрос позже.")
}
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
status: 408 | 429 | 500..=599,
..
})
| RuntimeError::RestAdapter(RestAdapterError::Transport(_))
| RuntimeError::ConcurrencyLimitExceeded { .. } => Some("Повторите запрос позже."),
RuntimeError::Schema(_)
| RuntimeError::Mapping(_)
| RuntimeError::InvalidPreparedRequest { .. } => {
Some("Проверьте параметры вызова инструмента.")
(Retryability::AfterDelay | Retryability::Safe, ExecutionLocale::En) => {
Some("Retry the request later.")
}
RuntimeError::ConfirmationRequired { .. } => {
Some("Повторите вызов с указанным токеном подтверждения.")
}
RuntimeError::InvalidConfirmationToken { .. } => {
Some("Запросите новый токен подтверждения.")
}
RuntimeError::ConfirmationStoreUnavailable { .. } => Some("Повторите запрос позже."),
RuntimeError::IdempotencyStoreUnavailable { .. }
| RuntimeError::IdempotencyInProgress { .. } => Some("Повторите запрос позже."),
RuntimeError::IdempotencyConflict { .. } => {
Some("Используйте новый ключ идемпотентности для изменённого запроса.")
}
RuntimeError::IdempotencyOutcomeUnknown { .. } => {
(Retryability::ManualReconcile, ExecutionLocale::Ru) => {
Some("Проверьте результат во внешней системе перед ручным повтором.")
}
RuntimeError::MissingAuthProfile { .. }
| RuntimeError::MissingSecret { .. }
| RuntimeError::MissingSecretVersion { .. }
| RuntimeError::InvalidAuthSecretValue { .. }
| RuntimeError::SecretCrypto { .. } => {
Some("Проверьте настройки авторизации и секретов в Crank.")
(Retryability::ManualReconcile, ExecutionLocale::En) => {
Some("Check the result in the external system before retrying manually.")
}
_ => None,
}
}
fn upstream_status(error: &RuntimeError) -> Option<u16> {
match error {
RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus { status, .. }) => {
Some(*status)
}
_ => None,
(Retryability::RequiresConfirmation, ExecutionLocale::Ru) => {
Some("Повторите вызов с указанным токеном подтверждения.")
}
(Retryability::RequiresConfirmation, ExecutionLocale::En) => {
Some("Repeat the call with the provided confirmation token.")
}
(Retryability::Never, _) => None,
}
}
+5 -12
View File
@@ -16,7 +16,7 @@ use crate::{
jsonrpc::{jsonrpc_error, jsonrpc_result, request_id},
manifest::{CALL_TOOL_NAME, SEARCH_TOOLS_NAME, searchable_tools},
session::SessionState,
tool_error::generic_tool_error_contract,
tool_error::{McpControlError, control_tool_error_contract, execution_locale},
transport::{ResponseMode, transport_response},
};
@@ -84,18 +84,11 @@ pub(super) async fn handle_catalog_tool_call(
message,
response_mode,
&session.protocol_version,
generic_tool_error_contract(
"catalog_revision_changed",
format!(
"catalog revision {} is no longer current",
proxy.catalog_revision
),
control_tool_error_contract(
McpControlError::CatalogRevisionChanged,
transport_request_id,
transport_correlation.trace_id().as_str(),
true,
Some(
"Повторите search_tools и вызовите инструмент с новой версией каталога.",
),
execution_locale(message),
),
);
}
@@ -253,7 +246,7 @@ fn handle_search_tools(
}
fn catalog_revision(catalog: &PublishedAgentCatalog) -> String {
format!("agent-version-{}", catalog.agent_version)
catalog.catalog_revision.clone()
}
fn invalid_arguments_response(
@@ -1,12 +1,16 @@
use std::collections::BTreeMap;
use std::time::Instant;
use crank_community_mcp::manifest::{analyze_published_tool_catalog, tool_definitions};
use crank_community_mcp::manifest::{
analyze_published_tool_catalog, catalog_tool_definitions, tool_definitions,
};
use crank_core::{
ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus,
Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
Protocol, RestTarget, Target, ToolAccessMode, ToolDescription, ToolSelectionPolicy,
WorkspaceId,
};
use crank_mapping::MappingSet;
use crank_registry::{PublishedAgentTool, RegistryOperation};
use crank_registry::{PublishedAgentCatalog, PublishedAgentTool, RegistryOperation};
use crank_schema::{Schema, SchemaKind};
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -71,6 +75,57 @@ fn catalog_budget_uses_the_same_definitions_as_tools_list() {
assert!(!analysis.budget.exceeds_recommended_budget);
}
#[test]
fn agent_catalog_discovery_profile_handles_200_agents_2000_operations_under_budget() {
const AGENT_COUNT: usize = 200;
const TOOLS_PER_AGENT: usize = 10;
const P95_BUDGET_MS: u128 = 150;
let mut durations = Vec::with_capacity(AGENT_COUNT);
let mut total_definitions = 0usize;
let started = Instant::now();
for agent_index in 0..AGENT_COUNT {
let tools = (0..TOOLS_PER_AGENT)
.map(|tool_index| {
let mut tool = published_tool();
tool.agent_id = format!("agent_{agent_index:03}").into();
tool.agent_slug = format!("agent-{agent_index:03}");
tool.operation.id =
OperationId::new(format!("op_{agent_index:03}_{tool_index:03}"));
tool.tool_name = format!("tool_{agent_index:03}_{tool_index:03}");
tool.tool_title = format!("Tool {agent_index:03}-{tool_index:03}");
tool
})
.collect::<Vec<_>>();
let catalog = PublishedAgentCatalog {
agent_version: 1,
catalog_revision: format!("agent-{agent_index:03}-revision-1"),
tool_selection_policy: ToolSelectionPolicy {
mode: ToolAccessMode::Direct,
..Default::default()
},
tools,
};
let catalog_started = Instant::now();
let definitions = catalog_tool_definitions(&catalog);
durations.push(catalog_started.elapsed());
total_definitions += definitions.len();
}
durations.sort_unstable();
let p95 = durations[((durations.len() * 95).div_ceil(100)).saturating_sub(1)];
assert_eq!(total_definitions, AGENT_COUNT * TOOLS_PER_AGENT);
assert!(
p95.as_millis() <= P95_BUDGET_MS,
"agent catalog discovery p95={}ms total={}ms",
p95.as_millis(),
started.elapsed().as_millis()
);
}
fn published_tool() -> PublishedAgentTool {
PublishedAgentTool {
workspace_id: WorkspaceId::new("ws_01"),
@@ -1,23 +1,28 @@
use crank_adapter_rest::RestAdapterError;
use crank_community_mcp::tool_error::tool_error_contract_from_runtime;
use crank_community_mcp::tool_error::{
tool_error_contract_from_failure, tool_error_contract_from_runtime,
};
use crank_core::{
CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionLocale,
ProtocolAdapterError, Retryability,
};
use crank_runtime::RuntimeError;
use serde_json::json;
#[test]
fn maps_upstream_429_to_recoverable_structured_tool_error() {
let contract = tool_error_contract_from_runtime(
&RuntimeError::RestAdapter(RestAdapterError::UnexpectedStatus {
&RuntimeError::ProtocolAdapter(ProtocolAdapterError::UnexpectedStatus {
status: 429,
body: json!({
"error": "rate limit exceeded",
"internal_trace": "do not leak"
}),
dispatch: crank_core::DispatchEvidence::MayHaveDispatched,
}),
"req-429",
"0af7651916cd43dd8448eb211c80319c",
ExecutionLocale::Ru,
);
assert_eq!(contract.error_code, "upstream_rate_limited");
assert_eq!(contract.stage, "upstream");
assert_eq!(contract.retryability, "after_delay");
assert_eq!(contract.outcome_certainty, "certain");
assert!(contract.recoverable);
assert_eq!(contract.upstream_status, Some(429));
assert_eq!(contract.request_id, "req-429");
@@ -26,6 +31,22 @@ fn maps_upstream_429_to_recoverable_structured_tool_error() {
assert!(!contract.message.contains("internal_trace"));
}
#[test]
fn maps_upstream_429_to_english_suggested_action() {
let contract = tool_error_contract_from_runtime(
&RuntimeError::ProtocolAdapter(ProtocolAdapterError::UnexpectedStatus {
status: 429,
dispatch: crank_core::DispatchEvidence::MayHaveDispatched,
}),
"req-429",
"0af7651916cd43dd8448eb211c80319c",
ExecutionLocale::En,
);
assert_eq!(contract.error_code, "upstream_rate_limited");
assert_eq!(contract.suggested_action, Some("Retry the request later."));
}
#[test]
fn maps_mapping_error_to_non_recoverable_structured_tool_error() {
let contract = tool_error_contract_from_runtime(
@@ -35,15 +56,91 @@ fn maps_mapping_error_to_non_recoverable_structured_tool_error() {
},
"req-map",
"0af7651916cd43dd8448eb211c80319c",
ExecutionLocale::Ru,
);
assert_eq!(contract.error_code, "runtime_error");
assert_eq!(contract.error_code, "prepared_request_invalid");
assert_eq!(contract.stage, "request_preparation");
assert_eq!(contract.retryability, "never");
assert!(!contract.recoverable);
assert_eq!(contract.upstream_status, None);
assert_eq!(contract.request_id, "req-map");
assert_eq!(contract.trace_id, "0af7651916cd43dd8448eb211c80319c");
assert_eq!(contract.suggested_action, None);
}
#[test]
fn ambiguous_timeout_requires_manual_reconciliation() {
let contract = tool_error_contract_from_runtime(
&RuntimeError::ExecutionDeadlineElapsed {
may_have_dispatched: true,
},
"req-timeout",
"0af7651916cd43dd8448eb211c80319c",
ExecutionLocale::Ru,
);
assert_eq!(contract.error_code, "upstream_timeout");
assert_eq!(contract.retryability, "manual_reconcile");
assert_eq!(contract.outcome_certainty, "outcome_unknown");
assert!(!contract.recoverable);
}
#[test]
fn every_execution_code_projects_the_same_canonical_semantics_as_admin() {
for code in ExecutionErrorCode::ALL {
let contract = tool_error_contract_from_failure(
&ExecutionFailure::new(code, CorrelationContext::generate()),
ExecutionLocale::En,
);
assert_eq!(contract.error_code, code.as_str());
assert_eq!(contract.stage, code.stage().as_str());
assert_eq!(contract.retryability, code.retryability().as_str());
assert_eq!(
contract.outcome_certainty,
code.outcome_certainty().as_str()
);
assert_eq!(
contract.recoverable,
matches!(
code.retryability(),
Retryability::Safe | Retryability::AfterDelay | Retryability::RequiresConfirmation
)
);
assert!(!contract.message.is_empty());
}
}
#[test]
fn confirmation_challenge_is_localized_for_mcp_clients() {
let failure = ExecutionFailure::new(
ExecutionErrorCode::ConfirmationRequired,
CorrelationContext::generate(),
)
.try_with_confirmation("ct_safe", 30_000)
.expect("valid confirmation challenge");
let en = tool_error_contract_from_failure(&failure, ExecutionLocale::En);
assert!(
en.message.contains(
"Repeat the call with _crank_confirmation_token=\"ct_safe\" within 30 seconds."
),
"{:?}",
en.message
);
assert_eq!(
contract.suggested_action,
Some("Проверьте параметры вызова инструмента.")
en.suggested_action,
Some("Repeat the call with the provided confirmation token.")
);
let ru = tool_error_contract_from_failure(&failure, ExecutionLocale::Ru);
assert!(
ru.message
.contains("Повторите вызов с _crank_confirmation_token=\"ct_safe\""),
"{:?}",
ru.message
);
assert_eq!(
ru.suggested_action,
Some("Повторите вызов с указанным токеном подтверждения.")
);
}
+5 -1
View File
@@ -27,6 +27,7 @@ impl fmt::Debug for OutboundSettings {
f.debug_struct("OutboundSettings")
.field("allowed_host_count", &self.allowed_hosts.len())
.field("denied_host_count", &self.denied_hosts.len())
.field("max_request_bytes", &self.max_request_bytes)
.field("max_response_bytes", &self.max_response_bytes)
.finish()
}
@@ -96,7 +97,10 @@ impl fmt::Debug for AdminProcessConfig {
.field("storage_root", &"configured")
.field("session_secret", &self.session_secret)
.field("password_pepper", &self.password_pepper)
.field("bootstrap_password", &self.bootstrap_password)
.field(
"bootstrap_password",
&self.bootstrap_password.as_ref().map(|_| "configured"),
)
.finish_non_exhaustive()
}
}
+40 -60
View File
@@ -1,7 +1,3 @@
use std::{collections::BTreeMap, fmt, net::SocketAddr, path::PathBuf};
use url::Url;
use crate::{
ConfigError, ConfigSource, Diagnostic, DiagnosticCode, ProcessScope, SecretString,
deployment_field_registry, field_registry,
@@ -9,14 +5,20 @@ use crate::{
schema::semantic_path_for,
validation::{valid_database_host, valid_database_identifier, valid_percent_encoding},
};
use std::{
collections::BTreeMap,
fmt,
net::{IpAddr, SocketAddr},
path::PathBuf,
};
use url::Url;
mod list_parsers;
const MAX_ENV_VALUE_BYTES: usize = 8_192;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessKind {
AdminApi,
McpServer,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DeprecationRecord {
pub field: &'static str,
@@ -24,7 +26,6 @@ pub struct DeprecationRecord {
pub replacement: &'static str,
pub removal_window: &'static str,
}
impl ProcessKind {
pub const fn as_str(self) -> &'static str {
match self {
@@ -49,7 +50,6 @@ pub struct PoolSettings {
pub idle_timeout_ms: u64,
pub max_lifetime_ms: u64,
}
#[derive(Clone)]
pub struct DatabaseSettings {
pub url: Option<SecretString>,
@@ -60,27 +60,24 @@ pub struct DatabaseSettings {
pub password: SecretString,
pub pool: PoolSettings,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CacheBackend {
Memory,
Valkey,
Redis,
}
#[derive(Clone)]
pub struct CacheSettings {
pub backend: CacheBackend,
pub url: Option<SecretString>,
}
#[derive(Clone)]
pub struct OutboundSettings {
pub allowed_hosts: Vec<String>,
pub denied_hosts: Vec<String>,
pub max_request_bytes: usize,
pub max_response_bytes: usize,
}
#[derive(Clone)]
pub struct RuntimeSettings {
pub master_key: SecretString,
@@ -90,20 +87,17 @@ pub struct RuntimeSettings {
pub cache: CacheSettings,
pub outbound: OutboundSettings,
}
#[derive(Clone, Debug)]
pub struct RateLimitSettings {
pub requests_per_second: u32,
pub burst: u32,
}
#[derive(Clone)]
pub struct MetricsSettings {
pub enabled: bool,
pub bind_addr: SocketAddr,
pub bearer_token: Option<SecretString>,
}
#[derive(Clone, Default)]
pub struct OtlpSettings {
pub endpoint: Option<String>,
@@ -141,9 +135,9 @@ pub struct AdminProcessConfig {
pub session_secret: SecretString,
pub password_pepper: SecretString,
pub session_ttl_hours: i64,
pub trust_forwarded_headers: bool,
pub trusted_proxy_ips: Vec<IpAddr>,
pub bootstrap_email: String,
pub bootstrap_password: SecretString,
pub bootstrap_password: Option<SecretString>,
pub bootstrap_display_name: String,
pub demo_seed: bool,
}
@@ -313,7 +307,15 @@ impl<'a> Parser<'a> {
}
fn secret(&mut self, name: &'static str, default: Option<&str>) -> SecretString {
SecretString::new(self.string(name, default))
let value = self.string(name, default);
if let Some(spec) = field_registry().iter().find(|field| field.env_name == name)
&& let Some(minimum) = spec.minimum
&& !value.is_empty()
&& value.len() < minimum as usize
{
self.push(DiagnosticCode::OutOfRange, spec.semantic_path);
}
SecretString::new(value)
}
fn optional_secret(&mut self, name: &'static str) -> Option<SecretString> {
@@ -501,41 +503,6 @@ impl<'a> Parser<'a> {
Some(SecretString::new(raw))
}
}
fn host_list(&mut self, name: &'static str) -> Vec<String> {
let Some(raw) = self.optional(name) else {
return Vec::new();
};
if raw.len() > 16_384 {
self.push(DiagnosticCode::OutOfRange, name);
return Vec::new();
}
let mut items = Vec::new();
for item in raw.split(',') {
let item = item.trim().to_ascii_lowercase();
let base = item.strip_prefix("*.").unwrap_or(&item);
let valid_ip = !item.starts_with("*.") && base.parse::<std::net::IpAddr>().is_ok();
if item.is_empty()
|| item.len() > 253
|| item.contains('/')
|| item.contains(char::is_whitespace)
|| base.is_empty()
|| (!valid_ip && base.contains(':'))
|| (item.starts_with("*.") && base.parse::<std::net::IpAddr>().is_ok())
{
self.push(DiagnosticCode::InvalidType, name);
continue;
}
if !items.contains(&item) {
items.push(item);
}
}
if items.len() > 256 {
self.push(DiagnosticCode::OutOfRange, name);
items.truncate(256);
}
items
}
}
fn parse_database(parser: &mut Parser<'_>) -> DatabaseSettings {
@@ -634,7 +601,6 @@ pub(crate) fn parse_database_source(
}
Ok((database, parser.deprecations))
}
pub fn parse_process(
kind: ProcessKind,
source: ConfigSource,
@@ -642,7 +608,6 @@ pub fn parse_process(
let values = source.values();
let mut parser = Parser::new(kind, values);
parser.check_source();
let database = parse_database(&mut parser);
let cache_backend = match parser
@@ -709,6 +674,7 @@ pub fn parse_process(
outbound: OutboundSettings {
allowed_hosts: parser.host_list("CRANK_OUTBOUND_ALLOWED_HOSTS"),
denied_hosts: parser.host_list("CRANK_OUTBOUND_DENIED_HOSTS"),
max_request_bytes: parser.number("CRANK_OUTBOUND_MAX_REQUEST_BYTES") as usize,
max_response_bytes: parser.number("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") as usize,
},
};
@@ -811,14 +777,24 @@ pub fn parse_process(
ProcessKind::AdminApi => {
let rps = parser.number("CRANK_ADMIN_RATE_LIMIT_RPS") as u32;
let burst = parser.number("CRANK_ADMIN_RATE_LIMIT_BURST") as u32;
let bind_addr = parser.socket("CRANK_ADMIN_BIND");
if burst < rps {
parser.push(DiagnosticCode::UnsafeCombination, "admin.rate_limit.burst");
}
if observability.environment == "production"
&& !bind_addr.ip().is_loopback()
&& !runtime
.base_url
.as_deref()
.is_some_and(|url| url.starts_with("https://"))
{
parser.push(DiagnosticCode::UnsafeCombination, "admin.exposure.tls");
}
Projection::Admin(AdminProcessConfig {
database,
runtime,
observability,
bind_addr: parser.socket("CRANK_ADMIN_BIND"),
bind_addr,
storage_root: parser.absolute_path("CRANK_STORAGE_ROOT", "/var/lib/crank/storage"),
rate_limit: RateLimitSettings {
requests_per_second: rps,
@@ -829,9 +805,9 @@ pub fn parse_process(
session_secret: parser.secret("CRANK_SESSION_SECRET", None),
password_pepper: parser.secret("CRANK_PASSWORD_PEPPER", None),
session_ttl_hours: parser.number("CRANK_SESSION_TTL_HOURS") as i64,
trust_forwarded_headers: parser.boolean("CRANK_TRUST_FORWARDED_HEADERS"),
trusted_proxy_ips: parser.ip_list("CRANK_TRUSTED_PROXY_IPS"),
bootstrap_email: parser.string("CRANK_BOOTSTRAP_ADMIN_EMAIL", None),
bootstrap_password: parser.secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD", None),
bootstrap_password: parser.optional_secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD"),
bootstrap_display_name: parser
.string("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", Some("Crank Owner")),
demo_seed: parser.boolean("CRANK_DEMO_SEED"),
@@ -889,14 +865,17 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
),
format!("retention={}", config.invocation_log_retention_days),
format!("session_ttl={}", config.session_ttl_hours),
format!("trusted={}", config.trust_forwarded_headers),
format!("trusted_proxies={:?}", config.trusted_proxy_ips),
format!("demo={}", config.demo_seed),
"storage=path-configured".to_owned(),
format!("session_secret={}", config.session_secret.is_configured()),
format!("pepper={}", config.password_pepper.is_configured()),
format!(
"bootstrap_password={}",
config.bootstrap_password.is_configured()
config
.bootstrap_password
.as_ref()
.is_some_and(SecretString::is_configured)
),
],
),
@@ -970,6 +949,7 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
),
format!("allowed={}", allowed.join(",")),
format!("denied={}", denied.join(",")),
format!("max_request={}", runtime.outbound.max_request_bytes),
format!("max_response={}", runtime.outbound.max_response_bytes),
format!("environment={}", observability.environment),
format!("log_filter={}", observability.log_filter),
@@ -0,0 +1,36 @@
use std::net::IpAddr;
use crate::{
DiagnosticCode,
validation::{parse_host_list, parse_ip_list},
};
impl super::Parser<'_> {
pub(super) fn host_list(&mut self, name: &'static str) -> Vec<String> {
let Some(raw) = self.optional(name) else {
return Vec::new();
};
let parsed = parse_host_list(&raw);
if parsed.invalid {
self.push(DiagnosticCode::InvalidType, name);
}
if parsed.out_of_range {
self.push(DiagnosticCode::OutOfRange, name);
}
parsed.items
}
pub(super) fn ip_list(&mut self, name: &'static str) -> Vec<IpAddr> {
let Some(raw) = self.optional(name) else {
return Vec::new();
};
let parsed = parse_ip_list(&raw);
if parsed.invalid {
self.push(DiagnosticCode::InvalidType, name);
}
if parsed.out_of_range {
self.push(DiagnosticCode::OutOfRange, name);
}
parsed.items
}
}
+5 -2
View File
@@ -63,6 +63,8 @@ pub fn reference_section() -> String {
};
let bounds = match (field.minimum, field.maximum) {
(Some(minimum), Some(maximum)) => format!("{minimum}..={maximum}"),
(Some(minimum), None) => format!(">={minimum}"),
(None, Some(maximum)) => format!("<={maximum}"),
_ => "-".to_owned(),
};
output.push_str(&format!(
@@ -102,9 +104,10 @@ fn example_value(field: &FieldSpec, production: bool) -> String {
}
match (field.env_name, production) {
("CRANK_ENVIRONMENT", true) => "production".to_owned(),
("CRANK_BASE_URL", _) => "http://localhost:3000".to_owned(),
("CRANK_BASE_URL", true) => "https://crank.example.local".to_owned(),
("CRANK_BASE_URL", false) => "http://localhost:3000".to_owned(),
("POSTGRES_HOST", true) => "postgres".to_owned(),
("CRANK_TRUST_FORWARDED_HEADERS", true) => "true".to_owned(),
("CRANK_TRUSTED_PROXY_IPS", true) => "127.0.0.1".to_owned(),
_ => field.default.unwrap_or("").to_owned(),
}
}
+34 -5
View File
@@ -78,7 +78,7 @@ macro_rules! f {
};
}
static FIELDS: [FieldSpec; 57] = [
static FIELDS: [FieldSpec; 59] = [
FieldSpec {
compatibility: Some("legacy URL form"),
rules: &[
@@ -222,7 +222,7 @@ static FIELDS: [FieldSpec; 57] = [
"secret",
None,
None,
None,
Some(32),
None,
Secret
)
@@ -317,6 +317,17 @@ static FIELDS: [FieldSpec; 57] = [
Internal
)
},
f!(
"outbound.max_request_bytes",
"CRANK_OUTBOUND_MAX_REQUEST_BYTES",
Shared,
"u64",
Some("bytes"),
Some("4194304"),
Some(1),
Some(67108864),
Public
),
f!(
"outbound.max_response_bytes",
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES",
@@ -636,19 +647,37 @@ static FIELDS: [FieldSpec; 57] = [
Public
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
mode: FieldMode::DeprecatedNoEffect,
compatibility: Some("deprecated boolean proxy trust; use CRANK_TRUSTED_PROXY_IPS"),
..f!(
"admin.trust_forwarded_headers",
"CRANK_TRUST_FORWARDED_HEADERS",
AdminApi,
"bool",
None,
Some("false"),
None,
None,
None,
Public
)
},
FieldSpec {
rules: &[
"only listed immediate peer IPs may supply X-Real-IP/X-Forwarded-For client identity",
"empty value disables forwarded-header trust",
],
..f!(
"admin.trusted_proxy_ips",
"CRANK_TRUSTED_PROXY_IPS",
AdminApi,
"ip_list",
None,
Some(""),
None,
None,
Internal
)
},
FieldSpec {
required: true,
..f!(
@@ -664,7 +693,7 @@ static FIELDS: [FieldSpec; 57] = [
)
},
FieldSpec {
required: true,
compatibility: Some("deprecated startup-bootstrap password; use local bootstrap contract"),
..f!(
"admin.bootstrap.password",
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
+69
View File
@@ -1,3 +1,11 @@
use std::net::IpAddr;
pub(crate) struct ParsedList<T> {
pub(crate) items: Vec<T>,
pub(crate) invalid: bool,
pub(crate) out_of_range: bool,
}
pub(crate) fn valid_percent_encoding(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = 0;
@@ -40,3 +48,64 @@ pub(crate) fn valid_database_identifier(value: &str) -> bool {
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}
pub(crate) fn parse_host_list(raw: &str) -> ParsedList<String> {
let mut result = ParsedList {
items: Vec::new(),
invalid: false,
out_of_range: raw.len() > 16_384,
};
if result.out_of_range {
return result;
}
for item in raw.split(',') {
let item = item.trim().to_ascii_lowercase();
let base = item.strip_prefix("*.").unwrap_or(&item);
let valid_ip = !item.starts_with("*.") && base.parse::<IpAddr>().is_ok();
if item.is_empty()
|| item.len() > 253
|| item.contains('/')
|| item.contains(char::is_whitespace)
|| base.is_empty()
|| (!valid_ip && base.contains(':'))
|| (item.starts_with("*.") && base.parse::<IpAddr>().is_ok())
{
result.invalid = true;
continue;
}
if !result.items.contains(&item) {
result.items.push(item);
}
}
if result.items.len() > 256 {
result.out_of_range = true;
result.items.truncate(256);
}
result
}
pub(crate) fn parse_ip_list(raw: &str) -> ParsedList<IpAddr> {
let mut result = ParsedList {
items: Vec::new(),
invalid: false,
out_of_range: raw.len() > 4096,
};
if result.out_of_range {
return result;
}
for item in raw.split(',').map(str::trim) {
match item.parse::<IpAddr>() {
Ok(value) if !result.items.contains(&value) => result.items.push(value),
Ok(_) => {}
Err(_) => result.invalid = true,
}
}
if result.items.is_empty() {
result.invalid = true;
}
if result.items.len() > 64 {
result.out_of_range = true;
result.items.truncate(64);
}
result
}
+30 -7
View File
@@ -5,9 +5,11 @@ use crank_config::{
parse_migrator, parse_process,
};
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
fn required_admin() -> BTreeMap<String, String> {
[
("CRANK_MASTER_KEY", "master"),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
@@ -19,7 +21,7 @@ fn required_admin() -> BTreeMap<String, String> {
}
fn required_mcp() -> BTreeMap<String, String> {
[("CRANK_MASTER_KEY", "master")]
[("CRANK_MASTER_KEY", TEST_MASTER_KEY)]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect()
@@ -106,9 +108,9 @@ fn source_for(
}
#[test]
fn registry_covers_exactly_the_57_observed_runtime_names() {
fn registry_covers_exactly_the_59_observed_runtime_names() {
let registry = field_registry();
assert_eq!(registry.len(), 57);
assert_eq!(registry.len(), 59);
let unique = registry
.iter()
.map(|field| field.env_name)
@@ -148,9 +150,10 @@ fn defaults_are_preserved_and_invalid_values_never_fall_back() {
for (name, value) in [
("POSTGRES_PORT", "bad"),
("CRANK_MASTER_KEY", "too-short"),
("CRANK_SESSION_TTL_HOURS", "bad"),
("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"),
("CRANK_TRUST_FORWARDED_HEADERS", "tru"),
("CRANK_TRUSTED_PROXY_IPS", "not-an-ip"),
] {
let mut vars = required_admin();
vars.insert(name.to_owned(), value.to_owned());
@@ -309,6 +312,24 @@ fn process_specific_fields_and_zero_ports_fail_closed() {
}
}
#[test]
fn production_admin_non_loopback_requires_https_base_url() {
let mut vars = required_admin();
vars.insert("CRANK_ENVIRONMENT".into(), "production".into());
vars.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
vars.insert("CRANK_BASE_URL".into(), "http://crank.example.test".into());
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(error.diagnostics().iter().any(|item| {
item.code == DiagnosticCode::UnsafeCombination && item.field == "admin.exposure.tls"
}));
let mut safe = required_admin();
safe.insert("CRANK_ENVIRONMENT".into(), "production".into());
safe.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
safe.insert("CRANK_BASE_URL".into(), "https://crank.example.test".into());
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(safe)).unwrap();
}
#[test]
fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() {
for name in [
@@ -355,6 +376,7 @@ fn cross_field_and_typed_boundaries_fail_closed() {
("POSTGRES_MAX_CONNECTIONS", "1025"),
("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"),
("CRANK_ADMIN_RATE_LIMIT_BURST", "0"),
("CRANK_OUTBOUND_MAX_REQUEST_BYTES", "67108865"),
("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"),
("OTEL_BSP_SCHEDULE_DELAY", "bad"),
("OTEL_BSP_EXPORT_TIMEOUT", "300001"),
@@ -406,11 +428,12 @@ fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()),
("POSTGRES_MIN_CONNECTIONS".into(), "0".into()),
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()),
("CRANK_OUTBOUND_MAX_REQUEST_BYTES".into(), "67108864".into()),
(
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(),
"67108864".into(),
),
("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()),
("CRANK_TRUSTED_PROXY_IPS".into(), "127.0.0.1,::1".into()),
("CRANK_DEMO_SEED".into(), "off".into()),
]);
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
@@ -419,7 +442,7 @@ fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
assert_eq!(admin.database.pool.max_connections, 1024);
assert_eq!(admin.database.pool.min_connections, 0);
assert_eq!(admin.runtime.max_concurrent_unary, 1);
assert!(admin.trust_forwarded_headers);
assert_eq!(admin.trusted_proxy_ips.len(), 2);
assert!(!admin.demo_seed);
}
+1 -1
View File
@@ -21,7 +21,7 @@ fn generated_reference_distinguishes_required_and_optional_fields() {
let reference = render::reference_section();
assert!(reference.contains(
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` |"
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` | `>=32` |"
));
assert!(
reference
+13 -9
View File
@@ -2,6 +2,10 @@ use std::collections::BTreeMap;
use crank_config::{ConfigSource, ProcessKind, parse_process};
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
const CANARY_ONE_MASTER_KEY: &str = "CANARY_ONE-000000000000000000000000000000000";
const CANARY_TWO_MASTER_KEY: &str = "CANARY_TWO-000000000000000000000000000000000";
fn config(secret: &str) -> crank_config::EffectiveConfig {
let vars = [
("CRANK_MASTER_KEY", secret),
@@ -18,8 +22,8 @@ fn config(secret: &str) -> crank_config::EffectiveConfig {
#[test]
fn secrets_are_absent_from_debug_display_and_fingerprint() {
let first = config("CANARY_ONE");
let second = config("CANARY_TWO");
let first = config(CANARY_ONE_MASTER_KEY);
let second = config(CANARY_TWO_MASTER_KEY);
let rendered = format!("{first:?}");
assert!(!rendered.contains("CANARY_ONE"));
assert_eq!(first.fingerprint(), second.fingerprint());
@@ -35,18 +39,18 @@ fn secrets_are_absent_from_debug_display_and_fingerprint() {
#[test]
fn effective_semantics_not_input_spelling_drive_fingerprint() {
let mut canonical = [
("CRANK_MASTER_KEY", "master"),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
("CRANK_TRUST_FORWARDED_HEADERS", "true"),
("CRANK_DEMO_SEED", "true"),
]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect::<BTreeMap<_, _>>();
let mut compatibility = canonical.clone();
compatibility.insert("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into());
compatibility.insert("CRANK_DEMO_SEED".into(), "yes".into());
let canonical_config = parse_process(
ProcessKind::AdminApi,
@@ -71,7 +75,7 @@ fn effective_semantics_not_input_spelling_drive_fingerprint() {
#[test]
fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
let canary = "CANARY_SECRET_VALUE";
let canary = "CANARY_SECRET_VALUE-0000000000000000000000";
let vars = [
("CRANK_MASTER_KEY", canary),
("CRANK_SESSION_SECRET", canary),
@@ -111,7 +115,7 @@ fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
#[test]
fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
let mut vars = [
("CRANK_MASTER_KEY", "master"),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@CANARY.test"),
@@ -135,7 +139,7 @@ fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
#[test]
fn normalized_database_and_admin_default_urls_drive_fingerprint() {
let base = [
("CRANK_MASTER_KEY", "master"),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
@@ -160,7 +164,7 @@ fn normalized_database_and_admin_default_urls_drive_fingerprint() {
assert_eq!(implicit.fingerprint(), url.fingerprint());
let tls = [
("CRANK_MASTER_KEY", "master"),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
+1
View File
@@ -33,6 +33,7 @@ pub enum InvitationStatus {
pub enum PlatformApiKeyStatus {
Active,
Revoked,
Deleted,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+2
View File
@@ -27,6 +27,8 @@ pub struct ApprovalRequest {
pub operation_version: u32,
pub status: ApprovalRequestStatus,
pub risk_level: OperationApprovalRiskLevel,
pub request_id: Option<String>,
pub trace_id: Option<String>,
pub request_payload: Value,
pub response_payload: Option<Value>,
#[serde(with = "time::serde::rfc3339")]
+21
View File
@@ -40,6 +40,15 @@ pub enum AuthConfig {
}
impl AuthConfig {
pub const fn kind(&self) -> AuthKind {
match self {
Self::Bearer(_) => AuthKind::Bearer,
Self::Basic(_) => AuthKind::Basic,
Self::ApiKeyHeader(_) => AuthKind::ApiKeyHeader,
Self::ApiKeyQuery(_) => AuthKind::ApiKeyQuery,
}
}
pub fn secret_ids(&self) -> Vec<&SecretId> {
match self {
Self::Bearer(config) => vec![&config.secret_id],
@@ -94,4 +103,16 @@ mod tests {
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
}
#[test]
fn auth_config_reports_its_semantic_kind() {
assert_eq!(
AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
header_name: "X-Api-Key".to_owned(),
secret_id: SecretId::new("secret_01"),
})
.kind(),
AuthKind::ApiKeyHeader
);
}
}
+521
View File
@@ -0,0 +1,521 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{AgentId, CorrelationContext, OperationId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionOrigin {
AdminDraft,
AgentSnapshot,
}
impl ExecutionOrigin {
pub fn validate_agent(self, agent_id: Option<&AgentId>) -> Result<(), ExecutionOriginError> {
match (self, agent_id) {
(Self::AdminDraft, None) | (Self::AgentSnapshot, Some(_)) => Ok(()),
(Self::AdminDraft, Some(_)) => Err(ExecutionOriginError::UnexpectedAgent),
(Self::AgentSnapshot, None) => Err(ExecutionOriginError::MissingAgent),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ExecutionOriginError {
#[error("admin draft execution cannot carry an agent identity")]
UnexpectedAgent,
#[error("agent snapshot execution requires an agent identity")]
MissingAgent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionStage {
Authorization,
InputSchema,
InputMapping,
RequestPreparation,
Admission,
Adapter,
Upstream,
OutputMapping,
OutputSchema,
MandatoryPersistence,
Runtime,
}
impl ExecutionStage {
pub const fn as_str(self) -> &'static str {
match self {
Self::Authorization => "authorization",
Self::InputSchema => "input_schema",
Self::InputMapping => "input_mapping",
Self::RequestPreparation => "request_preparation",
Self::Admission => "admission",
Self::Adapter => "adapter",
Self::Upstream => "upstream",
Self::OutputMapping => "output_mapping",
Self::OutputSchema => "output_schema",
Self::MandatoryPersistence => "mandatory_persistence",
Self::Runtime => "runtime",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Retryability {
Never,
Safe,
AfterDelay,
ManualReconcile,
RequiresConfirmation,
}
impl Retryability {
pub const fn as_str(self) -> &'static str {
match self {
Self::Never => "never",
Self::Safe => "safe",
Self::AfterDelay => "after_delay",
Self::ManualReconcile => "manual_reconcile",
Self::RequiresConfirmation => "requires_confirmation",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeCertainty {
Certain,
OutcomeUnknown,
}
impl OutcomeCertainty {
pub const fn as_str(self) -> &'static str {
match self {
Self::Certain => "certain",
Self::OutcomeUnknown => "outcome_unknown",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionErrorCode {
AuthorizationDenied,
AuthProfileNotFound,
SecretNotFound,
SecretInvalid,
InputSchemaInvalid,
InputMappingInvalid,
PreparedRequestInvalid,
ExecutionOverloaded,
SafetyStoreUnavailable,
ProtocolUnsupported,
ExecutionModeUnsupported,
AdapterConfigurationInvalid,
OutboundTargetRejected,
UpstreamAuthError,
UpstreamNotFound,
UpstreamRateLimited,
UpstreamServerError,
UpstreamStatusError,
UpstreamTimeout,
UpstreamTransportError,
UpstreamRequestTooLarge,
UpstreamResponseTooLarge,
OutputMappingInvalid,
OutputSchemaInvalid,
PersistenceUnavailable,
RuntimeInternal,
ConfirmationRequired,
ConfirmationInvalid,
IdempotencyInProgress,
IdempotencyConflict,
IdempotencyOutcomeUnknown,
}
impl ExecutionErrorCode {
pub const ALL: [Self; 31] = [
Self::AuthorizationDenied,
Self::AuthProfileNotFound,
Self::SecretNotFound,
Self::SecretInvalid,
Self::InputSchemaInvalid,
Self::InputMappingInvalid,
Self::PreparedRequestInvalid,
Self::ExecutionOverloaded,
Self::SafetyStoreUnavailable,
Self::ProtocolUnsupported,
Self::ExecutionModeUnsupported,
Self::AdapterConfigurationInvalid,
Self::OutboundTargetRejected,
Self::UpstreamAuthError,
Self::UpstreamNotFound,
Self::UpstreamRateLimited,
Self::UpstreamServerError,
Self::UpstreamStatusError,
Self::UpstreamTimeout,
Self::UpstreamTransportError,
Self::UpstreamRequestTooLarge,
Self::UpstreamResponseTooLarge,
Self::OutputMappingInvalid,
Self::OutputSchemaInvalid,
Self::PersistenceUnavailable,
Self::RuntimeInternal,
Self::ConfirmationRequired,
Self::ConfirmationInvalid,
Self::IdempotencyInProgress,
Self::IdempotencyConflict,
Self::IdempotencyOutcomeUnknown,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::AuthorizationDenied => "authorization_denied",
Self::AuthProfileNotFound => "auth_profile_not_found",
Self::SecretNotFound => "secret_not_found",
Self::SecretInvalid => "secret_invalid",
Self::InputSchemaInvalid => "input_schema_invalid",
Self::InputMappingInvalid => "input_mapping_invalid",
Self::PreparedRequestInvalid => "prepared_request_invalid",
Self::ExecutionOverloaded => "execution_overloaded",
Self::SafetyStoreUnavailable => "safety_store_unavailable",
Self::ProtocolUnsupported => "protocol_unsupported",
Self::ExecutionModeUnsupported => "execution_mode_unsupported",
Self::AdapterConfigurationInvalid => "adapter_configuration_invalid",
Self::OutboundTargetRejected => "outbound_target_rejected",
Self::UpstreamAuthError => "upstream_auth_error",
Self::UpstreamNotFound => "upstream_not_found",
Self::UpstreamRateLimited => "upstream_rate_limited",
Self::UpstreamServerError => "upstream_server_error",
Self::UpstreamStatusError => "upstream_status_error",
Self::UpstreamTimeout => "upstream_timeout",
Self::UpstreamTransportError => "upstream_transport_error",
Self::UpstreamRequestTooLarge => "upstream_request_too_large",
Self::UpstreamResponseTooLarge => "upstream_response_too_large",
Self::OutputMappingInvalid => "output_mapping_invalid",
Self::OutputSchemaInvalid => "output_schema_invalid",
Self::PersistenceUnavailable => "persistence_unavailable",
Self::RuntimeInternal => "runtime_internal",
Self::ConfirmationRequired => "confirmation_required",
Self::ConfirmationInvalid => "confirmation_invalid",
Self::IdempotencyInProgress => "idempotency_in_progress",
Self::IdempotencyConflict => "idempotency_conflict",
Self::IdempotencyOutcomeUnknown => "idempotency_outcome_unknown",
}
}
pub const fn stage(self) -> ExecutionStage {
match self {
Self::AuthorizationDenied
| Self::AuthProfileNotFound
| Self::SecretNotFound
| Self::SecretInvalid => ExecutionStage::Authorization,
Self::InputSchemaInvalid => ExecutionStage::InputSchema,
Self::InputMappingInvalid => ExecutionStage::InputMapping,
Self::PreparedRequestInvalid => ExecutionStage::RequestPreparation,
Self::ExecutionOverloaded
| Self::SafetyStoreUnavailable
| Self::ConfirmationRequired
| Self::ConfirmationInvalid
| Self::IdempotencyInProgress
| Self::IdempotencyConflict
| Self::IdempotencyOutcomeUnknown => ExecutionStage::Admission,
Self::ProtocolUnsupported
| Self::ExecutionModeUnsupported
| Self::AdapterConfigurationInvalid
| Self::OutboundTargetRejected => ExecutionStage::Adapter,
Self::UpstreamRequestTooLarge => ExecutionStage::RequestPreparation,
Self::UpstreamAuthError
| Self::UpstreamNotFound
| Self::UpstreamRateLimited
| Self::UpstreamServerError
| Self::UpstreamStatusError
| Self::UpstreamTimeout
| Self::UpstreamTransportError
| Self::UpstreamResponseTooLarge => ExecutionStage::Upstream,
Self::OutputMappingInvalid => ExecutionStage::OutputMapping,
Self::OutputSchemaInvalid => ExecutionStage::OutputSchema,
Self::PersistenceUnavailable => ExecutionStage::MandatoryPersistence,
Self::RuntimeInternal => ExecutionStage::Runtime,
}
}
pub const fn retryability(self) -> Retryability {
match self {
Self::ExecutionOverloaded
| Self::SafetyStoreUnavailable
| Self::UpstreamRateLimited
| Self::UpstreamServerError
| Self::UpstreamTimeout
| Self::UpstreamTransportError
| Self::PersistenceUnavailable
| Self::IdempotencyInProgress => Retryability::AfterDelay,
Self::ConfirmationRequired => Retryability::RequiresConfirmation,
Self::IdempotencyOutcomeUnknown => Retryability::ManualReconcile,
_ => Retryability::Never,
}
}
pub const fn outcome_certainty(self) -> OutcomeCertainty {
match self {
Self::IdempotencyOutcomeUnknown => OutcomeCertainty::OutcomeUnknown,
_ => OutcomeCertainty::Certain,
}
}
pub const fn message(self, locale: ExecutionLocale) -> &'static str {
match locale {
ExecutionLocale::Ru => self.message_ru(),
ExecutionLocale::En => self.message_en(),
}
}
const fn message_ru(self) -> &'static str {
match self {
Self::AuthorizationDenied => "Выполнение операции запрещено.",
Self::AuthProfileNotFound => "Профиль авторизации не найден.",
Self::SecretNotFound => "Секрет авторизации не найден.",
Self::SecretInvalid => "Секрет авторизации имеет неподходящий формат.",
Self::InputSchemaInvalid => "Входные параметры не прошли проверку схемы.",
Self::InputMappingInvalid => "Не удалось сопоставить входные параметры.",
Self::PreparedRequestInvalid => "Не удалось подготовить корректный API-запрос.",
Self::ExecutionOverloaded => "Сервис временно перегружен.",
Self::SafetyStoreUnavailable => "Обязательное хранилище безопасности недоступно.",
Self::ProtocolUnsupported => "Протокол операции не поддерживается.",
Self::ExecutionModeUnsupported => "Режим выполнения не поддерживается.",
Self::AdapterConfigurationInvalid => "Конфигурация адаптера некорректна.",
Self::OutboundTargetRejected => "Целевой адрес отклонён политикой безопасности.",
Self::UpstreamAuthError => "Внешний API отклонил авторизацию.",
Self::UpstreamNotFound => "Ресурс внешнего API не найден.",
Self::UpstreamRateLimited => "Внешний API ограничил частоту запросов.",
Self::UpstreamServerError => "Внешний API временно недоступен.",
Self::UpstreamStatusError => "Внешний API вернул ошибочный статус.",
Self::UpstreamTimeout => "Истекло время ожидания внешнего API.",
Self::UpstreamTransportError => "Не удалось подключиться к внешнему API.",
Self::UpstreamRequestTooLarge => "Запрос во внешний API превышает лимит.",
Self::UpstreamResponseTooLarge => "Ответ внешнего API превышает лимит.",
Self::OutputMappingInvalid => "Не удалось сопоставить ответ внешнего API.",
Self::OutputSchemaInvalid => "Ответ не прошёл проверку схемы.",
Self::PersistenceUnavailable => "Обязательное сохранение результата недоступно.",
Self::RuntimeInternal => "Внутренняя ошибка выполнения.",
Self::ConfirmationRequired => "Операция требует подтверждения.",
Self::ConfirmationInvalid => "Подтверждение недействительно или истекло.",
Self::IdempotencyInProgress => "Операция с этим ключом уже выполняется.",
Self::IdempotencyConflict => "Ключ идемпотентности использован с другими параметрами.",
Self::IdempotencyOutcomeUnknown => {
"Результат предыдущего выполнения неизвестен; автоматический повтор запрещён."
}
}
}
const fn message_en(self) -> &'static str {
match self {
Self::AuthorizationDenied => "Operation execution is denied.",
Self::AuthProfileNotFound => "Authorization profile was not found.",
Self::SecretNotFound => "Authorization secret was not found.",
Self::SecretInvalid => "Authorization secret has an invalid format.",
Self::InputSchemaInvalid => "Input does not satisfy the operation schema.",
Self::InputMappingInvalid => "Input parameters could not be mapped.",
Self::PreparedRequestInvalid => "A valid upstream request could not be prepared.",
Self::ExecutionOverloaded => "The service is temporarily overloaded.",
Self::SafetyStoreUnavailable => "A mandatory safety store is unavailable.",
Self::ProtocolUnsupported => "The operation protocol is unsupported.",
Self::ExecutionModeUnsupported => "The execution mode is unsupported.",
Self::AdapterConfigurationInvalid => "The adapter configuration is invalid.",
Self::OutboundTargetRejected => "The target was rejected by the safety policy.",
Self::UpstreamAuthError => "The upstream API rejected authorization.",
Self::UpstreamNotFound => "The upstream resource was not found.",
Self::UpstreamRateLimited => "The upstream API rate-limited the request.",
Self::UpstreamServerError => "The upstream API is temporarily unavailable.",
Self::UpstreamStatusError => "The upstream API returned an error status.",
Self::UpstreamTimeout => "The upstream API timed out.",
Self::UpstreamTransportError => "The upstream API could not be reached.",
Self::UpstreamRequestTooLarge => "The upstream request exceeded its limit.",
Self::UpstreamResponseTooLarge => "The upstream response exceeded its limit.",
Self::OutputMappingInvalid => "The upstream response could not be mapped.",
Self::OutputSchemaInvalid => "The output does not satisfy the operation schema.",
Self::PersistenceUnavailable => "Mandatory result persistence is unavailable.",
Self::RuntimeInternal => "Internal execution failure.",
Self::ConfirmationRequired => "The operation requires confirmation.",
Self::ConfirmationInvalid => "The confirmation is invalid or expired.",
Self::IdempotencyInProgress => "The operation is already running for this key.",
Self::IdempotencyConflict => "The idempotency key was used with different input.",
Self::IdempotencyOutcomeUnknown => {
"The previous outcome is unknown; automatic retry is unsafe."
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecutionLocale {
Ru,
En,
}
#[derive(Clone, PartialEq, Eq)]
pub struct ConfirmationChallenge {
token: String,
expires_in_ms: u64,
}
impl ConfirmationChallenge {
fn try_new(
token: impl Into<String>,
expires_in_ms: u64,
) -> Result<Self, ExecutionFailureBuildError> {
let token = token.into();
if token.is_empty() || token.len() > 4_096 || token.chars().any(char::is_control) {
return Err(ExecutionFailureBuildError::InvalidConfirmation);
}
if !(1..=3_600_000).contains(&expires_in_ms) {
return Err(ExecutionFailureBuildError::InvalidConfirmation);
}
Ok(Self {
token,
expires_in_ms,
})
}
pub fn token(&self) -> &str {
&self.token
}
pub fn expires_in_ms(&self) -> u64 {
self.expires_in_ms
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ExecutionFailureBuildError {
#[error("failure metadata is incompatible with the execution error code")]
IncompatibleMetadata,
#[error("confirmation metadata is invalid or exceeds its bound")]
InvalidConfirmation,
#[error("retry metadata is invalid or exceeds its bound")]
InvalidRetryAfter,
}
impl std::fmt::Debug for ConfirmationChallenge {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ConfirmationChallenge")
.field("token", &"[REDACTED]")
.field("expires_in_ms", &self.expires_in_ms)
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecutionFailure {
code: ExecutionErrorCode,
retryability: Retryability,
outcome_certainty: OutcomeCertainty,
correlation: CorrelationContext,
upstream_status: Option<u16>,
retry_after_ms: Option<u64>,
confirmation: Option<ConfirmationChallenge>,
}
impl ExecutionFailure {
pub fn new(code: ExecutionErrorCode, correlation: CorrelationContext) -> Self {
Self {
code,
retryability: code.retryability(),
outcome_certainty: code.outcome_certainty(),
correlation,
upstream_status: None,
retry_after_ms: None,
confirmation: None,
}
}
pub fn error_code(&self) -> ExecutionErrorCode {
self.code
}
pub fn stage(&self) -> ExecutionStage {
self.code.stage()
}
pub fn retryability(&self) -> Retryability {
self.retryability
}
pub fn outcome_certainty(&self) -> OutcomeCertainty {
self.outcome_certainty
}
pub fn correlation(&self) -> &CorrelationContext {
&self.correlation
}
pub fn upstream_status(&self) -> Option<u16> {
self.upstream_status
}
pub fn retry_after_ms(&self) -> Option<u64> {
self.retry_after_ms
}
pub fn confirmation(&self) -> Option<&ConfirmationChallenge> {
self.confirmation.as_ref()
}
pub fn with_upstream_status(mut self, status: u16) -> Self {
self.upstream_status = (100..=599).contains(&status).then_some(status);
self
}
pub fn try_with_retry_after_ms(
mut self,
retry_after_ms: u64,
) -> Result<Self, ExecutionFailureBuildError> {
if self.retryability != Retryability::AfterDelay {
return Err(ExecutionFailureBuildError::IncompatibleMetadata);
}
if !(1..=3_600_000).contains(&retry_after_ms) {
return Err(ExecutionFailureBuildError::InvalidRetryAfter);
}
self.retry_after_ms = Some(retry_after_ms);
Ok(self)
}
pub fn try_with_confirmation(
mut self,
token: impl Into<String>,
expires_in_ms: u64,
) -> Result<Self, ExecutionFailureBuildError> {
if self.code != ExecutionErrorCode::ConfirmationRequired {
return Err(ExecutionFailureBuildError::IncompatibleMetadata);
}
self.confirmation = Some(ConfirmationChallenge::try_new(token, expires_in_ms)?);
Ok(self)
}
pub fn with_dispatch_uncertainty(mut self) -> Self {
if matches!(
self.code,
ExecutionErrorCode::UpstreamTimeout
| ExecutionErrorCode::UpstreamTransportError
| ExecutionErrorCode::IdempotencyOutcomeUnknown
| ExecutionErrorCode::PersistenceUnavailable
) {
self.retryability = Retryability::ManualReconcile;
self.outcome_certainty = OutcomeCertainty::OutcomeUnknown;
}
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExecutionSuccess {
pub operation_id: OperationId,
pub operation_version: u32,
pub origin: ExecutionOrigin,
pub correlation: CorrelationContext,
pub request_preview: Value,
pub output: Value,
}
+3 -1
View File
@@ -1,7 +1,8 @@
use std::sync::Arc;
use crate::{
MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyScope, User, WorkspaceId,
MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyId, PlatformApiKeyScope,
User, WorkspaceId,
};
use async_trait::async_trait;
@@ -10,6 +11,7 @@ pub struct VerifiedMachineCredential {
pub machine_access_mode: MachineAccessMode,
pub max_security_level: OperationSecurityLevel,
pub scopes: Vec<PlatformApiKeyScope>,
pub platform_api_key_id: Option<PlatformApiKeyId>,
}
#[derive(Debug, thiserror::Error)]
+75 -10
View File
@@ -1,4 +1,10 @@
use std::{collections::BTreeMap, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@@ -21,14 +27,26 @@ pub struct ResponseCacheScope {
pub agent_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug)]
pub struct RuntimeRequestContext {
pub request_id: RequestId,
pub trace_context: TraceContext,
pub response_cache_scope: Option<ResponseCacheScope>,
pub metering_context: Option<MeteringContext>,
dispatch_started: Option<Arc<AtomicBool>>,
}
impl PartialEq for RuntimeRequestContext {
fn eq(&self, other: &Self) -> bool {
self.request_id == other.request_id
&& self.trace_context == other.trace_context
&& self.response_cache_scope == other.response_cache_scope
&& self.metering_context == other.metering_context
}
}
impl Eq for RuntimeRequestContext {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MeteringContext {
pub workspace_id: WorkspaceId,
@@ -43,6 +61,7 @@ impl RuntimeRequestContext {
trace_context,
response_cache_scope: None,
metering_context: None,
dispatch_started: None,
}
}
@@ -111,22 +130,43 @@ impl RuntimeRequestContext {
pub fn metering_context(&self) -> Option<&MeteringContext> {
self.metering_context.as_ref()
}
pub fn with_dispatch_started(mut self, dispatch_started: Arc<AtomicBool>) -> Self {
self.dispatch_started = Some(dispatch_started);
self
}
pub fn mark_dispatch_started(&self) {
if let Some(dispatch_started) = &self.dispatch_started {
dispatch_started.store(true, Ordering::Release);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[derive(Clone, PartialEq, Default)]
pub struct PreparedRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub path_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub query_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trusted_header_names: BTreeSet<String>,
pub body: Option<Value>,
#[serde(default)]
pub timeout_ms: u64,
}
impl std::fmt::Debug for PreparedRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PreparedRequest")
.field("path_param_count", &self.path_params.len())
.field("query_param_count", &self.query_params.len())
.field("header_count", &self.headers.len())
.field("trusted_header_count", &self.trusted_header_names.len())
.field("body_configured", &self.body.is_some())
.field("timeout_ms", &self.timeout_ms)
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AdapterResponse {
pub status_code: u16,
@@ -143,8 +183,33 @@ pub enum ProtocolAdapterError {
protocol: Protocol,
mode: ExecutionMode,
},
#[error("{0}")]
Message(String),
#[error("adapter configuration is invalid")]
InvalidConfiguration,
#[error("prepared request is invalid")]
InvalidPreparedRequest,
#[error("upstream request exceeded the configured limit")]
RequestTooLarge,
#[error("outbound target was rejected")]
TargetRejected,
#[error("upstream transport failed")]
Transport { dispatch: DispatchEvidence },
#[error("upstream request timed out")]
Timeout { dispatch: DispatchEvidence },
#[error("upstream response exceeded the configured limit")]
ResponseTooLarge { dispatch: DispatchEvidence },
#[error("upstream returned status {status}")]
UnexpectedStatus {
status: u16,
dispatch: DispatchEvidence,
},
#[error("upstream response could not be decoded")]
InvalidResponse { dispatch: DispatchEvidence },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DispatchEvidence {
NotDispatched,
MayHaveDispatched,
}
#[async_trait]
+1
View File
@@ -55,6 +55,7 @@ define_id!(InvitationId);
define_id!(PlatformApiKeyId);
define_id!(ApprovalRequestId);
define_id!(InvocationLogId);
define_id!(ProductEventId);
define_id!(AuditEventId);
define_id!(SecretId);
+35 -12
View File
@@ -5,10 +5,13 @@ pub mod auth;
pub mod cache;
pub mod correlation;
pub mod edition;
pub mod execution;
pub mod ext;
pub mod ids;
pub mod observability;
pub mod onboarding;
pub mod operation;
pub mod product_event;
pub mod protocol;
pub mod secret;
pub mod tool_catalog;
@@ -38,15 +41,20 @@ pub mod domain {
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel,
ProductEdition,
};
pub use crate::execution::{
ExecutionErrorCode, ExecutionFailure, ExecutionLocale, ExecutionOrigin,
ExecutionOriginError, ExecutionStage, ExecutionSuccess, OutcomeCertainty, Retryability,
};
pub use crate::ids::{
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
UserSessionId, WorkspaceId,
InvocationLogId, OperationId, PlatformApiKeyId, ProductEventId, SampleId, SecretId, ToolId,
UserId, UserSessionId, WorkspaceId,
};
pub use crate::observability::{
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
};
pub use crate::onboarding::{OnboardingProjection, OnboardingStep, OnboardingStepId};
pub use crate::operation::{
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
@@ -54,6 +62,10 @@ pub mod domain {
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy,
RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
};
pub use crate::product_event::{
OnboardingMilestone, PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES, PRODUCT_EVENT_SCHEMA_VERSION,
PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX, ProductEvent, ProductEventKind,
};
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
pub use crate::tool_catalog::{ToolCatalogAnalysis, ToolCatalogBudget};
@@ -89,9 +101,9 @@ pub mod ports {
MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink,
};
pub use crate::ext::protocol::{
AdapterRegistry, AdapterResponse, ExecutionMode, MeteringContext, PreparedRequest,
ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext,
SharedProtocolAdapter,
AdapterRegistry, AdapterResponse, DispatchEvidence, ExecutionMode, MeteringContext,
PreparedRequest, ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope,
RuntimeRequestContext, SharedProtocolAdapter,
};
}
@@ -118,6 +130,10 @@ pub use correlation::{CorrelationContext, CorrelationError, RequestId, TraceCont
pub use edition::{
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
};
pub use execution::{
ConfirmationChallenge, ExecutionErrorCode, ExecutionFailure, ExecutionLocale, ExecutionOrigin,
ExecutionOriginError, ExecutionStage, ExecutionSuccess, OutcomeCertainty, Retryability,
};
pub use ext::access::{
OwnerOnlyPolicyEngine, PolicyAction, PolicyDecision, PolicyEngine, PolicyScope, SessionActor,
};
@@ -133,25 +149,32 @@ pub use ext::auth::{
pub use ext::capability::{CapabilityProfile, CommunityCapabilityProfile};
pub use ext::metering::{MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink};
pub use ext::protocol::{
AdapterRegistry, AdapterResponse, ExecutionMode, MeteringContext, PreparedRequest,
ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext,
SharedProtocolAdapter,
AdapterRegistry, AdapterResponse, DispatchEvidence, ExecutionMode, MeteringContext,
PreparedRequest, ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope,
RuntimeRequestContext, SharedProtocolAdapter,
};
pub use ids::{
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
UserSessionId, WorkspaceId,
InvocationLogId, OperationId, PlatformApiKeyId, ProductEventId, SampleId, SecretId, ToolId,
UserId, UserSessionId, WorkspaceId,
};
pub use observability::{
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
};
pub use onboarding::{OnboardingProjection, OnboardingStep, OnboardingStepId};
pub use operation::{
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget,
RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
OperationAvailability, OperationLifecycle, OperationLifecycleAction, OperationLifecycleError,
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, OperationVersionState,
ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample,
WizardState,
};
pub use product_event::{
OnboardingMilestone, PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES, PRODUCT_EVENT_SCHEMA_VERSION,
PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX, ProductEvent, ProductEventKind,
};
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
+135 -19
View File
@@ -2,12 +2,18 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use time::OffsetDateTime;
use crate::{AgentId, OperationId, WorkspaceId};
use crate::{
AgentId, ExecutionErrorCode, ExecutionStage, OperationId, OutcomeCertainty, Retryability,
WorkspaceId,
};
pub const INVOCATION_PREVIEW_MAX_BYTES: usize = 16 * 1024;
pub const INVOCATION_PREVIEW_MAX_DEPTH: usize = 12;
pub const INVOCATION_PREVIEW_MAX_OBJECT_FIELDS: usize = 64;
pub const INVOCATION_PREVIEW_MAX_ARRAY_ITEMS: usize = 64;
pub fn sanitize_invocation_preview(value: &Value) -> Value {
let redacted = redact_sensitive_fields(value);
let redacted = redact_sensitive_fields(value, 0);
let Ok(encoded) = serde_json::to_vec(&redacted) else {
return Value::Null;
};
@@ -17,27 +23,63 @@ pub fn sanitize_invocation_preview(value: &Value) -> Value {
let preview = String::from_utf8_lossy(&encoded[..INVOCATION_PREVIEW_MAX_BYTES]).into_owned();
json!({
"truncated": true,
"reason": "max_bytes",
"original_bytes": encoded.len(),
"preview": preview,
})
}
fn redact_sensitive_fields(value: &Value) -> Value {
fn truncation_marker(reason: &'static str, omitted: usize) -> Value {
json!({
"truncated": true,
"reason": reason,
"omitted": omitted,
})
}
fn redact_sensitive_fields(value: &Value, depth: usize) -> Value {
if depth >= INVOCATION_PREVIEW_MAX_DEPTH {
return truncation_marker("max_depth", 1);
}
match value {
Value::Object(object) => Value::Object(
object
Value::Object(object) => {
let mut redacted = Map::new();
let mut omitted = 0usize;
for (index, (key, value)) in object.iter().enumerate() {
if index >= INVOCATION_PREVIEW_MAX_OBJECT_FIELDS {
omitted += 1;
continue;
}
let value = if is_sensitive_key(key) {
Value::String("[REDACTED]".to_owned())
} else {
redact_sensitive_fields(value, depth + 1)
};
redacted.insert(key.clone(), value);
}
if omitted > 0 {
redacted.insert(
"_crank_truncated".to_owned(),
truncation_marker("max_object_fields", omitted),
);
}
Value::Object(redacted)
}
Value::Array(items) => {
let mut redacted = items
.iter()
.map(|(key, value)| {
let value = if is_sensitive_key(key) {
Value::String("[REDACTED]".to_owned())
} else {
redact_sensitive_fields(value)
};
(key.clone(), value)
})
.collect::<Map<String, Value>>(),
),
Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_fields).collect()),
.take(INVOCATION_PREVIEW_MAX_ARRAY_ITEMS)
.map(|value| redact_sensitive_fields(value, depth + 1))
.collect::<Vec<_>>();
let omitted = items.len().saturating_sub(redacted.len());
if omitted > 0 {
redacted.push(truncation_marker("max_array_items", omitted));
}
Value::Array(redacted)
}
Value::String(value) if looks_like_sensitive_value(value) => {
Value::String("[REDACTED]".to_owned())
}
_ => value.clone(),
}
}
@@ -63,6 +105,22 @@ fn is_sensitive_key(key: &str) -> bool {
.any(|sensitive| compact.contains(sensitive))
}
fn looks_like_sensitive_value(value: &str) -> bool {
let trimmed = value.trim();
let lower = trimmed.to_ascii_lowercase();
lower.starts_with("bearer ")
|| lower.starts_with("basic ")
|| lower.contains("password=")
|| lower.contains("token=")
|| lower.contains("secret=")
|| lower.contains("api_key=")
|| lower.contains("apikey=")
|| lower.contains("authorization:")
|| trimmed.starts_with("sk_")
|| trimmed.starts_with("crk_")
|| trimmed.contains("SECRET_")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InvocationSource {
@@ -111,7 +169,10 @@ pub struct InvocationLog {
pub id: crate::ids::InvocationLogId,
pub workspace_id: WorkspaceId,
pub agent_id: Option<AgentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform_api_key_id: Option<crate::ids::PlatformApiKeyId>,
pub operation_id: OperationId,
pub operation_version: Option<u32>,
pub source: InvocationSource,
pub level: InvocationLevel,
pub status: InvocationStatus,
@@ -122,6 +183,10 @@ pub struct InvocationLog {
pub status_code: Option<u16>,
pub duration_ms: u64,
pub error_kind: Option<String>,
pub execution_stage: Option<ExecutionStage>,
pub execution_error_code: Option<ExecutionErrorCode>,
pub retryability: Option<Retryability>,
pub outcome_certainty: Option<OutcomeCertainty>,
pub request_preview: Value,
pub response_preview: Value,
#[serde(with = "time::serde::rfc3339")]
@@ -148,10 +213,12 @@ mod tests {
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
InvocationStatus, UsagePeriod, sanitize_invocation_preview,
INVOCATION_PREVIEW_MAX_ARRAY_ITEMS, INVOCATION_PREVIEW_MAX_BYTES,
INVOCATION_PREVIEW_MAX_DEPTH, INVOCATION_PREVIEW_MAX_OBJECT_FIELDS, InvocationLevel,
InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
sanitize_invocation_preview,
};
use crate::{AgentId, OperationId, WorkspaceId, ids::InvocationLogId};
use crate::{AgentId, OperationId, OutcomeCertainty, WorkspaceId, ids::InvocationLogId};
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
@@ -163,7 +230,9 @@ mod tests {
id: InvocationLogId::new("log_01"),
workspace_id: WorkspaceId::new("ws_01"),
agent_id: Some(AgentId::new("agent_01")),
platform_api_key_id: None,
operation_id: OperationId::new("op_01"),
operation_version: Some(1),
source: InvocationSource::AdminTestRun,
level: InvocationLevel::Info,
status: InvocationStatus::Ok,
@@ -174,6 +243,10 @@ mod tests {
status_code: Some(200),
duration_ms: 123,
error_kind: None,
execution_stage: None,
execution_error_code: None,
retryability: None,
outcome_certainty: Some(OutcomeCertainty::Certain),
request_preview: json!({"input": "value"}),
response_preview: json!({"ok": true}),
created_at: timestamp("2026-04-19T12:34:56Z"),
@@ -202,6 +275,8 @@ mod tests {
"api_key": "key",
"refreshToken": "token",
"client_secret_value": "secret",
"body": "Bearer SECRET_APPROVAL_CANARY",
"neutral": "token=SECRET_APPROVAL_CANARY",
"value": 42
}
}));
@@ -210,6 +285,8 @@ mod tests {
assert_eq!(preview["nested"]["api_key"], "[REDACTED]");
assert_eq!(preview["nested"]["refreshToken"], "[REDACTED]");
assert_eq!(preview["nested"]["client_secret_value"], "[REDACTED]");
assert_eq!(preview["nested"]["body"], "[REDACTED]");
assert_eq!(preview["nested"]["neutral"], "[REDACTED]");
assert_eq!(preview["nested"]["value"], 42);
}
@@ -220,6 +297,45 @@ mod tests {
}));
assert_eq!(preview["truncated"], true);
assert_eq!(preview["reason"], "max_bytes");
assert!(preview["original_bytes"].as_u64().unwrap() > INVOCATION_PREVIEW_MAX_BYTES as u64);
}
#[test]
fn bounds_preview_depth_fields_and_array_items() {
let mut nested = json!({"secret_token": "SECRET_VALUE"});
for _ in 0..(INVOCATION_PREVIEW_MAX_DEPTH + 4) {
nested = json!({ "nested": nested });
}
let deep_preview = sanitize_invocation_preview(&nested);
assert!(
serde_json::to_string(&deep_preview)
.unwrap()
.contains("\"reason\":\"max_depth\"")
);
assert!(
!serde_json::to_string(&deep_preview)
.unwrap()
.contains("SECRET_VALUE")
);
let wide = serde_json::Value::Object(
(0..(INVOCATION_PREVIEW_MAX_OBJECT_FIELDS + 5))
.map(|index| (format!("field_{index:03}"), json!(index)))
.collect(),
);
let wide_preview = sanitize_invocation_preview(&wide);
assert_eq!(
wide_preview["_crank_truncated"]["reason"],
"max_object_fields"
);
let array_preview = sanitize_invocation_preview(&json!(
(0..(INVOCATION_PREVIEW_MAX_ARRAY_ITEMS + 3)).collect::<Vec<_>>()
));
assert_eq!(
array_preview.as_array().unwrap().last().unwrap()["reason"],
"max_array_items"
);
}
}
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::{AgentId, InvocationLogId, OperationId, PlatformApiKeyId, WorkspaceId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardingStepId {
Operation,
Test,
PublishOperation,
Agent,
Key,
McpConnection,
FirstCall,
}
impl OnboardingStepId {
pub const ORDERED: [Self; 7] = [
Self::Operation,
Self::Test,
Self::PublishOperation,
Self::Agent,
Self::Key,
Self::McpConnection,
Self::FirstCall,
];
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingStep {
pub id: OnboardingStepId,
pub completed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingProjection {
pub workspace_id: WorkspaceId,
pub revision: i64,
pub completed: bool,
pub was_completed: bool,
pub steps: Vec<OnboardingStep>,
pub operation_id: Option<OperationId>,
pub operation_version: Option<u32>,
pub agent_id: Option<AgentId>,
pub catalog_revision: Option<i64>,
pub platform_api_key_id: Option<PlatformApiKeyId>,
pub first_call_log_id: Option<InvocationLogId>,
pub first_call_tool_name: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub first_call_at: Option<OffsetDateTime>,
pub first_call_request_id: Option<String>,
pub first_call_trace_id: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub eligible_since: Option<OffsetDateTime>,
}
impl OnboardingProjection {
pub fn step(&self, id: OnboardingStepId) -> Option<&OnboardingStep> {
self.steps.iter().find(|step| step.id == id)
}
}
+149 -2
View File
@@ -28,7 +28,73 @@ pub enum OperationStatus {
Archived,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationAvailability {
Active,
Archived,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationVersionState {
Draft,
Published,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OperationLifecycleAction {
SaveDraft,
Publish,
Archive,
Delete,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum OperationLifecycleError {
#[error("operation is archived")]
Archived,
#[error("operation lifecycle transition is invalid")]
InvalidTransition,
#[error("operation deletion would destroy durable history")]
DurableHistory,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OperationLifecycle {
pub availability: OperationAvailability,
pub current: OperationVersionState,
pub ever_published: bool,
pub has_durable_references: bool,
}
impl OperationLifecycle {
pub fn validate(self, action: OperationLifecycleAction) -> Result<(), OperationLifecycleError> {
if self.availability == OperationAvailability::Archived {
return match action {
OperationLifecycleAction::Archive => Ok(()),
_ => Err(OperationLifecycleError::Archived),
};
}
match action {
OperationLifecycleAction::SaveDraft => Ok(()),
OperationLifecycleAction::Publish if self.current == OperationVersionState::Draft => {
Ok(())
}
OperationLifecycleAction::Archive => Ok(()),
OperationLifecycleAction::Delete
if !self.ever_published && !self.has_durable_references =>
{
Ok(())
}
OperationLifecycleAction::Delete => Err(OperationLifecycleError::DurableHistory),
OperationLifecycleAction::Publish => Err(OperationLifecycleError::InvalidTransition),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RestTarget {
pub base_url: String,
pub method: HttpMethod,
@@ -44,11 +110,13 @@ pub enum Target {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
pub max_attempts: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResponseCachePolicy {
pub ttl_ms: u64,
}
@@ -62,6 +130,7 @@ pub enum IdempotencyMode {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IdempotencyPolicy {
pub mode: IdempotencyMode,
pub ttl_ms: u64,
@@ -92,11 +161,13 @@ impl OperationSafetyClass {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfirmationPolicy {
pub ttl_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationSafetyPolicy {
pub class: OperationSafetyClass,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -130,6 +201,7 @@ pub enum OperationApprovalMode {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationApprovalPolicy {
pub required: bool,
#[serde(default)]
@@ -143,6 +215,7 @@ pub struct OperationApprovalPolicy {
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecutionConfig {
pub timeout_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -162,11 +235,13 @@ pub struct ExecutionConfig {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolExample {
pub input: Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolDescription {
pub title: String,
pub description: String,
@@ -284,6 +359,23 @@ impl<TSchema, TMapping> Operation<TSchema, TMapping> {
}
}
impl<TSchema: PartialEq, TMapping: PartialEq> Operation<TSchema, TMapping> {
pub fn portable_semantically_eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.display_name == other.display_name
&& self.category == other.category
&& self.protocol == other.protocol
&& self.security_level == other.security_level
&& self.target == other.target
&& self.input_schema == other.input_schema
&& self.output_schema == other.output_schema
&& self.input_mapping == other.input_mapping
&& self.output_mapping == other.output_mapping
&& self.execution_config == other.execution_config
&& self.tool_description == other.tool_description
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
@@ -296,8 +388,9 @@ mod tests {
edition::OperationSecurityLevel,
ids::{AuthProfileId, OperationId},
operation::{
ConfigExport, ExecutionConfig, Operation, OperationStatus, RestTarget, Samples, Target,
ToolDescription, ToolExample,
ConfigExport, ExecutionConfig, Operation, OperationAvailability, OperationLifecycle,
OperationLifecycleAction, OperationLifecycleError, OperationStatus,
OperationVersionState, RestTarget, Samples, Target, ToolDescription, ToolExample,
},
protocol::{AuthKind, ExportMode, HttpMethod, Protocol},
};
@@ -335,6 +428,60 @@ mod tests {
);
}
#[test]
fn lifecycle_rejects_publish_rewind_and_durable_delete() {
let published = OperationLifecycle {
availability: OperationAvailability::Active,
current: OperationVersionState::Published,
ever_published: true,
has_durable_references: true,
};
assert_eq!(
published.validate(OperationLifecycleAction::Publish),
Err(OperationLifecycleError::InvalidTransition)
);
assert_eq!(
published.validate(OperationLifecycleAction::Delete),
Err(OperationLifecycleError::DurableHistory)
);
assert_eq!(
published.validate(OperationLifecycleAction::SaveDraft),
Ok(())
);
}
#[test]
fn archived_lifecycle_is_terminal_but_archive_retry_is_idempotent() {
let archived = OperationLifecycle {
availability: OperationAvailability::Archived,
current: OperationVersionState::Published,
ever_published: true,
has_durable_references: true,
};
assert_eq!(archived.validate(OperationLifecycleAction::Archive), Ok(()));
assert_eq!(
archived.validate(OperationLifecycleAction::SaveDraft),
Err(OperationLifecycleError::Archived)
);
}
#[test]
fn portable_semantic_equality_ignores_persistence_identity() {
let left = test_operation(OperationStatus::Draft);
let mut right = left.clone();
right.id = OperationId::new("op_other");
right.version = 9;
right.status = OperationStatus::Published;
right.updated_at = timestamp("2026-03-25T09:00:00Z");
right.published_at = Some(timestamp("2026-03-25T09:00:00Z"));
assert!(left.portable_semantically_eq(&right));
right.display_name = "Changed".to_owned();
assert!(!left.portable_semantically_eq(&right));
}
#[test]
fn auth_profile_serializes_secret_ids_without_secret_values() {
let profile = AuthProfile {
+161
View File
@@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use time::{Date, OffsetDateTime};
use crate::{ProductEventId, WorkspaceId};
pub const PRODUCT_EVENT_SCHEMA_VERSION: u16 = 1;
pub const PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES: usize = 256;
pub const PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX: &str = "onboarding:";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProductEventKind {
OnboardingEligible,
OnboardingStarted,
OnboardingResumed,
OnboardingDismissed,
OnboardingAbandoned,
OnboardingCompleted,
}
impl ProductEventKind {
pub const ALL: [Self; 6] = [
Self::OnboardingEligible,
Self::OnboardingStarted,
Self::OnboardingResumed,
Self::OnboardingDismissed,
Self::OnboardingAbandoned,
Self::OnboardingCompleted,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::OnboardingEligible => "onboarding_eligible",
Self::OnboardingStarted => "onboarding_started",
Self::OnboardingResumed => "onboarding_resumed",
Self::OnboardingDismissed => "onboarding_dismissed",
Self::OnboardingAbandoned => "onboarding_abandoned",
Self::OnboardingCompleted => "onboarding_completed",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardingMilestone {
Operation,
Test,
PublishOperation,
Agent,
Key,
McpConnection,
FirstCall,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProductEvent {
pub id: ProductEventId,
pub workspace_id: WorkspaceId,
pub kind: ProductEventKind,
pub schema_version: u16,
pub milestone: Option<OnboardingMilestone>,
pub eligible: bool,
#[serde(with = "time::serde::rfc3339::option")]
pub eligible_since: Option<OffsetDateTime>,
pub idempotency_key: String,
#[serde(with = "time::serde::rfc3339")]
pub occurred_at: OffsetDateTime,
}
impl ProductEvent {
pub fn occurred_on_utc(&self) -> Date {
self.occurred_at.to_offset(time::UtcOffset::UTC).date()
}
pub fn is_valid(&self) -> bool {
let eligibility_is_valid = match self.kind {
ProductEventKind::OnboardingEligible => self.eligible && self.eligible_since.is_some(),
_ => !self.eligible || self.eligible_since.is_some(),
};
self.schema_version == PRODUCT_EVENT_SCHEMA_VERSION
&& !self.idempotency_key.is_empty()
&& self.idempotency_key.len() <= PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES
&& eligibility_is_valid
}
pub fn is_semantic_replay_of(&self, recorded: &Self) -> bool {
self.workspace_id == recorded.workspace_id
&& self.kind == recorded.kind
&& self.schema_version == recorded.schema_version
&& self.milestone == recorded.milestone
&& self.eligible == recorded.eligible
&& self.eligible_since == recorded.eligible_since
&& self.idempotency_key == recorded.idempotency_key
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ProductEventId, WorkspaceId};
#[test]
fn vocabulary_is_closed_and_stable() {
assert_eq!(ProductEventKind::ALL.len(), 6);
assert_eq!(
ProductEventKind::OnboardingCompleted.as_str(),
"onboarding_completed"
);
}
#[test]
fn eligible_event_requires_explicit_cohort_timestamp() {
let event = ProductEvent {
id: ProductEventId::new("pe_eligible"),
workspace_id: WorkspaceId::new("ws_default"),
kind: ProductEventKind::OnboardingEligible,
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: true,
eligible_since: None,
idempotency_key: "eligible:first-login".to_owned(),
occurred_at: OffsetDateTime::UNIX_EPOCH,
};
assert!(!event.is_valid());
assert!(
ProductEvent {
eligible_since: Some(OffsetDateTime::UNIX_EPOCH),
..event
}
.is_valid()
);
}
#[test]
fn semantic_replay_ignores_transport_identity_and_retry_time() {
let recorded = ProductEvent {
id: ProductEventId::new("pe_recorded"),
workspace_id: WorkspaceId::new("ws_default"),
kind: ProductEventKind::OnboardingStarted,
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: false,
eligible_since: None,
idempotency_key: "ui:started:1".to_owned(),
occurred_at: OffsetDateTime::UNIX_EPOCH,
};
let replay = ProductEvent {
id: ProductEventId::new("pe_retry"),
occurred_at: OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1),
..recorded.clone()
};
assert!(replay.is_semantic_replay_of(&recorded));
assert!(
!ProductEvent {
kind: ProductEventKind::OnboardingDismissed,
..replay
}
.is_semantic_replay_of(&recorded)
);
}
}
+142
View File
@@ -0,0 +1,142 @@
use crank_core::{
AgentId, CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionOrigin,
ExecutionStage, OutcomeCertainty, Retryability,
};
#[test]
fn frozen_execution_taxonomy_is_closed_and_consistent() {
let cases = [
(
ExecutionErrorCode::AuthorizationDenied,
ExecutionStage::Authorization,
Retryability::Never,
OutcomeCertainty::Certain,
),
(
ExecutionErrorCode::InputSchemaInvalid,
ExecutionStage::InputSchema,
Retryability::Never,
OutcomeCertainty::Certain,
),
(
ExecutionErrorCode::ExecutionOverloaded,
ExecutionStage::Admission,
Retryability::AfterDelay,
OutcomeCertainty::Certain,
),
(
ExecutionErrorCode::ConfirmationRequired,
ExecutionStage::Admission,
Retryability::RequiresConfirmation,
OutcomeCertainty::Certain,
),
(
ExecutionErrorCode::IdempotencyOutcomeUnknown,
ExecutionStage::Admission,
Retryability::ManualReconcile,
OutcomeCertainty::OutcomeUnknown,
),
(
ExecutionErrorCode::RuntimeInternal,
ExecutionStage::Runtime,
Retryability::Never,
OutcomeCertainty::Certain,
),
];
for (code, stage, retryability, certainty) in cases {
let descriptor = ExecutionFailure::new(code, CorrelationContext::generate());
assert_eq!(descriptor.stage(), stage);
assert_eq!(descriptor.retryability(), retryability);
assert_eq!(descriptor.outcome_certainty(), certainty);
assert_eq!(descriptor.error_code(), code);
}
}
#[test]
fn execution_origin_enforces_agent_scope() {
let agent_id = AgentId::new("agent_01");
assert!(ExecutionOrigin::AdminDraft.validate_agent(None).is_ok());
assert!(
ExecutionOrigin::AdminDraft
.validate_agent(Some(&agent_id))
.is_err()
);
assert!(
ExecutionOrigin::AgentSnapshot
.validate_agent(Some(&agent_id))
.is_ok()
);
assert!(ExecutionOrigin::AgentSnapshot.validate_agent(None).is_err());
}
#[test]
fn dispatch_uncertainty_is_allowed_only_for_ambiguous_codes() {
let correlation = CorrelationContext::generate();
let timeout = ExecutionFailure::new(ExecutionErrorCode::UpstreamTimeout, correlation.clone())
.with_dispatch_uncertainty();
assert_eq!(timeout.retryability(), Retryability::ManualReconcile);
assert_eq!(
timeout.outcome_certainty(),
OutcomeCertainty::OutcomeUnknown
);
let validation = ExecutionFailure::new(ExecutionErrorCode::InputSchemaInvalid, correlation)
.with_dispatch_uncertainty();
assert_eq!(validation.retryability(), Retryability::Never);
assert_eq!(validation.outcome_certainty(), OutcomeCertainty::Certain);
}
#[test]
fn failure_metadata_is_code_specific_and_bounded() {
let correlation = CorrelationContext::generate();
assert!(
ExecutionFailure::new(ExecutionErrorCode::InputSchemaInvalid, correlation.clone())
.try_with_retry_after_ms(1)
.is_err()
);
assert!(
ExecutionFailure::new(ExecutionErrorCode::UpstreamRateLimited, correlation.clone())
.try_with_retry_after_ms(3_600_001)
.is_err()
);
assert!(
ExecutionFailure::new(ExecutionErrorCode::RuntimeInternal, correlation.clone())
.try_with_confirmation("token", 1)
.is_err()
);
assert!(
ExecutionFailure::new(
ExecutionErrorCode::ConfirmationRequired,
correlation.clone()
)
.try_with_confirmation("x".repeat(4_097), 1)
.is_err()
);
let challenge = ExecutionFailure::new(ExecutionErrorCode::ConfirmationRequired, correlation)
.try_with_confirmation("secret-canary", 1_000)
.expect("bounded confirmation");
assert!(!format!("{challenge:?}").contains("secret-canary"));
}
#[test]
fn unknown_taxonomy_values_are_rejected_by_serde() {
assert!(serde_json::from_str::<ExecutionStage>("\"unknown\"").is_err());
assert!(serde_json::from_str::<ExecutionErrorCode>("\"unknown\"").is_err());
assert!(serde_json::from_str::<Retryability>("\"automatic\"").is_err());
}
#[test]
fn every_published_code_has_stable_wire_value_stage_and_localized_messages() {
let mut wire_values = std::collections::BTreeSet::new();
for code in ExecutionErrorCode::ALL {
assert!(wire_values.insert(code.as_str()));
assert_eq!(serde_json::to_value(code).unwrap(), code.as_str());
assert!(!code.message(crank_core::ExecutionLocale::Ru).is_empty());
assert!(!code.message(crank_core::ExecutionLocale::En).is_empty());
assert!(!code.stage().as_str().is_empty());
assert!(!code.retryability().as_str().is_empty());
assert!(!code.outcome_certainty().as_str().is_empty());
}
assert_eq!(wire_values.len(), ExecutionErrorCode::ALL.len());
}
+4
View File
@@ -12,6 +12,7 @@ pub enum MappingTargetContext {
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MappingCondition {
pub source: String,
pub equals: Value,
@@ -31,11 +32,13 @@ pub enum TransformKind {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transform {
pub kind: TransformKind,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MappingRule {
pub source: String,
pub target: String,
@@ -52,6 +55,7 @@ pub struct MappingRule {
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct MappingSet {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<MappingRule>,
+14 -4
View File
@@ -9,8 +9,8 @@ use crate::labels::{
pub const METRIC_SCHEMA_VERSION: u32 = 1;
pub const MAX_METRIC_FAMILIES: usize = 128;
pub const MAX_PRODUCT_LABELS_PER_FAMILY: usize = 4;
pub const MAX_LOGICAL_SERIES_PER_PROCESS: usize = 5_000;
pub const MAX_RENDERED_SERIES_PER_PROCESS: usize = 15_000;
pub const MAX_LOGICAL_SERIES_PER_PROCESS: usize = 5_250;
pub const MAX_RENDERED_SERIES_PER_PROCESS: usize = 16_750;
pub const MAX_EXPOSITION_BYTES: usize = 8 * 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -167,13 +167,19 @@ pub const HTTP_ROUTE_DOMAIN: &[&str] = &[
"/health",
"/ready",
"/api/auth/login",
"/api/auth/bootstrap/status",
"/api/auth/bootstrap/complete",
"/api/auth/logout",
"/api/auth/session",
"/api/auth/session/csrf",
"/api/auth/profile",
"/api/auth/password",
"/api/admin/capabilities",
"/api/admin/workspaces",
"/api/admin/workspaces/{workspace_id}",
"/api/admin/workspaces/{workspace_id}/onboarding",
"/api/admin/workspaces/{workspace_id}/onboarding/events",
"/api/admin/workspaces/{workspace_id}/onboarding/reset-selection",
"/api/admin/workspaces/{workspace_id}/operations",
"/api/admin/workspaces/{workspace_id}/imports/openapi/preview",
"/api/admin/workspaces/{workspace_id}/imports/openapi/{job_id}/create",
@@ -209,10 +215,14 @@ pub const HTTP_ROUTE_DOMAIN: &[&str] = &[
"/api/admin/workspaces/{workspace_id}/secrets/{secret_id}/rotate",
"/api/admin/workspaces/{workspace_id}/export",
"/api/admin/workspaces/{workspace_id}/logs",
"/api/admin/workspaces/{workspace_id}/logs/export.csv",
"/api/admin/workspaces/{workspace_id}/logs/{log_id}",
"/api/admin/workspaces/{workspace_id}/approvals",
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}",
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}/approve",
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}/deny",
"/api/admin/workspaces/{workspace_id}/usage",
"/api/admin/workspaces/{workspace_id}/usage/export.csv",
"/api/admin/workspaces/{workspace_id}/usage/operations/{operation_id}",
"/api/admin/workspaces/{workspace_id}/usage/agents/{agent_id}",
"/v1/{workspace_slug}/{agent_slug}",
@@ -294,7 +304,7 @@ const METRIC_SCHEMA: &[MetricDefinition] = &[
],
&[],
BOTH,
57 * 10 * 6,
67 * 10 * 6,
"Total HTTP requests.",
),
metric(
@@ -308,7 +318,7 @@ const METRIC_SCHEMA: &[MetricDefinition] = &[
],
DURATION_BUCKETS_SECONDS,
BOTH,
57 * 10,
67 * 10,
"HTTP request duration in seconds.",
),
metric(
+18 -9
View File
@@ -46,16 +46,16 @@ fn schema_v1_is_complete_deterministic_and_inside_the_frozen_budget() {
#[test]
fn frozen_budget_edges_are_exact() {
assert!(validate_budget(4_999, 14_999).is_ok());
assert!(validate_budget(5_000, 15_000).is_ok());
assert!(validate_budget(5_001, 15_000).is_err());
assert!(validate_budget(5_000, 15_001).is_err());
assert_eq!(crank_metrics::max_exemplar_slots(), 9_170);
assert!(validate_budget(5_249, 16_749).is_ok());
assert!(validate_budget(5_250, 16_750).is_ok());
assert!(validate_budget(5_251, 16_000).is_err());
assert!(validate_budget(5_200, 16_751).is_err());
assert_eq!(crank_metrics::max_exemplar_slots(), 10_570);
}
#[test]
fn every_registered_route_round_trips_without_dynamic_segments() {
assert_eq!(HTTP_ROUTE_DOMAIN.len(), 57);
assert_eq!(HTTP_ROUTE_DOMAIN.len(), 67);
for route in HTTP_ROUTE_DOMAIN {
assert_eq!(HttpRoute::from_matched_path(route).as_str(), *route);
assert!(!route.contains("trace_id"));
@@ -68,8 +68,8 @@ fn canonical_schema_validation_reports_its_category() {
assert_eq!(
schema_budget(),
Ok(crank_metrics::SchemaBudget {
logical_series: 4_513,
rendered_series: 14_338
logical_series: 5_213,
rendered_series: 16_538
})
);
}
@@ -146,7 +146,16 @@ fn process_projection_excludes_mcp_only_metrics_from_admin() {
fn registered_admin_and_mcp_routes_are_exactly_the_schema_domain() {
let admin_source = include_str!("../../../apps/admin-api/src/app.rs");
let mcp_source = include_str!("../../crank-community-mcp/src/app.rs");
let auth = ["/login", "/logout", "/session", "/profile", "/password"];
let auth = [
"/bootstrap/status",
"/bootstrap/complete",
"/login",
"/logout",
"/session",
"/session/csrf",
"/profile",
"/password",
];
let admin_root = ["/capabilities", "/workspaces", "/workspaces/{workspace_id}"];
let mut actual = BTreeSet::from(["unmatched".to_owned()]);
for route in route_literals(admin_source) {
+64
View File
@@ -16,6 +16,16 @@ pub enum RegistryError {
UserNotFound { user_id: String },
#[error("user with email {email} already exists")]
UserEmailAlreadyExists { email: String },
#[error("admin bootstrap contract is not available")]
AdminBootstrapUnavailable,
#[error("admin bootstrap contract is invalid, expired or already used")]
AdminBootstrapRejected,
#[error("admin recovery request is invalid or unavailable")]
AdminRecoveryRejected,
#[error("admin login is temporarily rate limited")]
AdminLoginRateLimited { retry_after_ms: i64 },
#[error("admin csrf token is invalid or missing")]
AdminCsrfRejected,
#[error("membership for user {user_id} in workspace {workspace_id} was not found")]
MembershipNotFound {
workspace_id: String,
@@ -25,8 +35,28 @@ pub enum RegistryError {
InvitationNotFound { invitation_id: String },
#[error("platform api key {key_id} was not found")]
PlatformApiKeyNotFound { key_id: String },
#[error("platform api key {key_id} is not active")]
PlatformApiKeyInactive { key_id: String },
#[error("platform api key with name {name} already exists in workspace {workspace_id}")]
PlatformApiKeyNameAlreadyExists { workspace_id: String, name: String },
#[error("secret {secret_id} was not found")]
SecretNotFound { secret_id: String },
#[error("secret {secret_id} is not active")]
SecretInactive { secret_id: String },
#[error("secret {secret_id} was updated concurrently")]
SecretConcurrentUpdate { secret_id: String },
#[error("master key identity is not compatible with registered epoch {epoch}")]
MasterKeyIdentityMismatch { epoch: i64 },
#[error("master key identity metadata is invalid")]
InvalidMasterKeyIdentity,
#[error("master key rotation is already active")]
MasterKeyRotationInProgress,
#[error("master key rotation {rotation_id} was not found")]
MasterKeyRotationNotFound { rotation_id: String },
#[error("master key rotation state is incompatible with requested transition")]
MasterKeyRotationConflict,
#[error("master key rotation verification failed")]
MasterKeyRotationVerificationFailed,
#[error("secret with name {name} already exists in workspace {workspace_id}")]
SecretNameAlreadyExists { workspace_id: String, name: String },
#[error("secret {secret_id} is referenced by auth profile {auth_profile_id}")]
@@ -59,12 +89,40 @@ pub enum RegistryError {
expected: u32,
actual: u32,
},
#[error("operation {operation_id} is archived")]
OperationArchived { operation_id: String },
#[error("operation {operation_id} has a stale base version: expected {expected}, got {actual}")]
OperationStaleVersion {
operation_id: String,
expected: u32,
actual: u32,
},
#[error("operation {operation_id} cannot transition from {from} using {action}")]
InvalidOperationTransition {
operation_id: String,
from: String,
action: &'static str,
},
#[error("operation {operation_id} cannot be deleted because durable history exists")]
OperationDeleteForbidden { operation_id: String },
#[error("operation {operation_id} auth profile reference is unavailable")]
OperationAuthProfileUnavailable { operation_id: String },
#[error("agent {agent_id} expected next version {expected}, got {actual}")]
InvalidAgentVersionSequence {
agent_id: String,
expected: u32,
actual: u32,
},
#[error("agent version {version} for {agent_id} is immutable")]
ImmutableAgentVersion { agent_id: String, version: u32 },
#[error("agent {agent_id} state changed before mutation")]
AgentStaleRevision { agent_id: String },
#[error("agent {agent_id} cannot transition from {from} using {action}")]
InvalidAgentTransition {
agent_id: String,
from: String,
action: &'static str,
},
#[error("operation {operation_id} changed immutable field {field}")]
ImmutableOperationFieldChanged {
operation_id: String,
@@ -84,4 +142,10 @@ pub enum RegistryError {
InvalidNumericValue { field: &'static str, value: i64 },
#[error("invalid correlation identity for field {field}")]
InvalidCorrelationIdentity { field: &'static str },
#[error("new invocation record is missing or has incompatible execution field {field}")]
InvalidExecutionRecord { field: &'static str },
#[error("onboarding state changed before presentation milestone mutation")]
OnboardingStaleRevision,
#[error("onboarding domain steps are not complete")]
OnboardingIncomplete,
}
+59 -42
View File
@@ -13,32 +13,41 @@ pub use migrations::{
pub mod records {
pub use crate::model::{
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApprovalRequestRecord,
AuthUserRecord, DescriptorKind, DescriptorMetadata, ImportJob, ImportJobApplyResult,
ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord, InvocationHistoryLoss,
InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord,
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
PublishedAgentCatalog, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
SecretVersionRecord, SessionRecord, SkippedImportOperation, UsageAgentBreakdown,
UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
AdminBootstrapContractRecord, AgentSummary, AgentVersionRecord, AppendProductEventOutcome,
AppliedImportOperation, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
DescriptorMetadata, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind,
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
InvocationRetentionPolicy, InvocationRetentionStatus, MasterKeyIdentityRecord,
MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord,
OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
Page, PlatformApiKeyRecord, ProductEventRecord, PublishedAgentCatalog, PublishedAgentTool,
RegistryOperation, SampleKind, SecretRecord, SecretVersionRecord, SessionRecord,
SkippedImportOperation, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown,
UsageOutcomeBreakdown, UsageOutcomeGroup, UsageRollupRecord, UsageSummary,
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
YamlImportJobStatus,
};
}
pub mod requests {
pub use crate::model::{
ApplyImportJobRequest, CreateAgentDraftVersionRequest, CreateAgentRequest,
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DecideApprovalRequest, ExpireApprovalRequest, FinishApprovalRequest,
FinishImportJobRequest, ImportConflictMode, ImportOperationDraft,
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
AdminSecurityAuditRequest, AppendProductEventRequest, ApplyImportJobRequest,
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
ImportOperationDraft, ListApprovalRequestsQuery, ListInvocationLogsQuery,
ListProductEventsQuery, MasterKeyIdentityCandidate, PublishAgentRequest, PublishRequest,
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest,
RecoverAdminPasswordRequest, RotateSecretRequest, SaveAgentBindingsRequest,
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateAgentSummaryRequest,
UpdateWorkspaceRequest, UsageQuery,
};
}
@@ -52,26 +61,34 @@ pub mod infrastructure {
}
pub use model::{
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DecideApprovalRequest, DescriptorKind, DescriptorMetadata, ExpireApprovalRequest,
FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportJob,
ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus, ImportOperationDraft,
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
YamlImportJobStatus,
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentStateExpectation, AgentSummary,
AgentVersionRecord, AppendProductEventOutcome, AppendProductEventRequest,
AppliedImportOperation, ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus,
ImportOperationDraft, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
ListInvocationLogsQuery, ListProductEventsQuery, MASTER_KEY_CIPHER_CONTRACT,
MasterKeyIdentityCandidate, MasterKeyIdentityRecord, MasterKeyRotationRecord,
MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult,
OnboardingPresentationMilestone, OperationAgentRef, OperationSampleMetadata,
OperationStateExpectation, OperationSummary, OperationUsageSummary, OperationVersionRecord,
Page, PlatformApiKeyRecord, ProductEventRecord, PublishAgentRequest, PublishRequest,
PublishedAgentCatalog, PublishedAgentTool, RecordOnboardingCompletionRequest,
RecordOnboardingMilestoneRequest, RecoverAdminPasswordRequest, RegistryOperation,
RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
SkippedImportOperation, UpdateAgentSummaryRequest, UpdateWorkspaceRequest, UsageAgentBreakdown,
UsageBucket, UsageOperationBreakdown, UsageOutcomeBreakdown, UsageOutcomeGroup, UsageQuery,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
+13
View File
@@ -1,6 +1,19 @@
mod admin_auth_lifecycle_v8;
mod agent_catalog_lifecycle_v9;
mod approval_side_effects_v10;
mod authority;
mod baseline_v1;
mod execution_outcome_v5;
mod master_key_identity_v7;
mod onboarding_product_events_v11;
mod owned_relations;
mod platform_key_name_reuse_v6;
mod schema_guard;
mod schema_guard_v10;
mod schema_guard_v11;
mod schema_guard_v7;
mod schema_guard_v8;
mod schema_guard_v9;
pub use authority::{
BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor,
@@ -0,0 +1,44 @@
use sqlx::{Postgres, Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("admin_auth_lifecycle_v8.sql");
pub(super) const SOURCE_SHA256: &str =
"6361f3321a1442a77c695cad9b30c4702de1aa3e565bfbf1a7415d653302611f";
pub(super) async fn apply(
transaction: &mut Transaction<'_, Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.admin_auth_lifecycle",
Some(8),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(8),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,122 @@
create table admin_bootstrap_contracts (
id text primary key,
token_hash text not null unique,
email text not null,
display_name text not null,
status text not null,
attempts integer not null default 0,
expires_at timestamptz not null,
created_at timestamptz not null default now(),
used_at timestamptz,
used_by_user_id text references users(id),
constraint admin_bootstrap_contracts_id_check check (
octet_length(id) between 1 and 128
and id !~ '[[:cntrl:]]'
),
constraint admin_bootstrap_contracts_token_hash_check check (
token_hash ~ '^[A-Za-z0-9_-]{43,86}$'
),
constraint admin_bootstrap_contracts_email_check check (
octet_length(email) between 3 and 254
and email like '%@%'
and email !~ '[[:space:][:cntrl:]<>]'
),
constraint admin_bootstrap_contracts_display_name_check check (
octet_length(display_name) between 1 and 160
and display_name !~ '[[:cntrl:]<>]'
),
constraint admin_bootstrap_contracts_status_check check (
status in ('active', 'used', 'expired', 'revoked')
),
constraint admin_bootstrap_contracts_attempts_check check (
attempts between 0 and 100
),
constraint admin_bootstrap_contracts_used_shape_check check (
(status = 'used' and used_at is not null and used_by_user_id is not null)
or (status <> 'used' and used_by_user_id is null)
)
);
create unique index admin_bootstrap_contracts_single_active_idx
on admin_bootstrap_contracts (status)
where status = 'active';
create index admin_bootstrap_contracts_token_hash_idx
on admin_bootstrap_contracts (token_hash);
alter table user_sessions
add column csrf_hash text,
add column revoked_at timestamptz,
add constraint user_sessions_csrf_hash_check check (
csrf_hash is null or csrf_hash ~ '^[A-Za-z0-9_-]{43,86}$'
);
create table admin_login_backoff (
scope_hash text primary key,
failure_count integer not null default 0,
locked_until timestamptz,
last_attempt_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint admin_login_backoff_scope_hash_check check (
scope_hash ~ '^[A-Za-z0-9_-]{43,86}$'
),
constraint admin_login_backoff_failure_count_check check (
failure_count between 0 and 1000
)
);
create table admin_security_audit_events (
id text primary key,
action text not null,
outcome text not null,
actor_user_id text references users(id),
session_id text,
request_id text,
trace_id text,
source text not null,
created_at timestamptz not null default now(),
constraint admin_security_audit_events_id_check check (
octet_length(id) between 1 and 128
and id !~ '[[:cntrl:]]'
),
constraint admin_security_audit_events_action_check check (
action in (
'bootstrap_created',
'bootstrap_completed',
'bootstrap_rejected',
'login_succeeded',
'login_rejected',
'logout',
'password_rotated',
'recovery_completed',
'session_revoked'
)
),
constraint admin_security_audit_events_outcome_check check (
outcome in ('success', 'rejected', 'rate_limited', 'expired', 'conflict')
),
constraint admin_security_audit_events_session_id_check check (
session_id is null
or (
octet_length(session_id) between 1 and 128
and session_id !~ '[[:cntrl:]]'
)
),
constraint admin_security_audit_events_request_id_check check (
request_id is null
or (
octet_length(request_id) between 1 and 128
and request_id !~ '[[:cntrl:],;]'
)
),
constraint admin_security_audit_events_trace_id_check check (
trace_id is null or trace_id ~ '^[0-9a-f]{32}$'
),
constraint admin_security_audit_events_source_check check (
octet_length(source) between 1 and 128
and source !~ '[[:cntrl:]]'
)
);
create index admin_security_audit_events_created_idx
on admin_security_audit_events (created_at desc);
@@ -0,0 +1,44 @@
use sqlx::{Postgres, Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("agent_catalog_lifecycle_v9.sql");
pub(super) const SOURCE_SHA256: &str =
"b49bf4b53691407ec27e3810b0531c0d440da7dddc62ea71eb219933c0a30211";
pub(super) async fn apply(
transaction: &mut Transaction<'_, Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.agent_catalog_lifecycle",
Some(9),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(9),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,100 @@
alter table agents
add column catalog_revision bigint not null default 0,
add constraint agents_catalog_revision_check check (catalog_revision >= 0);
alter table published_agents
add column catalog_revision bigint not null default 1,
add constraint published_agents_catalog_revision_check check (catalog_revision > 0);
update agents a
set catalog_revision = case when pa.agent_id is null then 0 else greatest(pa.catalog_revision, 1) end
from agents source
left join published_agents pa on pa.agent_id = source.id
where a.id = source.id;
create function crank_reject_published_agent_version_mutation()
returns trigger
language plpgsql
as $$
begin
if old.status = 'published' then
if tg_op = 'DELETE' then
raise exception 'published Agent Version is immutable'
using errcode = '23514';
end if;
if row(old.*) is distinct from row(new.*) then
raise exception 'published Agent Version is immutable'
using errcode = '23514';
end if;
end if;
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
create trigger agent_versions_immutable_guard
before update or delete on agent_versions
for each row
execute function crank_reject_published_agent_version_mutation();
create function crank_reject_published_agent_binding_mutation()
returns trigger
language plpgsql
as $$
declare
bound_status text;
checked_agent_id text;
checked_agent_version integer;
begin
checked_agent_id := coalesce(new.agent_id, old.agent_id);
checked_agent_version := coalesce(new.agent_version, old.agent_version);
select status
into bound_status
from agent_versions
where agent_id = checked_agent_id and version = checked_agent_version;
if bound_status = 'published' then
raise exception 'published Agent catalog bindings are immutable'
using errcode = '23514';
end if;
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
create trigger agent_operation_bindings_immutable_guard
before insert or update or delete on agent_operation_bindings
for each row
execute function crank_reject_published_agent_binding_mutation();
create function crank_reject_published_agent_pointer_rewind()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'DELETE' then
raise exception 'published Agent catalog pointer is immutable'
using errcode = '23514';
end if;
if tg_op = 'INSERT' then
return new;
end if;
if new.catalog_revision <= old.catalog_revision then
raise exception 'published Agent catalog revision must increase'
using errcode = '23514';
end if;
if new.version < old.version then
raise exception 'published Agent catalog version cannot rewind'
using errcode = '23514';
end if;
return new;
end;
$$;
create trigger published_agents_monotonic_guard
before insert or update or delete on published_agents
for each row
execute function crank_reject_published_agent_pointer_rewind();
@@ -0,0 +1,44 @@
use sqlx::{Postgres, Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("approval_side_effects_v10.sql");
pub(super) const SOURCE_SHA256: &str =
"6c87f680efdbc275fa2b920826b3e3e390aa34d6f2f1db48fe074a5d7691ba5b";
pub(super) async fn apply(
transaction: &mut Transaction<'_, Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.approval_side_effects",
Some(10),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(10),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,37 @@
alter table approval_requests
add column if not exists request_id text null;
alter table approval_requests
add column if not exists trace_id text null;
alter table approval_requests
add constraint approval_requests_request_id_check check (
request_id is null
or (
octet_length(request_id) between 1 and 128
and request_id !~ '[[:cntrl:],;]'
)
) not valid;
alter table approval_requests
add constraint approval_requests_trace_id_check check (
trace_id is null
or (
trace_id ~ '^[0-9a-f]{32}$'
and trace_id <> '00000000000000000000000000000000'
)
) not valid;
create unique index if not exists approval_requests_pending_scope_fingerprint_idx
on approval_requests(
workspace_id,
agent_id,
operation_id,
operation_version,
request_fingerprint
)
where status = 'pending' and request_fingerprint is not null;
create index if not exists approval_requests_workspace_request_trace_idx
on approval_requests(workspace_id, request_id, trace_id)
where request_id is not null or trace_id is not null;
+174 -113
View File
@@ -1,17 +1,22 @@
use std::fmt;
use sha2::{Digest, Sha256};
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
use super::admin_auth_lifecycle_v8;
use super::agent_catalog_lifecycle_v9;
use super::approval_side_effects_v10;
use super::execution_outcome_v5;
use super::master_key_identity_v7;
use super::onboarding_product_events_v11;
use super::owned_relations;
use super::platform_key_name_reuse_v6;
use super::schema_guard::{
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
};
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
use crate::ext::ExtensionMigration;
use sha2::{Digest, Sha256};
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
use std::fmt;
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
const CURRENT_VERSION: i64 = 3;
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3];
const CURRENT_VERSION: i64 = 11;
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const BASELINE_SOURCE_SHA256: &str =
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
@@ -20,41 +25,10 @@ const CONSOLIDATION_SOURCE_SHA256: &str =
const REQUEST_TRACE_IDENTITY_SOURCE: &str = include_str!("request_trace_identity_v3.sql");
const REQUEST_TRACE_IDENTITY_SOURCE_SHA256: &str =
"36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94";
const BASELINE_RELATIONS: &[&str] = &[
"workspaces",
"users",
"memberships",
"user_sessions",
"invitation_tokens",
"platform_api_keys",
"operations",
"operation_versions",
"published_operations",
"operation_samples",
"descriptors",
"agents",
"agent_versions",
"published_agents",
"agent_operation_bindings",
"secrets",
"secret_versions",
"auth_profiles",
"workspace_upstreams",
"yaml_import_jobs",
"import_jobs",
"approval_requests",
"invocation_logs",
"usage_rollups",
];
const CONSOLIDATION_RELATIONS: &[&str] = &[
"__crank_migrations",
"__crank_migration_legacy_audit",
"__crank_mcp_migrations",
"mcp_transport_sessions",
"__crank_ext_migrations",
];
const OPERATION_LIFECYCLE_SOURCE: &str = include_str!("operation_lifecycle_v4.sql");
const OPERATION_LIFECYCLE_SOURCE_SHA256: &str =
"45723712a1ea49cd8bbf59d77148c225f3ec376df7433983d982cc6f9d7fb39c";
const REGISTERED_EXTENSION_MIGRATIONS: &[(&str, ExtensionMigration)] = &[];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MigrationDescriptor {
pub version: i64,
@@ -70,7 +44,6 @@ pub struct MigrationDescriptor {
pub readable_schema_max: i64,
pub contract_evidence: Option<&'static str>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BackfillPolicy {
None,
@@ -80,14 +53,12 @@ pub enum BackfillPolicy {
resumable: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BackfillBatch {
pub cursor: Option<String>,
pub max_rows: u32,
pub max_ms: u32,
}
impl BackfillBatch {
pub fn validate(&self, policy: BackfillPolicy) -> Result<(), MigrationError> {
match policy {
@@ -113,19 +84,16 @@ impl BackfillBatch {
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MigrationPreflight {
Current { version: i64 },
MigrationRequired { current: i64, target: i64 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MigrationApplyResult {
Applied { from: i64, to: i64 },
AlreadyCurrent { version: i64 },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MigrationError {
code: &'static str,
@@ -133,7 +101,6 @@ pub struct MigrationError {
version: Option<i64>,
recovery: &'static str,
}
impl MigrationError {
pub(super) fn new(
code: &'static str,
@@ -148,28 +115,22 @@ impl MigrationError {
recovery,
}
}
pub(super) fn storage(stage: &'static str) -> Self {
Self::new("storage_unavailable", stage, None, "contact_operator")
}
pub fn code(&self) -> &'static str {
self.code
}
pub fn stage(&self) -> &'static str {
self.stage
}
pub fn version(&self) -> Option<i64> {
self.version
}
pub fn recovery(&self) -> &'static str {
self.recovery
}
}
impl fmt::Display for MigrationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
@@ -183,16 +144,33 @@ impl fmt::Display for MigrationError {
)
}
}
impl std::error::Error for MigrationError {}
pub struct MigrationAuthority;
fn expand_descriptor(
version: i64,
name: &'static str,
checksum: &'static str,
readable_schema_min: i64,
) -> MigrationDescriptor {
MigrationDescriptor {
version,
name,
checksum: checksum.to_owned(),
source_digest: checksum.to_owned(),
phase: "expand",
compatibility: "n-minus-one-readable",
owner: "crank-registry",
transactional: true,
backfill: BackfillPolicy::None,
readable_schema_min,
readable_schema_max: version,
contract_evidence: None,
}
}
impl MigrationAuthority {
pub fn registered_extension_migrations() -> &'static [(&'static str, ExtensionMigration)] {
REGISTERED_EXTENSION_MIGRATIONS
}
pub fn sequence() -> Vec<MigrationDescriptor> {
vec![
MigrationDescriptor {
@@ -209,41 +187,66 @@ impl MigrationAuthority {
readable_schema_max: 1,
contract_evidence: None,
},
MigrationDescriptor {
version: 2,
name: "legacy-consolidation-v2",
checksum: CONSOLIDATION_SOURCE_SHA256.to_owned(),
source_digest: CONSOLIDATION_SOURCE_SHA256.to_owned(),
phase: "expand",
compatibility: "n-minus-one-readable",
owner: "crank-registry",
transactional: true,
backfill: BackfillPolicy::None,
readable_schema_min: 1,
readable_schema_max: 2,
contract_evidence: None,
},
MigrationDescriptor {
version: 3,
name: "request-trace-identity-v3",
checksum: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
source_digest: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
phase: "expand",
compatibility: "n-minus-one-readable",
owner: "crank-registry",
transactional: true,
backfill: BackfillPolicy::None,
readable_schema_min: 2,
readable_schema_max: 3,
contract_evidence: None,
},
expand_descriptor(2, "legacy-consolidation-v2", CONSOLIDATION_SOURCE_SHA256, 1),
expand_descriptor(
3,
"request-trace-identity-v3",
REQUEST_TRACE_IDENTITY_SOURCE_SHA256,
2,
),
expand_descriptor(
4,
"operation-lifecycle-v4",
OPERATION_LIFECYCLE_SOURCE_SHA256,
3,
),
expand_descriptor(
5,
"execution-outcome-v5",
execution_outcome_v5::SOURCE_SHA256,
4,
),
expand_descriptor(
6,
"platform-key-name-reuse-v6",
platform_key_name_reuse_v6::SOURCE_SHA256,
5,
),
expand_descriptor(
7,
"master-key-identity-v7",
master_key_identity_v7::SOURCE_SHA256,
6,
),
expand_descriptor(
8,
"admin-auth-lifecycle-v8",
admin_auth_lifecycle_v8::SOURCE_SHA256,
7,
),
expand_descriptor(
9,
"agent-catalog-lifecycle-v9",
agent_catalog_lifecycle_v9::SOURCE_SHA256,
8,
),
expand_descriptor(
10,
"approval-side-effects-v10",
approval_side_effects_v10::SOURCE_SHA256,
9,
),
expand_descriptor(
11,
"onboarding-product-events-v11",
onboarding_product_events_v11::SOURCE_SHA256,
10,
),
]
}
pub fn validate_sequence() -> Result<(), MigrationError> {
validate_descriptors(&Self::sequence())
}
pub async fn preflight(pool: &PgPool) -> Result<MigrationPreflight, MigrationError> {
Self::validate_sequence()?;
let mut connection = pool
@@ -252,7 +255,6 @@ impl MigrationAuthority {
.map_err(|_| MigrationError::storage("preflight.connect"))?;
inspect(&mut connection).await
}
pub async fn require_current(pool: &PgPool) -> Result<(), MigrationError> {
match Self::preflight(pool).await? {
MigrationPreflight::Current { .. } => Ok(()),
@@ -270,7 +272,6 @@ impl MigrationAuthority {
)),
}
}
pub async fn apply(pool: &PgPool) -> Result<MigrationApplyResult, MigrationError> {
Self::validate_sequence()?;
let mut transaction = pool
@@ -296,7 +297,6 @@ impl MigrationAuthority {
MigrationError::storage("apply.lock")
}
})?;
let before = inspect(&mut transaction).await?;
let from = match before {
MigrationPreflight::Current { version } => {
@@ -308,7 +308,6 @@ impl MigrationAuthority {
}
MigrationPreflight::MigrationRequired { current, .. } => current,
};
if from == 0 {
create_core_ledger(&mut transaction).await?;
apply_baseline(&mut transaction).await.map_err(|_| {
@@ -335,14 +334,36 @@ impl MigrationAuthority {
)
})?;
}
if from < 2 {
apply_consolidation(&mut transaction).await?;
}
if from < 3 {
apply_request_trace_identity(&mut transaction).await?;
}
if from < 4 {
apply_operation_lifecycle(&mut transaction).await?;
}
if from < 5 {
execution_outcome_v5::apply(&mut transaction, &Self::sequence()[4]).await?;
}
if from < 6 {
platform_key_name_reuse_v6::apply(&mut transaction, &Self::sequence()[5]).await?;
}
if from < 7 {
master_key_identity_v7::apply(&mut transaction, &Self::sequence()[6]).await?;
}
if from < 8 {
admin_auth_lifecycle_v8::apply(&mut transaction, &Self::sequence()[7]).await?;
}
if from < 9 {
agent_catalog_lifecycle_v9::apply(&mut transaction, &Self::sequence()[8]).await?;
}
if from < 10 {
approval_side_effects_v10::apply(&mut transaction, &Self::sequence()[9]).await?;
}
if from < 11 {
onboarding_product_events_v11::apply(&mut transaction, &Self::sequence()[10]).await?;
}
transaction
.commit()
.await
@@ -353,11 +374,9 @@ impl MigrationAuthority {
})
}
}
fn sha256_hex(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
#[cfg(test)]
fn baseline_source_digest() -> String {
let source = include_str!("baseline_v1.rs");
@@ -369,7 +388,6 @@ fn baseline_source_digest() -> String {
.expect("baseline end marker must exist");
sha256_hex(baseline.as_bytes())
}
fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationError> {
if descriptors.is_empty() || descriptors.len() > 1_024 {
return Err(MigrationError::new(
@@ -461,6 +479,19 @@ fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), Migra
|| sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256
|| sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes())
!= REQUEST_TRACE_IDENTITY_SOURCE_SHA256
|| sha256_hex(OPERATION_LIFECYCLE_SOURCE.as_bytes()) != OPERATION_LIFECYCLE_SOURCE_SHA256
|| sha256_hex(execution_outcome_v5::SOURCE.as_bytes())
!= execution_outcome_v5::SOURCE_SHA256
|| sha256_hex(platform_key_name_reuse_v6::SOURCE.as_bytes())
!= platform_key_name_reuse_v6::SOURCE_SHA256
|| sha256_hex(master_key_identity_v7::SOURCE.as_bytes())
!= master_key_identity_v7::SOURCE_SHA256
|| sha256_hex(admin_auth_lifecycle_v8::SOURCE.as_bytes())
!= admin_auth_lifecycle_v8::SOURCE_SHA256
|| sha256_hex(agent_catalog_lifecycle_v9::SOURCE.as_bytes())
!= agent_catalog_lifecycle_v9::SOURCE_SHA256
|| sha256_hex(approval_side_effects_v10::SOURCE.as_bytes())
!= approval_side_effects_v10::SOURCE_SHA256
{
return Err(MigrationError::new(
"invalid_contract",
@@ -471,11 +502,9 @@ fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), Migra
}
Ok(())
}
async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, MigrationError> {
let core_exists = relation_exists(connection, "__crank_core_migrations").await?;
let canonical_exists = relation_exists(connection, "__crank_migrations").await?;
if !core_exists {
let mut owned_exists = canonical_exists;
for relation in OWNED_RELATIONS {
@@ -495,18 +524,15 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
target: CURRENT_VERSION,
});
}
validate_core_ledger(connection).await?;
validate_required_relations(connection, BASELINE_RELATIONS, 1).await?;
validate_required_relations(connection, owned_relations::BASELINE, 1).await?;
inspect_optional_legacy(connection).await?;
if !canonical_exists {
return Ok(MigrationPreflight::MigrationRequired {
current: 1,
target: CURRENT_VERSION,
});
}
let descriptors = MigrationAuthority::sequence();
let rows = query("select version, name, checksum, phase, compatibility from __crank_migrations order by version limit 1025")
.fetch_all(&mut *connection)
@@ -580,7 +606,6 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
));
}
}
let current = rows
.last()
.and_then(|row| row.try_get::<i64, _>("version").ok())
@@ -600,13 +625,19 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
target: CURRENT_VERSION,
})
} else {
validate_required_relations(connection, CONSOLIDATION_RELATIONS, CURRENT_VERSION).await?;
validate_required_relations(connection, owned_relations::CONSOLIDATION, CURRENT_VERSION)
.await?;
validate_required_relations(
connection,
owned_relations::ONBOARDING_PRODUCT_EVENTS,
CURRENT_VERSION,
)
.await?;
validate_schema_fingerprint(connection, CURRENT_VERSION).await?;
validate_legacy_audit(connection).await?;
Ok(MigrationPreflight::Current { version: current })
}
}
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
.fetch_all(connection)
@@ -642,7 +673,6 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
}
Ok(())
}
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
@@ -749,7 +779,6 @@ async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), Mi
}
Ok(())
}
async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), MigrationError> {
let rows = query(
"select source, source_version, source_checksum
@@ -800,7 +829,6 @@ async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), Migr
}
Ok(())
}
async fn create_core_ledger(
transaction: &mut Transaction<'_, sqlx::Postgres>,
) -> Result<(), MigrationError> {
@@ -824,7 +852,6 @@ async fn create_core_ledger(
})?;
Ok(())
}
async fn apply_consolidation(
transaction: &mut Transaction<'_, sqlx::Postgres>,
) -> Result<(), MigrationError> {
@@ -883,7 +910,6 @@ async fn apply_consolidation(
})?;
Ok(())
}
async fn apply_request_trace_identity(
transaction: &mut Transaction<'_, sqlx::Postgres>,
) -> Result<(), MigrationError> {
@@ -920,7 +946,42 @@ async fn apply_request_trace_identity(
})?;
Ok(())
}
async fn apply_operation_lifecycle(
transaction: &mut Transaction<'_, sqlx::Postgres>,
) -> Result<(), MigrationError> {
sqlx::raw_sql(OPERATION_LIFECYCLE_SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.operation_lifecycle",
Some(4),
"restore_known_good_backup",
)
})?;
let descriptor = &MigrationAuthority::sequence()[3];
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(4),
"restore_known_good_backup",
)
})?;
Ok(())
}
#[cfg(test)]
#[path = "authority_tests.rs"]
mod tests;
@@ -8,7 +8,7 @@ fn sequence_is_deterministic_and_append_only() {
MigrationAuthority::validate_sequence().unwrap();
assert_eq!(
first.iter().map(|item| item.version).collect::<Vec<_>>(),
vec![1, 2, 3]
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
);
assert_eq!(first[0].checksum, "crank-community-baseline-v1");
assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256);
@@ -0,0 +1,44 @@
use sqlx::{Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("execution_outcome_v5.sql");
pub(super) const SOURCE_SHA256: &str =
"bd6703249cc407789586327eb7fbbd5776ba27923c5dc7eb85b05d332585bf42";
pub(super) async fn apply(
transaction: &mut Transaction<'_, sqlx::Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.execution_outcome",
Some(5),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(5),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,41 @@
alter table invocation_logs
add column operation_version integer null,
add column execution_stage text null,
add column execution_error_code text null,
add column retryability text null,
add column outcome_certainty text null;
alter table invocation_logs
add constraint invocation_logs_operation_version_check
check (operation_version is null or operation_version > 0),
add constraint invocation_logs_execution_stage_check
check (execution_stage is null or execution_stage in (
'authorization', 'input_schema', 'input_mapping', 'request_preparation',
'admission', 'adapter', 'upstream', 'output_mapping', 'output_schema',
'mandatory_persistence', 'runtime'
)),
add constraint invocation_logs_execution_error_code_check
check (execution_error_code is null or execution_error_code in (
'authorization_denied', 'auth_profile_not_found', 'secret_not_found',
'secret_invalid', 'input_schema_invalid', 'input_mapping_invalid',
'prepared_request_invalid', 'execution_overloaded', 'safety_store_unavailable',
'protocol_unsupported', 'execution_mode_unsupported',
'adapter_configuration_invalid', 'outbound_target_rejected',
'upstream_auth_error', 'upstream_not_found', 'upstream_rate_limited',
'upstream_server_error', 'upstream_status_error', 'upstream_timeout',
'upstream_transport_error', 'upstream_request_too_large',
'upstream_response_too_large',
'output_mapping_invalid', 'output_schema_invalid', 'persistence_unavailable',
'runtime_internal', 'confirmation_required', 'confirmation_invalid',
'idempotency_in_progress', 'idempotency_conflict', 'idempotency_outcome_unknown'
)),
add constraint invocation_logs_retryability_check
check (retryability is null or retryability in (
'never', 'safe', 'after_delay', 'manual_reconcile', 'requires_confirmation'
)),
add constraint invocation_logs_outcome_certainty_check
check (outcome_certainty is null or outcome_certainty in ('certain', 'outcome_unknown'));
create index invocation_logs_workspace_operation_version_idx
on invocation_logs(workspace_id, operation_id, operation_version)
where operation_version is not null;
@@ -0,0 +1,44 @@
use sqlx::{Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("master_key_identity_v7.sql");
pub(super) const SOURCE_SHA256: &str =
"06e5d4667d9a7346474e9dcb176b8b2b9680c4b89089bea5a18eb5a88d5ac60e";
pub(super) async fn apply(
transaction: &mut Transaction<'_, sqlx::Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.master_key_identity",
Some(7),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(7),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,104 @@
create table master_key_identities (
epoch bigint primary key,
fingerprint text not null unique,
cipher_contract text not null,
status text not null,
backup_ref text,
created_at timestamptz not null default now(),
activated_at timestamptz,
retired_at timestamptz,
constraint master_key_identities_epoch_check check (epoch > 0),
constraint master_key_identities_fingerprint_check check (
fingerprint ~ '^[0-9a-f]{64}$'
),
constraint master_key_identities_cipher_contract_check check (
cipher_contract = 'secret-envelope-v2/aes-256-gcm-hkdf-sha256'
),
constraint master_key_identities_status_check check (
status in ('active', 'pending', 'retired', 'revoked')
),
constraint master_key_identities_backup_ref_check check (
backup_ref is null
or (
octet_length(backup_ref) <= 256
and backup_ref !~ '[[:cntrl:]]'
and backup_ref !~ '^[A-Za-z][A-Za-z0-9+.-]*://'
)
)
);
create unique index master_key_identities_active_idx
on master_key_identities (status)
where status = 'active';
create table master_key_rotations (
id text primary key,
source_epoch bigint not null,
target_epoch bigint not null,
target_fingerprint text not null,
state text not null,
backup_ref text,
checkpoint_secret_id text,
total_secret_versions bigint not null default 0,
processed_secret_versions bigint not null default 0,
verified_secret_versions bigint not null default 0,
failure_code text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint master_key_rotations_id_check check (
octet_length(id) between 1 and 128
and id !~ '[[:cntrl:]]'
),
constraint master_key_rotations_source_epoch_check check (source_epoch > 0),
constraint master_key_rotations_target_epoch_check check (target_epoch > source_epoch),
constraint master_key_rotations_target_fingerprint_check check (
target_fingerprint ~ '^[0-9a-f]{64}$'
),
constraint master_key_rotations_state_check check (
state in ('preflighted', 'running', 'verifying', 'verified', 'promoted', 'aborted', 'failed')
),
constraint master_key_rotations_backup_ref_check check (
backup_ref is null
or (
octet_length(backup_ref) <= 256
and backup_ref !~ '[[:cntrl:]]'
and backup_ref !~ '^[A-Za-z][A-Za-z0-9+.-]*://'
)
),
constraint master_key_rotations_counts_check check (
total_secret_versions >= 0
and processed_secret_versions >= 0
and verified_secret_versions >= 0
and processed_secret_versions <= total_secret_versions
and verified_secret_versions <= total_secret_versions
),
constraint master_key_rotations_failure_code_check check (
failure_code is null
or (
octet_length(failure_code) between 1 and 128
and failure_code !~ '[[:cntrl:]]'
)
)
);
alter table secret_versions
add column master_key_epoch bigint not null default 1,
add column target_ciphertext text,
add column target_key_version text,
add column target_master_key_epoch bigint,
add constraint secret_versions_master_key_epoch_check check (master_key_epoch > 0),
add constraint secret_versions_target_epoch_check check (
target_master_key_epoch is null or target_master_key_epoch > master_key_epoch
),
add constraint secret_versions_target_all_or_none_check check (
(
target_ciphertext is null
and target_key_version is null
and target_master_key_epoch is null
)
or (
target_ciphertext is not null
and target_key_version is not null
and target_master_key_epoch is not null
)
);
@@ -0,0 +1,44 @@
use sqlx::{Postgres, Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("onboarding_product_events_v11.sql");
pub(super) const SOURCE_SHA256: &str =
"a439bbcdc9cc909d717ed5a868c7a51bd1ad3c49c4e85fd20933743ee3f31166";
pub(super) async fn apply(
transaction: &mut Transaction<'_, Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.onboarding_product_events",
Some(11),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(11),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,110 @@
alter table platform_api_keys
add constraint platform_api_keys_workspace_agent_id_unique
unique (workspace_id, agent_id, id);
alter table invocation_logs
add column platform_api_key_id text null;
alter table invocation_logs
add constraint invocation_logs_platform_key_scope_fk
foreign key (workspace_id, agent_id, platform_api_key_id)
references platform_api_keys(workspace_id, agent_id, id)
on delete set null (platform_api_key_id);
create index invocation_logs_workspace_agent_key_success_idx
on invocation_logs(workspace_id, agent_id, platform_api_key_id, created_at desc)
where platform_api_key_id is not null
and source = 'agent_tool_call'
and status = 'ok';
create table product_events (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
event_name text not null,
schema_version integer not null,
occurred_at timestamptz not null,
idempotency_key text not null,
properties_json jsonb not null default '{}'::jsonb,
constraint product_events_id_check check (id ~ '^pe_[A-Za-z0-9_-]{1,128}$'),
constraint product_events_name_check check (event_name in (
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed', 'onboarding_dismissed',
'onboarding_abandoned', 'onboarding_completed'
)),
constraint product_events_schema_version_check check (schema_version = 1),
constraint product_events_idempotency_key_check check (
octet_length(idempotency_key) between 1 and 256
),
constraint product_events_properties_check check (
jsonb_typeof(properties_json) = 'object'
and pg_column_size(properties_json) <= 4096
and (
event_name <> 'onboarding_eligible'
or (
properties_json @> '{"eligible": true}'::jsonb
and jsonb_typeof(properties_json -> 'eligible_since') = 'string'
)
)
),
unique (workspace_id, idempotency_key)
);
create index product_events_workspace_occurred_idx
on product_events(workspace_id, occurred_at, id);
create table product_event_daily_rollups (
workspace_id text not null references workspaces(id) on delete cascade,
event_name text not null,
day date not null,
events_total bigint not null default 0,
eligible_total bigint not null default 0,
constraint product_event_daily_rollups_name_check check (event_name in (
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed', 'onboarding_dismissed',
'onboarding_abandoned', 'onboarding_completed'
)),
constraint product_event_daily_rollups_counts_check check (
events_total >= 0 and eligible_total >= 0 and eligible_total <= events_total
),
primary key (workspace_id, event_name, day)
);
create table onboarding_selections (
workspace_id text primary key references workspaces(id) on delete cascade,
operation_id text null,
operation_version integer null,
agent_id text null,
catalog_revision bigint null,
platform_api_key_id text null,
invocation_log_id text null,
test_log_id text null,
evidence_after timestamptz not null,
selected_at timestamptz null,
constraint onboarding_selections_shape_check check (
(
operation_id is null and operation_version is null and agent_id is null
and catalog_revision is null and platform_api_key_id is null
and invocation_log_id is null and test_log_id is null and selected_at is null
) or (
operation_id is not null and operation_version is not null and operation_version > 0
and agent_id is not null and catalog_revision is not null and catalog_revision > 0
and platform_api_key_id is not null and invocation_log_id is not null
and selected_at is not null
)
)
);
create function crank_reject_product_event_mutation()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'DELETE'
and not exists (select 1 from workspaces where id = old.workspace_id) then
return old;
end if;
raise exception 'ProductEvent is append-only' using errcode = '23514';
end;
$$;
create trigger product_events_append_only_guard
before update or delete on product_events
for each row execute function crank_reject_product_event_mutation();
@@ -0,0 +1,142 @@
alter table operation_versions
add column name text,
add column display_name text,
add column category text,
add column protocol text,
add column security_level text,
add column snapshot_provenance text,
add column snapshot_observed_at timestamptz,
add column published_at timestamptz,
add column published_by text;
update operation_versions ov
set name = o.name,
display_name = o.display_name,
category = o.category,
protocol = o.protocol,
security_level = o.security_level,
snapshot_provenance = 'legacy_observed',
snapshot_observed_at = statement_timestamp()
from operations o
where o.id = ov.operation_id;
update operation_versions ov
set published_at = po.published_at,
published_by = po.published_by
from published_operations po
where po.operation_id = ov.operation_id
and po.version = ov.version;
alter table operation_versions
alter column name set not null,
alter column display_name set not null,
alter column category set not null,
alter column protocol set not null,
alter column security_level set not null,
alter column snapshot_provenance set not null,
alter column snapshot_observed_at set not null,
add constraint operation_versions_snapshot_provenance_check
check (snapshot_provenance in ('legacy_observed', 'native_v4'));
create or replace function crank_guard_operation_version_immutable()
returns trigger
language plpgsql
as $guard$
begin
if tg_op = 'DELETE' then
if old.status = 'published' then
raise exception 'published operation version is immutable'
using errcode = '55000';
end if;
return old;
end if;
if old.status = 'draft'
and new.status = 'published'
and old.operation_id = new.operation_id
and old.version = new.version
and old.name = new.name
and old.display_name = new.display_name
and old.category = new.category
and old.protocol = new.protocol
and old.security_level = new.security_level
and old.target_json = new.target_json
and old.input_schema_json = new.input_schema_json
and old.output_schema_json = new.output_schema_json
and old.input_mapping_json = new.input_mapping_json
and old.output_mapping_json = new.output_mapping_json
and old.execution_config_json = new.execution_config_json
and old.tool_description_json = new.tool_description_json
and old.samples_json is not distinct from new.samples_json
and old.generated_draft_json is not distinct from new.generated_draft_json
and old.config_export_json is not distinct from new.config_export_json
and old.wizard_state_json is not distinct from new.wizard_state_json
and old.change_note is not distinct from new.change_note
and old.created_at = new.created_at
and old.created_by is not distinct from new.created_by
and old.snapshot_provenance = new.snapshot_provenance
and old.snapshot_observed_at = new.snapshot_observed_at
and old.published_at is null
and new.published_at is not null
then
return new;
end if;
raise exception 'operation version is append-only'
using errcode = '55000';
end;
$guard$;
create trigger operation_versions_immutable_guard
before update or delete on operation_versions
for each row execute function crank_guard_operation_version_immutable();
create or replace function crank_guard_published_operation_pointer()
returns trigger
language plpgsql
as $guard$
begin
if tg_op = 'DELETE' then
raise exception 'published operation pointer is immutable'
using errcode = '55000';
end if;
if new.operation_id = old.operation_id and new.version > old.version then
return new;
end if;
raise exception 'published operation pointer cannot rewind'
using errcode = '55000';
end;
$guard$;
create trigger published_operations_monotonic_guard
before update or delete on published_operations
for each row execute function crank_guard_published_operation_pointer();
create or replace function crank_guard_operation_latest_pointer()
returns trigger
language plpgsql
as $guard$
begin
if old.latest_published_version is not null
and (new.latest_published_version is null
or new.latest_published_version < old.latest_published_version) then
raise exception 'latest published operation version cannot rewind'
using errcode = '55000';
end if;
if new.latest_published_version is distinct from old.latest_published_version
and new.latest_published_version is not null
and not exists (
select 1 from published_operations po
where po.operation_id = new.id
and po.version = new.latest_published_version
) then
raise exception 'latest published operation version has no authoritative pointer'
using errcode = '55000';
end if;
return new;
end;
$guard$;
create trigger operations_latest_pointer_monotonic_guard
before update on operations
for each row execute function crank_guard_operation_latest_pointer();
@@ -0,0 +1,40 @@
pub(super) const BASELINE: &[&str] = &[
"workspaces",
"users",
"memberships",
"user_sessions",
"invitation_tokens",
"platform_api_keys",
"operations",
"operation_versions",
"published_operations",
"operation_samples",
"descriptors",
"agents",
"agent_versions",
"published_agents",
"agent_operation_bindings",
"secrets",
"secret_versions",
"auth_profiles",
"workspace_upstreams",
"yaml_import_jobs",
"import_jobs",
"approval_requests",
"invocation_logs",
"usage_rollups",
];
pub(super) const ONBOARDING_PRODUCT_EVENTS: &[&str] = &[
"product_events",
"product_event_daily_rollups",
"onboarding_selections",
];
pub(super) const CONSOLIDATION: &[&str] = &[
"__crank_migrations",
"__crank_migration_legacy_audit",
"__crank_mcp_migrations",
"mcp_transport_sessions",
"__crank_ext_migrations",
];
@@ -0,0 +1,44 @@
use sqlx::{Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("platform_key_name_reuse_v6.sql");
pub(super) const SOURCE_SHA256: &str =
"94dba9b9dd3364607bc37e7698478ba6644607a7f268a1621cd3acea6bc96c2d";
pub(super) async fn apply(
transaction: &mut Transaction<'_, sqlx::Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.platform_key_name_reuse",
Some(6),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(6),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,5 @@
drop index if exists platform_api_keys_workspace_name_idx;
create unique index if not exists platform_api_keys_workspace_name_active_idx
on platform_api_keys(workspace_id, name)
where status <> 'deleted';
@@ -26,6 +26,8 @@ pub(super) const OWNED_RELATIONS: &[&str] = &[
"agent_operation_bindings",
"secrets",
"secret_versions",
"master_key_identities",
"master_key_rotations",
"auth_profiles",
"workspace_upstreams",
"yaml_import_jobs",
@@ -77,6 +79,37 @@ const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[
"expires_at",
],
),
(
"master_key_identities",
&[
"epoch",
"fingerprint",
"cipher_contract",
"status",
"backup_ref",
"created_at",
"activated_at",
"retired_at",
],
),
(
"master_key_rotations",
&[
"id",
"source_epoch",
"target_epoch",
"target_fingerprint",
"state",
"backup_ref",
"checkpoint_secret_id",
"total_secret_versions",
"processed_secret_versions",
"verified_secret_versions",
"failure_code",
"created_at",
"updated_at",
],
),
];
const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
@@ -165,6 +198,67 @@ const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
"timestamp with time zone",
true,
),
("master_key_identities", "epoch", "bigint", false),
("master_key_identities", "fingerprint", "text", false),
("master_key_identities", "cipher_contract", "text", false),
("master_key_identities", "status", "text", false),
("master_key_identities", "backup_ref", "text", true),
(
"master_key_identities",
"created_at",
"timestamp with time zone",
false,
),
(
"master_key_identities",
"activated_at",
"timestamp with time zone",
true,
),
(
"master_key_identities",
"retired_at",
"timestamp with time zone",
true,
),
("master_key_rotations", "id", "text", false),
("master_key_rotations", "source_epoch", "bigint", false),
("master_key_rotations", "target_epoch", "bigint", false),
("master_key_rotations", "target_fingerprint", "text", false),
("master_key_rotations", "state", "text", false),
("master_key_rotations", "backup_ref", "text", true),
("master_key_rotations", "checkpoint_secret_id", "text", true),
(
"master_key_rotations",
"total_secret_versions",
"bigint",
false,
),
(
"master_key_rotations",
"processed_secret_versions",
"bigint",
false,
),
(
"master_key_rotations",
"verified_secret_versions",
"bigint",
false,
),
("master_key_rotations", "failure_code", "text", true),
(
"master_key_rotations",
"created_at",
"timestamp with time zone",
false,
),
(
"master_key_rotations",
"updated_at",
"timestamp with time zone",
false,
),
];
pub(super) async fn relation_exists(
@@ -363,9 +457,397 @@ pub(super) async fn validate_schema_fingerprint(
)
.await?;
}
let lifecycle_columns = [
"name",
"display_name",
"category",
"protocol",
"security_level",
"snapshot_provenance",
"snapshot_observed_at",
"published_at",
"published_by",
];
for column in lifecycle_columns {
let present = query(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'operation_versions'
and column_name = $1
) as present",
)
.bind(column)
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get::<bool, _>("present")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if present != (current_version >= 4) {
return Err(schema_error(current_version));
}
}
if current_version >= 4 {
for (table, trigger) in [
("operation_versions", "operation_versions_immutable_guard"),
(
"published_operations",
"published_operations_monotonic_guard",
),
("operations", "operations_latest_pointer_monotonic_guard"),
] {
let trigger_present = query(
"select exists (
select 1 from pg_catalog.pg_trigger tg
join pg_catalog.pg_class t on t.oid = tg.tgrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema()
and t.relname = $1
and tg.tgname = $2
and not tg.tgisinternal
) as present",
)
.bind(table)
.bind(trigger)
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get::<bool, _>("present")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if !trigger_present {
return Err(schema_error(current_version));
}
}
}
let outcome_columns = [
"operation_version",
"execution_stage",
"execution_error_code",
"retryability",
"outcome_certainty",
];
for column in outcome_columns {
let row = query(
"select data_type, is_nullable from information_schema.columns
where table_schema = current_schema()
and table_name = 'invocation_logs'
and column_name = $1",
)
.bind(column)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if current_version < 5 {
if row.is_some() {
return Err(schema_error(current_version));
}
} else {
let expected_type = if column == "operation_version" {
"integer"
} else {
"text"
};
let valid = row.is_some_and(|row| {
row.try_get::<String, _>("data_type").ok().as_deref() == Some(expected_type)
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("YES")
});
if !valid {
return Err(schema_error(current_version));
}
}
}
if current_version >= 5 {
validate_v5_execution_contract(connection).await?;
}
if current_version >= 6 {
validate_v6_platform_key_name_reuse(connection).await?;
}
if current_version < 7 {
let v7_relations_present = relation_exists(connection, "master_key_identities").await?
|| relation_exists(connection, "master_key_rotations").await?;
let mut v7_secret_columns_present = false;
for column in [
"master_key_epoch",
"target_ciphertext",
"target_key_version",
"target_master_key_epoch",
] {
v7_secret_columns_present |=
column_exists(connection, "secret_versions", column).await?;
}
if v7_relations_present || v7_secret_columns_present {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v7::validate_v7_master_key_identity(connection).await?;
}
if current_version < 8 {
let v8_relations_present = relation_exists(connection, "admin_bootstrap_contracts").await?
|| relation_exists(connection, "admin_login_backoff").await?
|| relation_exists(connection, "admin_security_audit_events").await?;
let v8_session_columns_present = column_exists(connection, "user_sessions", "csrf_hash")
.await?
|| column_exists(connection, "user_sessions", "revoked_at").await?;
if v8_relations_present || v8_session_columns_present {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v8::validate_v8_admin_auth_lifecycle(connection).await?;
}
if current_version < 9 {
if !super::schema_guard_v9::validate_v9_absent(connection).await? {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v9::validate_v9_agent_catalog_lifecycle(connection).await?;
}
if current_version < 10 {
if !super::schema_guard_v10::validate_v10_absent(connection).await? {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v10::validate_v10_approval_side_effects(connection).await?;
}
if current_version < 11 {
if !super::schema_guard_v11::validate_v11_absent(connection).await? {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v11::validate_v11_onboarding_product_events(connection).await?;
}
Ok(())
}
pub(super) async fn column_exists(
connection: &mut PgConnection,
table: &str,
column: &str,
) -> Result<bool, MigrationError> {
query(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = $1
and column_name = $2
) as present",
)
.bind(table)
.bind(column)
.fetch_one(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get::<bool, _>("present")
.map_err(|_| MigrationError::storage("preflight.schema"))
}
async fn validate_v5_execution_contract(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let operation_version = constraint_expression(
connection,
"invocation_logs",
"invocation_logs_operation_version_check",
)
.await?;
if normalize_definition(&operation_version) != "operation_versionisnulloroperation_version>0" {
return Err(schema_error(5));
}
for (constraint, column, allowed) in [
(
"invocation_logs_execution_stage_check",
"execution_stage",
&[
"authorization",
"input_schema",
"input_mapping",
"request_preparation",
"admission",
"adapter",
"upstream",
"output_mapping",
"output_schema",
"mandatory_persistence",
"runtime",
][..],
),
(
"invocation_logs_execution_error_code_check",
"execution_error_code",
&[
"authorization_denied",
"auth_profile_not_found",
"secret_not_found",
"secret_invalid",
"input_schema_invalid",
"input_mapping_invalid",
"prepared_request_invalid",
"execution_overloaded",
"safety_store_unavailable",
"protocol_unsupported",
"execution_mode_unsupported",
"adapter_configuration_invalid",
"outbound_target_rejected",
"upstream_auth_error",
"upstream_not_found",
"upstream_rate_limited",
"upstream_server_error",
"upstream_status_error",
"upstream_timeout",
"upstream_transport_error",
"upstream_request_too_large",
"upstream_response_too_large",
"output_mapping_invalid",
"output_schema_invalid",
"persistence_unavailable",
"runtime_internal",
"confirmation_required",
"confirmation_invalid",
"idempotency_in_progress",
"idempotency_conflict",
"idempotency_outcome_unknown",
][..],
),
(
"invocation_logs_retryability_check",
"retryability",
&[
"never",
"safe",
"after_delay",
"manual_reconcile",
"requires_confirmation",
][..],
),
(
"invocation_logs_outcome_certainty_check",
"outcome_certainty",
&["certain", "outcome_unknown"][..],
),
] {
let expression = constraint_expression(connection, "invocation_logs", constraint).await?;
if !enum_constraint_matches(&expression, column, allowed) {
return Err(schema_error(5));
}
}
validate_v5_index(connection).await
}
pub(super) async fn constraint_expression(
connection: &mut PgConnection,
table: &str,
constraint: &str,
) -> Result<String, MigrationError> {
query(
"select pg_get_expr(c.conbin, c.conrelid) as expression
from pg_catalog.pg_constraint c
join pg_catalog.pg_class t on t.oid = c.conrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema() and t.relname = $1 and c.conname = $2 and c.contype = 'c'",
)
.bind(table)
.bind(constraint)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.and_then(|row| row.try_get::<String, _>("expression").ok())
.ok_or_else(|| schema_error(5))
}
pub(super) fn enum_constraint_matches(expression: &str, column: &str, allowed: &[&str]) -> bool {
let normalized = normalize_definition(expression);
if !normalized.contains(column) || normalized.contains("ortrue") {
return false;
}
let mut values = expression
.split('\'')
.enumerate()
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
.collect::<Vec<_>>();
values.sort_unstable();
values.dedup();
let mut expected = allowed.to_vec();
expected.sort_unstable();
values == expected
}
async fn validate_v5_index(connection: &mut PgConnection) -> Result<(), MigrationError> {
let row = query(
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
pg_get_expr(i.indpred, i.indrelid) as predicate
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join pg_catalog.pg_am am on am.oid = idx.relam
where n.nspname = current_schema()
and idx.relname = 'invocation_logs_workspace_operation_version_idx'",
)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = row.is_some_and(|row| {
row.try_get::<String, _>("table_name").ok().as_deref() == Some("invocation_logs")
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
&& row.try_get::<bool, _>("indisunique").ok() == Some(false)
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("operation_id")
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("operation_version")
&& row
.try_get::<String, _>("predicate")
.ok()
.is_some_and(|value| normalize_definition(&value) == "operation_versionisnotnull")
});
if valid { Ok(()) } else { Err(schema_error(5)) }
}
async fn validate_v6_platform_key_name_reuse(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
if relation_exists(connection, "platform_api_keys_workspace_name_idx").await? {
return Err(schema_error(6));
}
let row = query(
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
pg_get_expr(i.indpred, i.indrelid) as predicate
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join pg_catalog.pg_am am on am.oid = idx.relam
where n.nspname = current_schema()
and idx.relname = 'platform_api_keys_workspace_name_active_idx'",
)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = row.is_some_and(|row| {
row.try_get::<String, _>("table_name").ok().as_deref() == Some("platform_api_keys")
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("name")
&& row
.try_get::<String, _>("predicate")
.ok()
.is_some_and(|value| {
matches!(
normalize_definition(&value).as_str(),
"status<>'deleted'::text" | "status!='deleted'::text"
)
})
});
if valid { Ok(()) } else { Err(schema_error(6)) }
}
async fn named_constraint_exists(
connection: &mut PgConnection,
table: &str,
@@ -433,7 +915,7 @@ async fn validate_index(
if valid { Ok(()) } else { Err(schema_error(3)) }
}
fn normalize_definition(value: &str) -> String {
pub(super) fn normalize_definition(value: &str) -> String {
value
.chars()
.filter(|character| !character.is_ascii_whitespace() && !matches!(character, '(' | ')'))
@@ -441,7 +923,7 @@ fn normalize_definition(value: &str) -> String {
.collect()
}
fn schema_error(version: i64) -> MigrationError {
pub(super) fn schema_error(version: i64) -> MigrationError {
MigrationError::new(
"partial_sequence",
"preflight.schema",
@@ -0,0 +1,83 @@
use sqlx::{PgConnection, Row, query};
use super::authority::MigrationError;
use super::schema_guard::{normalize_definition, relation_exists, schema_error};
pub(super) async fn validate_v10_absent(
connection: &mut PgConnection,
) -> Result<bool, MigrationError> {
let old_present =
relation_exists(connection, "approval_requests_pending_fingerprint_idx").await?;
let new_present = relation_exists(
connection,
"approval_requests_pending_scope_fingerprint_idx",
)
.await?;
Ok(old_present && !new_present)
}
pub(super) async fn validate_v10_approval_side_effects(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
if !relation_exists(connection, "approval_requests_pending_fingerprint_idx").await? {
return Err(schema_error(10));
}
for column in ["request_id", "trace_id"] {
let present = query(
"select 1
from information_schema.columns
where table_schema = current_schema()
and table_name = 'approval_requests'
and column_name = $1",
)
.bind(column)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.is_some();
if !present {
return Err(schema_error(10));
}
}
let row = query(
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
pg_get_indexdef(i.indexrelid, 4, true) as fourth_column,
pg_get_indexdef(i.indexrelid, 5, true) as fifth_column,
pg_get_expr(i.indpred, i.indrelid) as predicate
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join pg_catalog.pg_am am on am.oid = idx.relam
where n.nspname = current_schema()
and idx.relname = 'approval_requests_pending_scope_fingerprint_idx'",
)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = row.is_some_and(|row| {
row.try_get::<String, _>("table_name").ok().as_deref() == Some("approval_requests")
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("agent_id")
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("operation_id")
&& row.try_get::<String, _>("fourth_column").ok().as_deref()
== Some("operation_version")
&& row.try_get::<String, _>("fifth_column").ok().as_deref()
== Some("request_fingerprint")
&& row
.try_get::<String, _>("predicate")
.ok()
.is_some_and(|value| {
normalize_definition(&value)
== "status='pending'::textandrequest_fingerprintisnotnull"
})
});
if valid { Ok(()) } else { Err(schema_error(10)) }
}
@@ -0,0 +1,331 @@
use sqlx::{PgConnection, Row, query};
use super::authority::MigrationError;
use super::schema_guard::{
column_exists, constraint_expression, normalize_definition, relation_exists, schema_error,
};
pub(super) async fn validate_v11_absent(
connection: &mut PgConnection,
) -> Result<bool, MigrationError> {
Ok(!relation_exists(connection, "product_events").await?
&& !relation_exists(connection, "product_event_daily_rollups").await?
&& !relation_exists(connection, "onboarding_selections").await?
&& !column_exists(connection, "invocation_logs", "platform_api_key_id").await?)
}
pub(super) async fn validate_v11_onboarding_product_events(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
for relation in [
"product_events",
"product_event_daily_rollups",
"onboarding_selections",
] {
if !relation_exists(connection, relation).await? {
return Err(schema_error(11));
}
}
if !column_exists(connection, "invocation_logs", "platform_api_key_id").await? {
return Err(schema_error(11));
}
for relation in [
"product_events_workspace_id_idempotency_key_key",
"product_events_workspace_occurred_idx",
"invocation_logs_workspace_agent_key_success_idx",
] {
if !relation_exists(connection, relation).await? {
return Err(schema_error(11));
}
}
for (table, constraint, required) in [
(
"product_events",
"product_events_id_check",
&["id~", "^pe_[a-za-z0-9_-]{1,128}$"][..],
),
(
"product_events",
"product_events_name_check",
&[
"event_name=any",
"onboarding_eligible",
"onboarding_started",
"onboarding_resumed",
"onboarding_dismissed",
"onboarding_abandoned",
"onboarding_completed",
][..],
),
(
"product_events",
"product_events_schema_version_check",
&["schema_version=1"][..],
),
(
"product_events",
"product_events_idempotency_key_check",
&[
"octet_lengthidempotency_key>=1",
"octet_lengthidempotency_key<=256",
][..],
),
(
"product_events",
"product_events_properties_check",
&[
"jsonb_typeofproperties_json='object'",
"pg_column_sizeproperties_json<=4096",
"event_name<>'onboarding_eligible'",
"properties_json@>'{\"eligible\":true}'",
"jsonb_typeofproperties_json->'eligible_since'",
"='string'",
][..],
),
(
"product_event_daily_rollups",
"product_event_daily_rollups_name_check",
&[
"event_name=any",
"onboarding_eligible",
"onboarding_completed",
][..],
),
(
"product_event_daily_rollups",
"product_event_daily_rollups_counts_check",
&[
"events_total>=0",
"eligible_total>=0",
"eligible_total<=events_total",
][..],
),
] {
let definition =
normalize_definition(&constraint_expression(connection, table, constraint).await?);
if required.iter().any(|snippet| !definition.contains(snippet)) {
return Err(schema_error(11));
}
}
validate_index(
connection,
"product_events_workspace_occurred_idx",
"product_events",
false,
&["workspace_id", "occurred_at", "id"],
&[],
)
.await?;
validate_index(
connection,
"invocation_logs_workspace_agent_key_success_idx",
"invocation_logs",
false,
&[
"workspace_id",
"agent_id",
"platform_api_key_id",
"created_atdesc",
],
&[
"platform_api_key_idisnotnull",
"source='agent_tool_call'",
"status='ok'",
],
)
.await?;
let key_scope_fk = constraint_definition(
connection,
"invocation_logs",
"invocation_logs_platform_key_scope_fk",
)
.await?;
for required in [
"foreignkeyworkspace_id,agent_id,platform_api_key_id",
"referencesplatform_api_keysworkspace_id,agent_id,id",
"ondeletesetnullplatform_api_key_id",
] {
if !key_scope_fk.contains(required) {
return Err(schema_error(11));
}
}
for (kind, name) in [
("constraint", "invocation_logs_platform_key_scope_fk"),
("trigger", "product_events_append_only_guard"),
] {
let present: bool = match kind {
"constraint" => query(
"select exists (select 1 from pg_constraint c
join pg_namespace n on n.oid = c.connamespace
where n.nspname = current_schema() and c.conname = $1) as present",
),
_ => query(
"select exists (select 1 from pg_trigger t
join pg_class c on c.oid = t.tgrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = current_schema() and t.tgname = $1 and not t.tgisinternal) as present",
),
}
.bind(name)
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get("present")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if !present {
return Err(schema_error(11));
}
}
let nullable = query(
"select is_nullable from information_schema.columns
where table_schema = current_schema()
and table_name = 'invocation_logs'
and column_name = 'platform_api_key_id'",
)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.and_then(|row| row.try_get::<String, _>("is_nullable").ok());
if nullable.as_deref() != Some("YES") {
return Err(schema_error(11));
}
let shape = constraint_expression(
connection,
"onboarding_selections",
"onboarding_selections_shape_check",
)
.await?;
let normalized_shape = normalize_definition(&shape);
for required in [
"operation_idisnull",
"operation_version>0",
"catalog_revision>0",
"invocation_log_idisnotnull",
"selected_atisnull",
"selected_atisnotnull",
] {
if !normalized_shape.contains(required) {
return Err(schema_error(11));
}
}
let function_definition = query(
"select p.prosrc as definition
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = current_schema()
and p.proname = 'crank_reject_product_event_mutation'",
)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.and_then(|row| row.try_get::<String, _>("definition").ok());
if !function_definition.is_some_and(|definition| {
let normalized = normalize_definition(&definition);
[
"notexists",
"fromworkspaces",
"old.workspace_id",
"returnold",
"producteventisappend-only",
]
.into_iter()
.all(|required| normalized.contains(required))
}) {
return Err(schema_error(11));
}
Ok(())
}
async fn constraint_definition(
connection: &mut PgConnection,
table: &str,
constraint: &str,
) -> Result<String, MigrationError> {
query(
"select pg_get_constraintdef(c.oid, true) as definition
from pg_catalog.pg_constraint c
join pg_catalog.pg_class t on t.oid = c.conrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema() and t.relname = $1 and c.conname = $2",
)
.bind(table)
.bind(constraint)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.ok_or_else(|| schema_error(11))?
.try_get::<String, _>("definition")
.map(|definition| normalize_definition(&definition))
.map_err(|_| MigrationError::storage("preflight.schema"))
}
async fn validate_index(
connection: &mut PgConnection,
index: &str,
expected_table: &str,
expected_unique: bool,
expected_columns: &[&str],
predicate_snippets: &[&str],
) -> Result<(), MigrationError> {
let row = query(
"select t.relname as table_name, am.amname as access_method,
i.indisvalid, i.indisready, i.indisunique,
pg_get_indexdef(i.indexrelid, 1, true) as first_column,
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
pg_get_indexdef(i.indexrelid, 4, true) as fourth_column,
pg_get_indexdef(i.indexrelid) as definition,
coalesce(pg_get_expr(i.indpred, i.indrelid), '') as predicate
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join pg_catalog.pg_am am on am.oid = idx.relam
where n.nspname = current_schema() and idx.relname = $1",
)
.bind(index)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = row.is_some_and(|row| {
let actual_columns = [
"first_column",
"second_column",
"third_column",
"fourth_column",
]
.into_iter()
.filter_map(|field| row.try_get::<Option<String>, _>(field).ok().flatten())
.map(|value| normalize_definition(&value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
let expected_base_columns = expected_columns
.iter()
.map(|column| column.strip_suffix("desc").unwrap_or(column).to_owned())
.collect::<Vec<_>>();
let definition = row
.try_get::<String, _>("definition")
.ok()
.map(|value| normalize_definition(&value))
.unwrap_or_default();
let predicate = row
.try_get::<String, _>("predicate")
.ok()
.map(|value| normalize_definition(&value))
.unwrap_or_default();
row.try_get::<String, _>("table_name").ok().as_deref() == Some(expected_table)
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
&& row.try_get::<bool, _>("indisunique").ok() == Some(expected_unique)
&& actual_columns == expected_base_columns
&& expected_columns
.iter()
.filter(|column| column.ends_with("desc"))
.all(|column| definition.contains(column))
&& predicate_snippets
.iter()
.all(|snippet| predicate.contains(snippet))
});
if valid { Ok(()) } else { Err(schema_error(11)) }
}
@@ -0,0 +1,231 @@
use sqlx::{PgConnection, Row, query};
use super::{
authority::MigrationError,
schema_guard::{
column_exists, constraint_expression, enum_constraint_matches, normalize_definition,
relation_exists, schema_error,
},
};
pub(super) async fn validate_v7_master_key_identity(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
for relation in ["master_key_identities", "master_key_rotations"] {
if !relation_exists(connection, relation).await? {
return Err(schema_error(7));
}
}
for (column, data_type, nullable) in [
("master_key_epoch", "bigint", false),
("target_ciphertext", "text", true),
("target_key_version", "text", true),
("target_master_key_epoch", "bigint", true),
] {
if !secret_version_column_matches(connection, column, data_type, nullable).await? {
return Err(schema_error(7));
}
}
validate_identity_constraints(connection).await?;
validate_rotation_constraints(connection).await?;
validate_secret_version_constraints(connection).await?;
validate_active_identity_index(connection).await
}
async fn secret_version_column_matches(
connection: &mut PgConnection,
column: &str,
data_type: &str,
nullable: bool,
) -> Result<bool, MigrationError> {
if !column_exists(connection, "secret_versions", column).await? {
return Ok(false);
}
let row = query(
"select data_type, is_nullable from information_schema.columns
where table_schema = current_schema()
and table_name = 'secret_versions'
and column_name = $1",
)
.bind(column)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
Ok(row.is_some_and(|row| {
row.try_get::<String, _>("data_type").ok().as_deref() == Some(data_type)
&& row.try_get::<String, _>("is_nullable").ok().as_deref()
== Some(if nullable { "YES" } else { "NO" })
}))
}
async fn validate_identity_constraints(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let identity_epoch = constraint_expression(
connection,
"master_key_identities",
"master_key_identities_epoch_check",
)
.await?;
if normalize_definition(&identity_epoch) != "epoch>0" {
return Err(schema_error(7));
}
let identity_fingerprint = constraint_expression(
connection,
"master_key_identities",
"master_key_identities_fingerprint_check",
)
.await?;
if !regex_constraint_matches(&identity_fingerprint, "fingerprint", "^[0-9a-f]{64}$") {
return Err(schema_error(7));
}
let identity_cipher = constraint_expression(
connection,
"master_key_identities",
"master_key_identities_cipher_contract_check",
)
.await?;
if !enum_constraint_matches(
&identity_cipher,
"cipher_contract",
&["secret-envelope-v2/aes-256-gcm-hkdf-sha256"],
) {
return Err(schema_error(7));
}
let identity_status = constraint_expression(
connection,
"master_key_identities",
"master_key_identities_status_check",
)
.await?;
if enum_constraint_matches(
&identity_status,
"status",
&["active", "pending", "retired", "revoked"],
) {
Ok(())
} else {
Err(schema_error(7))
}
}
async fn validate_rotation_constraints(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let rotation_state = constraint_expression(
connection,
"master_key_rotations",
"master_key_rotations_state_check",
)
.await?;
if enum_constraint_matches(
&rotation_state,
"state",
&[
"preflighted",
"running",
"verifying",
"verified",
"promoted",
"aborted",
"failed",
],
) {
Ok(())
} else {
Err(schema_error(7))
}
}
async fn validate_secret_version_constraints(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let secret_epoch = constraint_expression(
connection,
"secret_versions",
"secret_versions_master_key_epoch_check",
)
.await?;
if normalize_definition(&secret_epoch) != "master_key_epoch>0" {
return Err(schema_error(7));
}
let secret_target_epoch = constraint_expression(
connection,
"secret_versions",
"secret_versions_target_epoch_check",
)
.await?;
if normalize_definition(&secret_target_epoch)
!= "target_master_key_epochisnullortarget_master_key_epoch>master_key_epoch"
{
return Err(schema_error(7));
}
let secret_all_or_none = constraint_expression(
connection,
"secret_versions",
"secret_versions_target_all_or_none_check",
)
.await?;
if target_ciphertext_all_or_none_matches(&secret_all_or_none) {
Ok(())
} else {
Err(schema_error(7))
}
}
async fn validate_active_identity_index(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let active_index = query(
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
pg_get_expr(i.indpred, i.indrelid) as predicate
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join pg_catalog.pg_am am on am.oid = idx.relam
where n.nspname = current_schema()
and idx.relname = 'master_key_identities_active_idx'",
)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = active_index.is_some_and(|row| {
row.try_get::<String, _>("table_name").ok().as_deref() == Some("master_key_identities")
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("status")
&& row
.try_get::<String, _>("predicate")
.ok()
.is_some_and(|value| normalize_definition(&value) == "status='active'::text")
});
if valid { Ok(()) } else { Err(schema_error(7)) }
}
fn regex_constraint_matches(expression: &str, column: &str, pattern: &str) -> bool {
let normalized = normalize_definition(expression);
if !normalized.contains(column) || normalized.contains("ortrue") {
return false;
}
let values = expression
.split('\'')
.enumerate()
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
.collect::<Vec<_>>();
values == [pattern]
}
fn target_ciphertext_all_or_none_matches(expression: &str) -> bool {
let normalized = normalize_definition(expression);
!normalized.contains("ortrue")
&& normalized.contains("target_ciphertextisnull")
&& normalized.contains("target_key_versionisnull")
&& normalized.contains("target_master_key_epochisnull")
&& normalized.contains("target_ciphertextisnotnull")
&& normalized.contains("target_key_versionisnotnull")
&& normalized.contains("target_master_key_epochisnotnull")
}
@@ -0,0 +1,27 @@
use sqlx::PgConnection;
use super::authority::MigrationError;
use super::schema_guard::{column_exists, relation_exists, schema_error};
pub(super) async fn validate_v8_admin_auth_lifecycle(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
for relation in [
"admin_bootstrap_contracts",
"admin_bootstrap_contracts_single_active_idx",
"admin_bootstrap_contracts_token_hash_idx",
"admin_login_backoff",
"admin_security_audit_events",
"admin_security_audit_events_created_idx",
] {
if !relation_exists(connection, relation).await? {
return Err(schema_error(8));
}
}
for column in ["csrf_hash", "revoked_at"] {
if !column_exists(connection, "user_sessions", column).await? {
return Err(schema_error(8));
}
}
Ok(())
}
@@ -0,0 +1,201 @@
use sqlx::{PgConnection, Row, query};
use super::authority::MigrationError;
use super::schema_guard::{
column_exists, constraint_expression, normalize_definition, schema_error,
};
pub(super) async fn validate_v9_absent(
connection: &mut PgConnection,
) -> Result<bool, MigrationError> {
let columns_present = column_exists(connection, "agents", "catalog_revision").await?
|| column_exists(connection, "published_agents", "catalog_revision").await?;
let triggers_present = trigger_exists(
connection,
"agent_versions",
"agent_versions_immutable_guard",
)
.await?
|| trigger_exists(
connection,
"agent_operation_bindings",
"agent_operation_bindings_immutable_guard",
)
.await?
|| trigger_exists(
connection,
"published_agents",
"published_agents_monotonic_guard",
)
.await?;
Ok(!columns_present && !triggers_present)
}
pub(super) async fn validate_v9_agent_catalog_lifecycle(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
for (table, column) in [
("agents", "catalog_revision"),
("published_agents", "catalog_revision"),
] {
let row = query(
"select data_type, is_nullable
from information_schema.columns
where table_schema = current_schema()
and table_name = $1
and column_name = $2",
)
.bind(table)
.bind(column)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = row.is_some_and(|row| {
row.try_get::<String, _>("data_type").ok().as_deref() == Some("bigint")
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("NO")
});
if !valid {
return Err(schema_error(9));
}
}
let agents_revision =
constraint_expression(connection, "agents", "agents_catalog_revision_check").await?;
if normalize_definition(&agents_revision) != "catalog_revision>=0" {
return Err(schema_error(9));
}
let published_revision = constraint_expression(
connection,
"published_agents",
"published_agents_catalog_revision_check",
)
.await?;
if normalize_definition(&published_revision) != "catalog_revision>0" {
return Err(schema_error(9));
}
for (table, trigger, expected_events, expected_function, function_snippets) in [
(
"agent_versions",
"agent_versions_immutable_guard",
&["before", "update", "delete"][..],
"crank_reject_published_agent_version_mutation",
&["old.status='published'", "returnold", "returnnew"][..],
),
(
"agent_operation_bindings",
"agent_operation_bindings_immutable_guard",
&["before", "insert", "update", "delete"][..],
"crank_reject_published_agent_binding_mutation",
&[
"coalescenew.agent_id,old.agent_id",
"bound_status='published'",
"returnold",
"returnnew",
][..],
),
(
"published_agents",
"published_agents_monotonic_guard",
&["before", "insert", "update", "delete"][..],
"crank_reject_published_agent_pointer_rewind",
&[
"tg_op='delete'",
"new.catalog_revision<=old.catalog_revision",
"new.version<old.version",
][..],
),
] {
let Some(trigger_definition) = trigger_definition(connection, table, trigger).await? else {
return Err(schema_error(9));
};
let normalized_trigger = normalize_definition(&trigger_definition);
if expected_events
.iter()
.any(|event| !normalized_trigger.contains(event))
|| !normalized_trigger.contains(expected_function)
{
return Err(schema_error(9));
}
let function_definition = function_definition(connection, expected_function).await?;
let normalized_function = normalize_definition(&function_definition);
if function_snippets
.iter()
.any(|snippet| !normalized_function.contains(snippet))
{
return Err(schema_error(9));
}
}
Ok(())
}
async fn trigger_exists(
connection: &mut PgConnection,
table: &str,
trigger: &str,
) -> Result<bool, MigrationError> {
query(
"select exists (
select 1
from pg_catalog.pg_trigger trg
join pg_catalog.pg_class t on t.oid = trg.tgrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema()
and t.relname = $1
and trg.tgname = $2
and not trg.tgisinternal
) as present",
)
.bind(table)
.bind(trigger)
.fetch_one(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get::<bool, _>("present")
.map_err(|_| MigrationError::storage("preflight.schema"))
}
async fn trigger_definition(
connection: &mut PgConnection,
table: &str,
trigger: &str,
) -> Result<Option<String>, MigrationError> {
let row = query(
"select pg_get_triggerdef(trg.oid) as definition
from pg_catalog.pg_trigger trg
join pg_catalog.pg_class t on t.oid = trg.tgrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema()
and t.relname = $1
and trg.tgname = $2
and not trg.tgisinternal",
)
.bind(table)
.bind(trigger)
.fetch_optional(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
row.map(|row| row.try_get::<String, _>("definition"))
.transpose()
.map_err(|_| MigrationError::storage("preflight.schema"))
}
async fn function_definition(
connection: &mut PgConnection,
function_name: &str,
) -> Result<String, MigrationError> {
query(
"select pg_get_functiondef(p.oid) as definition
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = current_schema()
and p.proname = $1",
)
.bind(function_name)
.fetch_optional(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.ok_or_else(|| schema_error(9))?
.try_get::<String, _>("definition")
.map_err(|_| MigrationError::storage("preflight.schema"))
}
+262 -6
View File
@@ -1,10 +1,12 @@
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
Protocol, SampleId, Secret, SecretId, SecretVersion, ToolSelectionPolicy, UsagePeriod,
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExecutionErrorCode,
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
InvocationStatus, MembershipRole, OnboardingProjection, Operation, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, ProductEvent,
ProductEventId, ProductEventKind, Protocol, SampleId, Secret, SecretId, SecretVersion,
ToolSelectionPolicy, UsagePeriod, UsageRollup, User, UserId, UserSessionId, Workspace,
WorkspaceId,
};
use crank_mapping::MappingSet;
use crank_schema::Schema;
@@ -84,6 +86,56 @@ pub struct SessionRecord {
pub user: User,
pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<WorkspaceId>,
pub csrf_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminBootstrapContractRecord {
pub id: String,
pub email: String,
pub display_name: String,
pub status: String,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub used_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateAdminBootstrapContractRequest<'a> {
pub id: &'a str,
pub token_hash: &'a str,
pub email: &'a str,
pub display_name: &'a str,
pub expires_at: &'a OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeAdminBootstrapContractRequest<'a> {
pub token_hash: &'a str,
pub password_hash: &'a str,
pub now: &'a OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AdminSecurityAuditRequest<'a> {
pub id: &'a str,
pub action: &'a str,
pub outcome: &'a str,
pub actor_user_id: Option<&'a UserId>,
pub session_id: Option<&'a UserSessionId>,
pub request_id: Option<&'a str>,
pub trace_id: Option<&'a str>,
pub source: &'a str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecoverAdminPasswordRequest<'a> {
pub email: &'a str,
pub password_hash: &'a str,
pub audit_id: &'a str,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -109,6 +161,60 @@ pub struct SecretRecord {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecretVersionRecord {
pub secret_version: SecretVersion,
pub master_key_epoch: i64,
pub target_ciphertext: Option<String>,
pub target_key_version: Option<String>,
pub target_master_key_epoch: Option<i64>,
}
pub const MASTER_KEY_CIPHER_CONTRACT: &str = "secret-envelope-v2/aes-256-gcm-hkdf-sha256";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MasterKeyIdentityRecord {
pub epoch: i64,
pub fingerprint: String,
pub cipher_contract: String,
pub status: String,
pub backup_ref: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub activated_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub retired_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MasterKeyIdentityCandidate<'a> {
pub epoch: i64,
pub fingerprint: &'a str,
pub cipher_contract: &'a str,
pub observed_at: &'a OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MasterKeyRotationRecord {
pub id: String,
pub source_epoch: i64,
pub target_epoch: i64,
pub target_fingerprint: String,
pub state: String,
pub backup_ref: Option<String>,
pub checkpoint_secret_id: Option<String>,
pub total_secret_versions: i64,
pub processed_secret_versions: i64,
pub verified_secret_versions: i64,
pub failure_code: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MasterKeyRotationStatus {
pub active_identity: Option<MasterKeyIdentityRecord>,
pub rotations: Vec<MasterKeyRotationRecord>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -170,6 +276,114 @@ pub struct UsageAgentBreakdown {
pub p99_ms: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageOutcomeGroup {
Success,
Upstream,
Client,
Schema,
Crank,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UsageOutcomeBreakdown {
pub group: UsageOutcomeGroup,
pub execution_error_code: Option<ExecutionErrorCode>,
pub calls_total: u64,
pub p50_ms: u64,
pub p95_ms: u64,
pub p99_ms: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppendProductEventOutcome {
Recorded,
Duplicate,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProductEventRecord {
pub event: ProductEvent,
}
#[derive(Clone, Debug)]
pub struct AppendProductEventRequest<'a> {
pub event: &'a ProductEvent,
}
#[derive(Clone, Debug)]
pub struct ListProductEventsQuery<'a> {
pub workspace_id: &'a WorkspaceId,
pub kind: Option<ProductEventKind>,
pub created_after: OffsetDateTime,
pub created_before: OffsetDateTime,
pub limit: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardingPresentationMilestone {
Eligible,
Started,
Resumed,
Dismissed,
Abandoned,
}
#[derive(Clone, Debug)]
pub struct RecordOnboardingMilestoneRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub event_id: &'a ProductEventId,
pub milestone: OnboardingPresentationMilestone,
pub idempotency_key: &'a str,
pub expected_revision: i64,
pub occurred_at: OffsetDateTime,
pub eligible_since: Option<OffsetDateTime>,
}
#[derive(Clone, Debug)]
pub struct RecordOnboardingCompletionRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub event_id: &'a ProductEventId,
pub idempotency_key: &'a str,
pub expected_revision: i64,
pub occurred_at: OffsetDateTime,
pub eligible_since: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingMilestoneResult {
pub accepted: bool,
pub projection: OnboardingProjection,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InvocationRetentionStatus {
Noop,
Completed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InvocationRetentionPolicy {
#[serde(with = "time::serde::rfc3339")]
pub requested_cutoff: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub effective_cutoff: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub usage_preservation_floor: OffsetDateTime,
pub preserved_usage_window_days: u16,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InvocationRetentionOutcome {
pub status: InvocationRetentionStatus,
pub deleted_records: u64,
pub policy: InvocationRetentionPolicy,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AgentSummary {
pub id: AgentId,
@@ -180,6 +394,7 @@ pub struct AgentSummary {
pub status: AgentStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub catalog_revision: i64,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub published_at: Option<OffsetDateTime>,
@@ -196,6 +411,15 @@ pub struct AgentVersionRecord {
pub bindings: Vec<AgentOperationBinding>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentStateExpectation {
pub status: AgentStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub catalog_revision: i64,
pub updated_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PublishedAgentTool {
pub workspace_id: WorkspaceId,
@@ -211,6 +435,7 @@ pub struct PublishedAgentTool {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PublishedAgentCatalog {
pub agent_version: u32,
pub catalog_revision: String,
pub tool_selection_policy: ToolSelectionPolicy,
pub tools: Vec<PublishedAgentTool>,
}
@@ -229,6 +454,7 @@ pub struct OperationSummary {
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub can_delete: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub published_at: Option<OffsetDateTime>,
@@ -485,11 +711,16 @@ impl InvocationHistoryWriteOutcome {
pub struct ListInvocationLogsQuery<'a> {
pub workspace_id: &'a WorkspaceId,
pub level: Option<InvocationLevel>,
pub status: Option<InvocationStatus>,
pub outcome_group: Option<UsageOutcomeGroup>,
pub search_text: Option<&'a str>,
pub source: Option<InvocationSource>,
pub operation_id: Option<&'a OperationId>,
pub agent_id: Option<&'a AgentId>,
pub created_after: Option<&'a str>,
pub created_before: Option<&'a str>,
pub cursor_created_at: Option<&'a str>,
pub cursor_id: Option<&'a InvocationLogId>,
pub limit: u32,
}
@@ -507,6 +738,7 @@ pub struct UsageQuery<'a> {
pub period: UsagePeriod,
pub source: Option<InvocationSource>,
pub created_after: &'a str,
pub created_before: &'a str,
pub bucket: UsageBucket,
}
@@ -524,6 +756,13 @@ pub struct CreateVersionRequest<'a> {
pub created_by: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OperationStateExpectation {
pub current_draft_version: u32,
pub status: OperationStatus,
pub latest_published_version: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishRequest<'a> {
pub workspace_id: &'a WorkspaceId,
@@ -579,12 +818,22 @@ pub struct CreateAgentDraftVersionRequest<'a> {
pub updated_at: &'a OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UpdateAgentSummaryRequest<'a> {
pub slug: &'a str,
pub display_name: &'a str,
pub description: &'a str,
pub updated_at: &'a OffsetDateTime,
pub expected_state: Option<&'a AgentStateExpectation>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SaveAgentBindingsRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub agent_version: u32,
pub bindings: &'a [AgentOperationBinding],
pub expected_state: Option<&'a AgentStateExpectation>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -594,6 +843,7 @@ pub struct SaveAgentCatalogConfigRequest<'a> {
pub agent_version: u32,
pub bindings: &'a [AgentOperationBinding],
pub tool_selection_policy: &'a ToolSelectionPolicy,
pub expected_state: Option<&'a AgentStateExpectation>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -603,6 +853,7 @@ pub struct PublishAgentRequest<'a> {
pub version: u32,
pub published_at: &'a OffsetDateTime,
pub published_by: Option<&'a str>,
pub expected_state: Option<&'a AgentStateExpectation>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -633,9 +884,12 @@ pub struct DecideApprovalRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub approval_id: &'a ApprovalRequestId,
pub operation_id: &'a OperationId,
pub operation_version: u32,
pub request_payload: &'a Value,
pub status: ApprovalRequestStatus,
pub decided_at: OffsetDateTime,
pub decided_by_key_id: &'a PlatformApiKeyId,
pub decided_by_key_id: Option<&'a PlatformApiKeyId>,
pub response_payload: Option<Value>,
pub decision_note: Option<&'a str>,
}
@@ -663,6 +917,7 @@ pub struct CreateSecretRequest<'a> {
pub secret: &'a Secret,
pub ciphertext: &'a str,
pub key_version: &'a str,
pub master_key_epoch: i64,
pub created_by: Option<&'a crank_core::UserId>,
}
@@ -672,6 +927,7 @@ pub struct RotateSecretRequest<'a> {
pub secret_id: &'a SecretId,
pub ciphertext: &'a str,
pub key_version: &'a str,
pub master_key_epoch: i64,
pub created_at: &'a OffsetDateTime,
pub updated_at: &'a OffsetDateTime,
pub created_by: Option<&'a crank_core::UserId>,
+382 -290
View File
@@ -1,47 +1,12 @@
use super::*;
use crate::model::{AgentStateExpectation, UpdateAgentSummaryRequest};
impl PostgresRegistry {
pub async fn get_published_agent_catalog_by_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> Result<PublishedAgentCatalog, RegistryError> {
let row = sqlx::query(
"select
pa.version,
av.tool_selection_policy_json
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
join agent_versions av on av.agent_id = a.id and av.version = pa.version
where w.slug = $1 and a.slug = $2",
)
.bind(workspace_slug)
.bind(agent_slug)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
workspace_slug: workspace_slug.to_owned(),
agent_slug: agent_slug.to_owned(),
})?;
let tools = self
.get_published_agent_tools_by_slug(workspace_slug, agent_slug)
.await?;
Ok(PublishedAgentCatalog {
agent_version: from_db_version(row.try_get("version")?, "agent_version")?,
tool_selection_policy: deserialize_json_value(
row.try_get("tool_selection_policy_json")?,
)?,
tools,
})
}
pub async fn list_agents(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<AgentSummary>, RegistryError> {
let rows = sqlx::query!(
let rows = sqlx::query(
"select
id,
workspace_id,
@@ -51,31 +16,33 @@ impl PostgresRegistry {
status,
current_draft_version,
latest_published_version,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\",
published_at as \"published_at: time::OffsetDateTime\"
catalog_revision,
created_at,
updated_at,
published_at
from agents
where workspace_id = $1
order by slug asc",
workspace_id.as_str(),
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
build_agent_summary(
row.id,
row.workspace_id,
row.slug,
row.display_name,
row.description,
row.status,
row.current_draft_version,
row.latest_published_version,
row.created_at,
row.updated_at,
row.published_at,
row.try_get("id")?,
row.try_get("workspace_id")?,
row.try_get("slug")?,
row.try_get("display_name")?,
row.try_get("description")?,
row.try_get("status")?,
row.try_get("current_draft_version")?,
row.try_get("latest_published_version")?,
row.try_get("catalog_revision")?,
row.try_get("created_at")?,
row.try_get("updated_at")?,
row.try_get("published_at")?,
)
})
.collect()
@@ -86,7 +53,7 @@ impl PostgresRegistry {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Option<AgentSummary>, RegistryError> {
let row = sqlx::query!(
let row = sqlx::query(
"select
id,
workspace_id,
@@ -96,30 +63,32 @@ impl PostgresRegistry {
status,
current_draft_version,
latest_published_version,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\",
published_at as \"published_at: time::OffsetDateTime\"
catalog_revision,
created_at,
updated_at,
published_at
from agents
where workspace_id = $1 and id = $2",
workspace_id.as_str(),
agent_id.as_str(),
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
build_agent_summary(
row.id,
row.workspace_id,
row.slug,
row.display_name,
row.description,
row.status,
row.current_draft_version,
row.latest_published_version,
row.created_at,
row.updated_at,
row.published_at,
row.try_get("id")?,
row.try_get("workspace_id")?,
row.try_get("slug")?,
row.try_get("display_name")?,
row.try_get("description")?,
row.try_get("status")?,
row.try_get("current_draft_version")?,
row.try_get("latest_published_version")?,
row.try_get("catalog_revision")?,
row.try_get("created_at")?,
row.try_get("updated_at")?,
row.try_get("published_at")?,
)
})
.transpose()
@@ -162,6 +131,7 @@ impl PostgresRegistry {
insert_agent_version_row(&mut tx, request.version).await?;
replace_agent_bindings_rows(
&mut tx,
&request.agent.workspace_id,
&request.agent.id,
request.version.version,
request.bindings,
@@ -198,6 +168,7 @@ impl PostgresRegistry {
insert_agent_version_row(&mut tx, request.version).await?;
replace_agent_bindings_rows(
&mut tx,
request.workspace_id,
request.agent_id,
request.version.version,
request.bindings,
@@ -268,19 +239,19 @@ impl PostgresRegistry {
&self,
request: SaveAgentBindingsRequest<'_>,
) -> Result<(), RegistryError> {
if self
.get_agent_summary(request.workspace_id, request.agent_id)
.await?
.is_none()
{
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
}
let mut tx = self.pool.begin().await?;
Self::lock_agent_and_validate_expected_state(
&mut tx,
request.workspace_id,
request.agent_id,
request.expected_state,
)
.await?;
Self::reject_published_agent_version(&mut tx, request.agent_id, request.agent_version)
.await?;
replace_agent_bindings_rows(
&mut tx,
request.workspace_id,
request.agent_id,
request.agent_version,
request.bindings,
@@ -294,17 +265,16 @@ impl PostgresRegistry {
&self,
request: SaveAgentCatalogConfigRequest<'_>,
) -> Result<(), RegistryError> {
if self
.get_agent_summary(request.workspace_id, request.agent_id)
.await?
.is_none()
{
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
}
let mut tx = self.pool.begin().await?;
Self::lock_agent_and_validate_expected_state(
&mut tx,
request.workspace_id,
request.agent_id,
request.expected_state,
)
.await?;
Self::reject_published_agent_version(&mut tx, request.agent_id, request.agent_version)
.await?;
let updated = sqlx::query(
"update agent_versions
set tool_selection_policy_json = $3
@@ -323,24 +293,124 @@ impl PostgresRegistry {
}
replace_agent_bindings_rows(
&mut tx,
request.workspace_id,
request.agent_id,
request.agent_version,
request.bindings,
)
.await?;
sqlx::query(
"update agents
set updated_at = now()
where id = $1 and workspace_id = $2",
)
.bind(request.agent_id.as_str())
.bind(request.workspace_id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn lock_agent_and_validate_expected_state(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<(), RegistryError> {
let row = sqlx::query(
"select
status,
current_draft_version,
latest_published_version,
catalog_revision,
updated_at
from agents
where workspace_id = $1 and id = $2
for update",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.fetch_optional(&mut **tx)
.await?;
let Some(row) = row else {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
};
if let Some(expected) = expected_state {
let status = deserialize_enum_text::<AgentStatus>(row.try_get("status")?, "status")?;
let current_draft_version = from_db_version(
row.try_get("current_draft_version")?,
"current_draft_version",
)?;
let latest_published_version = row
.try_get::<Option<i32>, _>("latest_published_version")?
.map(|value| from_db_version(value, "latest_published_version"))
.transpose()?;
let catalog_revision: i64 = row.try_get("catalog_revision")?;
let updated_at: time::OffsetDateTime = row.try_get("updated_at")?;
if status != expected.status
|| current_draft_version != expected.current_draft_version
|| latest_published_version != expected.latest_published_version
|| catalog_revision != expected.catalog_revision
|| updated_at != expected.updated_at
{
return Err(RegistryError::AgentStaleRevision {
agent_id: agent_id.as_str().to_owned(),
});
}
}
Ok(())
}
async fn reject_published_agent_version(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
agent_id: &AgentId,
version: u32,
) -> Result<(), RegistryError> {
let row = sqlx::query(
"select status
from agent_versions
where agent_id = $1 and version = $2
for update",
)
.bind(agent_id.as_str())
.bind(to_db_version(version))
.fetch_optional(&mut **tx)
.await?;
let Some(row) = row else {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
};
let status = deserialize_enum_text::<AgentStatus>(row.try_get("status")?, "status")?;
if status == AgentStatus::Published {
return Err(RegistryError::ImmutableAgentVersion {
agent_id: agent_id.as_str().to_owned(),
version,
});
}
Ok(())
}
pub async fn update_agent_summary(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
slug: &str,
display_name: &str,
description: &str,
updated_at: &time::OffsetDateTime,
update: UpdateAgentSummaryRequest<'_>,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
Self::lock_agent_and_validate_expected_state(
&mut tx,
workspace_id,
agent_id,
update.expected_state,
)
.await?;
let result = sqlx::query(
"update agents
set slug = $3,
@@ -351,11 +421,11 @@ impl PostgresRegistry {
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(slug)
.bind(display_name)
.bind(description)
.bind(updated_at)
.execute(&self.pool)
.bind(update.slug)
.bind(update.display_name)
.bind(update.description)
.bind(update.updated_at)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
@@ -364,6 +434,7 @@ impl PostgresRegistry {
});
}
tx.commit().await?;
Ok(())
}
@@ -371,19 +442,36 @@ impl PostgresRegistry {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<(), RegistryError> {
let result = sqlx::query("delete from agents where workspace_id = $1 and id = $2")
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.execute(&self.pool)
.await?;
let mut tx = self.pool.begin().await?;
Self::lock_agent_and_validate_expected_state(
&mut tx,
workspace_id,
agent_id,
expected_state,
)
.await?;
let result = sqlx::query(
"delete from agents
where workspace_id = $1
and id = $2
and latest_published_version is null",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
return Err(RegistryError::InvalidAgentTransition {
agent_id: agent_id.as_str().to_owned(),
from: "published".to_owned(),
action: "delete",
});
}
tx.commit().await?;
Ok(())
}
@@ -391,37 +479,102 @@ impl PostgresRegistry {
&self,
request: PublishAgentRequest<'_>,
) -> Result<(), RegistryError> {
if self
.get_agent_version(request.workspace_id, request.agent_id, request.version)
.await?
.is_none()
{
let mut tx = self.pool.begin().await?;
let Some(agent_row) = sqlx::query(
"select status, current_draft_version, latest_published_version, catalog_revision, updated_at
from agents
where id = $1 and workspace_id = $2
for update",
)
.bind(request.agent_id.as_str())
.bind(request.workspace_id.as_str())
.fetch_optional(&mut *tx)
.await?
else {
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
};
if let Some(expected) = request.expected_state {
let status =
deserialize_enum_text::<AgentStatus>(agent_row.try_get("status")?, "status")?;
let expected_current_draft_version = from_db_version(
agent_row.try_get("current_draft_version")?,
"current_draft_version",
)?;
let expected_latest_published_version = agent_row
.try_get::<Option<i32>, _>("latest_published_version")?
.map(|value| from_db_version(value, "latest_published_version"))
.transpose()?;
let expected_catalog_revision: i64 = agent_row.try_get("catalog_revision")?;
let expected_updated_at: time::OffsetDateTime = agent_row.try_get("updated_at")?;
if status != expected.status
|| expected_current_draft_version != expected.current_draft_version
|| expected_latest_published_version != expected.latest_published_version
|| expected_catalog_revision != expected.catalog_revision
|| expected_updated_at != expected.updated_at
{
return Err(RegistryError::AgentStaleRevision {
agent_id: request.agent_id.as_str().to_owned(),
});
}
}
let agent_status =
deserialize_enum_text::<AgentStatus>(agent_row.try_get("status")?, "status")?;
if agent_status == AgentStatus::Archived {
return Err(RegistryError::InvalidAgentTransition {
agent_id: request.agent_id.as_str().to_owned(),
from: "archived".to_owned(),
action: "publish",
});
}
let current_draft_version = from_db_version(
agent_row.try_get("current_draft_version")?,
"current_draft_version",
)?;
if request.version != current_draft_version {
return Err(RegistryError::InvalidAgentVersionSequence {
agent_id: request.agent_id.as_str().to_owned(),
expected: current_draft_version,
actual: request.version,
});
}
let latest_published_version = agent_row
.try_get::<Option<i32>, _>("latest_published_version")?
.map(|value| from_db_version(value, "latest_published_version"))
.transpose()?;
if latest_published_version.is_some_and(|latest| request.version < latest) {
return Err(RegistryError::InvalidAgentVersionSequence {
agent_id: request.agent_id.as_str().to_owned(),
expected: latest_published_version.unwrap_or(request.version),
actual: request.version,
});
}
let catalog_revision: i64 = agent_row.try_get("catalog_revision")?;
let next_catalog_revision = catalog_revision
.checked_add(1)
.filter(|value| *value > 0)
.ok_or(RegistryError::InvalidNumericValue {
field: "catalog_revision",
value: catalog_revision,
})?;
let version_exists = sqlx::query(
"select 1
from agent_versions
where agent_id = $1 and version = $2",
)
.bind(request.agent_id.as_str())
.bind(to_db_version(request.version))
.fetch_optional(&mut *tx)
.await?
.is_some();
if !version_exists {
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
}
let mut tx = self.pool.begin().await?;
sqlx::query(
"insert into published_agents (
agent_id,
version,
published_at,
published_by
) values ($1, $2, $3::timestamptz, $4)
on conflict(agent_id) do update set
version = excluded.version,
published_at = excluded.published_at,
published_by = excluded.published_by",
)
.bind(request.agent_id.as_str())
.bind(to_db_version(request.version))
.bind(request.published_at)
.bind(request.published_by)
.execute(&mut *tx)
.await?;
sqlx::query(
"update agent_versions
set status = $1
@@ -433,16 +586,40 @@ impl PostgresRegistry {
.execute(&mut *tx)
.await?;
sqlx::query(
"insert into published_agents (
agent_id,
version,
catalog_revision,
published_at,
published_by
) values ($1, $2, $3, $4::timestamptz, $5)
on conflict(agent_id) do update set
version = excluded.version,
catalog_revision = excluded.catalog_revision,
published_at = excluded.published_at,
published_by = excluded.published_by",
)
.bind(request.agent_id.as_str())
.bind(to_db_version(request.version))
.bind(next_catalog_revision)
.bind(request.published_at)
.bind(request.published_by)
.execute(&mut *tx)
.await?;
sqlx::query(
"update agents
set status = $1,
latest_published_version = $2,
published_at = $3::timestamptz,
updated_at = $4::timestamptz
where id = $5 and workspace_id = $6",
catalog_revision = $3,
published_at = $4::timestamptz,
updated_at = $5::timestamptz
where id = $6 and workspace_id = $7",
)
.bind(serialize_enum_text(&AgentStatus::Published, "status")?)
.bind(to_db_version(request.version))
.bind(next_catalog_revision)
.bind(request.published_at)
.bind(request.published_at)
.bind(request.agent_id.as_str())
@@ -459,38 +636,55 @@ impl PostgresRegistry {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
updated_at: &time::OffsetDateTime,
expected_state: Option<&AgentStateExpectation>,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("delete from published_agents where agent_id = $1")
.bind(agent_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update agent_versions
set status = $1
where agent_id = $2
and version = (
select current_draft_version
from agents
where id = $2 and workspace_id = $3
)",
Self::lock_agent_and_validate_expected_state(
&mut tx,
workspace_id,
agent_id,
expected_state,
)
.await?;
let current_status = sqlx::query(
"select status, catalog_revision
from agents
where id = $1 and workspace_id = $2
for update",
)
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
.execute(&mut *tx)
.fetch_one(&mut *tx)
.await?;
let status =
deserialize_enum_text::<AgentStatus>(current_status.try_get("status")?, "status")?;
if status != AgentStatus::Published {
return Err(RegistryError::InvalidAgentTransition {
agent_id: agent_id.as_str().to_owned(),
from: serialize_enum_text(&status, "status")?,
action: "unpublish",
});
}
let catalog_revision: i64 = current_status.try_get("catalog_revision")?;
let next_catalog_revision = catalog_revision
.checked_add(1)
.filter(|value| *value > 0)
.ok_or(RegistryError::InvalidNumericValue {
field: "catalog_revision",
value: catalog_revision,
})?;
let result = sqlx::query(
"update agents
set status = $1,
catalog_revision = $2,
published_at = null,
updated_at = $2::timestamptz
where id = $3 and workspace_id = $4",
updated_at = $3::timestamptz
where id = $4 and workspace_id = $5",
)
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
.bind(next_catalog_revision)
.bind(updated_at)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
@@ -512,38 +706,55 @@ impl PostgresRegistry {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
updated_at: &time::OffsetDateTime,
expected_state: Option<&AgentStateExpectation>,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("delete from published_agents where agent_id = $1")
.bind(agent_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update agent_versions
set status = $1
where agent_id = $2
and version = (
select current_draft_version
from agents
where id = $2 and workspace_id = $3
)",
Self::lock_agent_and_validate_expected_state(
&mut tx,
workspace_id,
agent_id,
expected_state,
)
.await?;
let current_status = sqlx::query(
"select status, catalog_revision
from agents
where id = $1 and workspace_id = $2
for update",
)
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
.execute(&mut *tx)
.fetch_one(&mut *tx)
.await?;
let status =
deserialize_enum_text::<AgentStatus>(current_status.try_get("status")?, "status")?;
if status == AgentStatus::Archived {
return Err(RegistryError::InvalidAgentTransition {
agent_id: agent_id.as_str().to_owned(),
from: "archived".to_owned(),
action: "archive",
});
}
let catalog_revision: i64 = current_status.try_get("catalog_revision")?;
let next_catalog_revision = catalog_revision
.checked_add(1)
.filter(|value| *value > 0)
.ok_or(RegistryError::InvalidNumericValue {
field: "catalog_revision",
value: catalog_revision,
})?;
let result = sqlx::query(
"update agents
set status = $1,
catalog_revision = $2,
published_at = null,
updated_at = $2::timestamptz
where id = $3 and workspace_id = $4",
updated_at = $3::timestamptz
where id = $4 and workspace_id = $5",
)
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
.bind(next_catalog_revision)
.bind(updated_at)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
@@ -559,123 +770,4 @@ impl PostgresRegistry {
tx.commit().await?;
Ok(())
}
pub async fn get_published_agent_tools_by_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
let rows = sqlx::query!(
"select
w.id as workspace_id,
w.slug as workspace_slug,
a.id as agent_id,
a.slug as agent_slug,
b.tool_name,
b.tool_title,
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",
o.id,
o.name,
o.display_name,
o.category,
o.protocol,
o.security_level,
o.created_at as \"operation_created_at!: time::OffsetDateTime\",
o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",
o.published_at as \"operation_published_at: time::OffsetDateTime\",
ov.version,
ov.status,
ov.target_json,
ov.input_schema_json,
ov.output_schema_json,
ov.input_mapping_json,
ov.output_mapping_json,
ov.execution_config_json,
ov.tool_description_json,
ov.samples_json,
ov.generated_draft_json,
ov.config_export_json,
ov.wizard_state_json,
ov.change_note,
ov.created_at as \"created_at!: time::OffsetDateTime\",
ov.created_by
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
where w.slug = $1 and a.slug = $2 and b.enabled = true
order by b.tool_name asc",
workspace_slug,
agent_slug,
)
.fetch_all(&self.pool)
.await?;
if rows.is_empty() {
let exists = sqlx::query!(
"select 1 as \"present!\"
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
where w.slug = $1 and a.slug = $2",
workspace_slug,
agent_slug,
)
.fetch_optional(&self.pool)
.await?;
if exists.is_none() {
return Err(RegistryError::PublishedAgentNotFound {
workspace_slug: workspace_slug.to_owned(),
agent_slug: agent_slug.to_owned(),
});
}
}
rows.into_iter()
.map(|row| {
let workspace_id = row.workspace_id.clone();
build_published_agent_tool(
row.workspace_id,
row.workspace_slug,
row.agent_id,
row.agent_slug,
row.tool_name,
row.tool_title,
row.tool_description,
build_operation_version_record(
row.id,
workspace_id,
row.name,
row.display_name,
row.category,
row.protocol,
row.security_level,
row.operation_created_at,
row.operation_updated_at,
row.operation_published_at,
row.version,
row.status,
row.target_json,
row.input_schema_json,
row.output_schema_json,
row.input_mapping_json,
row.output_mapping_json,
row.execution_config_json,
row.tool_description_json,
row.samples_json,
row.generated_draft_json,
row.config_export_json,
row.wizard_state_json,
row.change_note,
row.created_at,
row.created_by,
)?
.snapshot,
)
})
.collect()
}
}
@@ -0,0 +1,201 @@
use super::*;
impl PostgresRegistry {
pub async fn get_published_agent_catalog_by_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> Result<PublishedAgentCatalog, RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("set transaction isolation level repeatable read")
.execute(&mut *tx)
.await?;
let row = sqlx::query(
"select
pa.version,
pa.catalog_revision,
av.tool_selection_policy_json
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
join agent_versions av on av.agent_id = a.id and av.version = pa.version
where w.slug = $1
and a.slug = $2
and a.status = 'published'
and av.status = 'published'",
)
.bind(workspace_slug)
.bind(agent_slug)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
workspace_slug: workspace_slug.to_owned(),
agent_slug: agent_slug.to_owned(),
})?;
let tools = query_published_agent_tools(&mut tx, workspace_slug, agent_slug).await?;
let agent_version = from_db_version(row.try_get("version")?, "agent_version")?;
let catalog_revision: i64 = row.try_get("catalog_revision")?;
tx.commit().await?;
Ok(PublishedAgentCatalog {
agent_version,
catalog_revision: format!(
"agent-version-{agent_version}-catalog-revision-{catalog_revision}"
),
tool_selection_policy: deserialize_json_value(
row.try_get("tool_selection_policy_json")?,
)?,
tools,
})
}
pub async fn get_published_agent_tools_by_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
let rows = published_agent_tools_query()
.bind(workspace_slug)
.bind(agent_slug)
.fetch_all(&self.pool)
.await?;
if rows.is_empty() {
let exists = sqlx::query(
"select 1 as present
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
where w.slug = $1
and a.slug = $2
and a.status = 'published'",
)
.bind(workspace_slug)
.bind(agent_slug)
.fetch_optional(&self.pool)
.await?;
if exists.is_none() {
return Err(RegistryError::PublishedAgentNotFound {
workspace_slug: workspace_slug.to_owned(),
agent_slug: agent_slug.to_owned(),
});
}
}
published_agent_tools_from_rows(rows)
}
}
async fn query_published_agent_tools(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
workspace_slug: &str,
agent_slug: &str,
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
let rows = published_agent_tools_query()
.bind(workspace_slug)
.bind(agent_slug)
.fetch_all(&mut **tx)
.await?;
published_agent_tools_from_rows(rows)
}
fn published_agent_tools_query()
-> sqlx::query::Query<'static, sqlx::Postgres, sqlx::postgres::PgArguments> {
sqlx::query(
"select
w.id as workspace_id,
w.slug as workspace_slug,
a.id as agent_id,
a.slug as agent_slug,
b.tool_name,
b.tool_title,
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as tool_description,
o.id,
ov.name,
ov.display_name,
ov.category,
ov.protocol,
ov.security_level,
ov.created_at as operation_created_at,
ov.created_at as operation_updated_at,
ov.published_at as operation_published_at,
ov.version,
ov.status,
ov.target_json,
ov.input_schema_json,
ov.output_schema_json,
ov.input_mapping_json,
ov.output_mapping_json,
ov.execution_config_json,
ov.tool_description_json,
ov.samples_json,
ov.generated_draft_json,
ov.config_export_json,
ov.wizard_state_json,
ov.change_note,
ov.created_at as created_at,
ov.created_by
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
where w.slug = $1
and a.slug = $2
and a.status = 'published'
and ov.status = 'published'
and b.enabled = true
order by b.tool_name asc",
)
}
fn published_agent_tools_from_rows(
rows: Vec<sqlx::postgres::PgRow>,
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
rows.into_iter()
.map(|row| {
let workspace_id: String = row.try_get("workspace_id")?;
build_published_agent_tool(
workspace_id.clone(),
row.try_get("workspace_slug")?,
row.try_get("agent_id")?,
row.try_get("agent_slug")?,
row.try_get("tool_name")?,
row.try_get("tool_title")?,
row.try_get("tool_description")?,
build_operation_version_record(
row.try_get("id")?,
workspace_id,
row.try_get("name")?,
row.try_get("display_name")?,
row.try_get("category")?,
row.try_get("protocol")?,
row.try_get("security_level")?,
row.try_get("operation_created_at")?,
row.try_get("operation_updated_at")?,
row.try_get("operation_published_at")?,
row.try_get("version")?,
row.try_get("status")?,
row.try_get("target_json")?,
row.try_get("input_schema_json")?,
row.try_get("output_schema_json")?,
row.try_get("input_mapping_json")?,
row.try_get("output_mapping_json")?,
row.try_get("execution_config_json")?,
row.try_get("tool_description_json")?,
row.try_get("samples_json")?,
row.try_get("generated_draft_json")?,
row.try_get("config_export_json")?,
row.try_get("wizard_state_json")?,
row.try_get("change_note")?,
row.try_get("created_at")?,
row.try_get("created_by")?,
)?
.snapshot,
)
})
.collect()
}
+141 -38
View File
@@ -274,9 +274,18 @@ impl PostgresRegistry {
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(error))
if error.constraint() == Some("platform_api_keys_workspace_name_idx") =>
if matches!(
error.constraint(),
Some(
"platform_api_keys_workspace_name_idx"
| "platform_api_keys_workspace_name_active_idx"
)
) =>
{
Err(RegistryError::Storage(sqlx::Error::Database(error)))
Err(RegistryError::PlatformApiKeyNameAlreadyExists {
workspace_id: request.api_key.workspace_id.as_str().to_owned(),
name: request.api_key.name.clone(),
})
}
Err(error) => Err(RegistryError::Storage(error)),
}
@@ -288,11 +297,22 @@ impl PostgresRegistry {
key_id: &PlatformApiKeyId,
revoked_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3 and id = $4",
let row = sqlx::query(
"with target as (
select id
from platform_api_keys
where workspace_id = $3 and id = $4
), updated as (
update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3
and id = $4
and status = 'active'
returning id
)
select exists(select 1 from target) as exists,
exists(select 1 from updated) as updated",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Revoked,
@@ -301,14 +321,19 @@ impl PostgresRegistry {
.bind(revoked_at)
.bind(workspace_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.fetch_one(&self.pool)
.await?;
if result.rows_affected() == 0 {
if !row.get::<bool, _>("exists") {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
if !row.get::<bool, _>("updated") {
return Err(RegistryError::PlatformApiKeyInactive {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
@@ -320,11 +345,23 @@ impl PostgresRegistry {
key_id: &PlatformApiKeyId,
revoked_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3 and agent_id = $4 and id = $5",
let row = sqlx::query(
"with target as (
select id
from platform_api_keys
where workspace_id = $3 and agent_id = $4 and id = $5
), updated as (
update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3
and agent_id = $4
and id = $5
and status = 'active'
returning id
)
select exists(select 1 from target) as exists,
exists(select 1 from updated) as updated",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Revoked,
@@ -334,14 +371,19 @@ impl PostgresRegistry {
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.fetch_one(&self.pool)
.await?;
if result.rows_affected() == 0 {
if !row.get::<bool, _>("exists") {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
if !row.get::<bool, _>("updated") {
return Err(RegistryError::PlatformApiKeyInactive {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
@@ -351,18 +393,42 @@ impl PostgresRegistry {
workspace_id: &WorkspaceId,
key_id: &PlatformApiKeyId,
) -> Result<(), RegistryError> {
let result =
sqlx::query("delete from platform_api_keys where workspace_id = $1 and id = $2")
.bind(workspace_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
let row = sqlx::query(
"with target as (
select id
from platform_api_keys
where workspace_id = $2 and id = $3
), updated as (
update platform_api_keys
set status = $1,
revoked_at = coalesce(revoked_at, now())
where workspace_id = $2
and id = $3
and status <> 'deleted'
returning id
)
select exists(select 1 from target) as exists,
exists(select 1 from updated) as updated",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Deleted,
"status",
)?)
.bind(workspace_id.as_str())
.bind(key_id.as_str())
.fetch_one(&self.pool)
.await?;
if result.rows_affected() == 0 {
if !row.get::<bool, _>("exists") {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
if !row.get::<bool, _>("updated") {
return Err(RegistryError::PlatformApiKeyInactive {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
@@ -373,20 +439,44 @@ impl PostgresRegistry {
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"delete from platform_api_keys where workspace_id = $1 and agent_id = $2 and id = $3",
let row = sqlx::query(
"with target as (
select id
from platform_api_keys
where workspace_id = $2 and agent_id = $3 and id = $4
), updated as (
update platform_api_keys
set status = $1,
revoked_at = coalesce(revoked_at, now())
where workspace_id = $2
and agent_id = $3
and id = $4
and status <> 'deleted'
returning id
)
select exists(select 1 from target) as exists,
exists(select 1 from updated) as updated",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Deleted,
"status",
)?)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.fetch_one(&self.pool)
.await?;
if result.rows_affected() == 0 {
if !row.get::<bool, _>("exists") {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
if !row.get::<bool, _>("updated") {
return Err(RegistryError::PlatformApiKeyInactive {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
@@ -397,23 +487,31 @@ impl PostgresRegistry {
key_id: &PlatformApiKeyId,
used_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let exists = sqlx::query_scalar::<_, bool>(
"with target as (
select id
let row = sqlx::query(
"with target as materialized (
select id,
last_used_at,
status = 'active'
and (expires_at is null or expires_at > now()) as active
from platform_api_keys
where workspace_id = $1 and id = $2
for update
), updated as (
update platform_api_keys
update platform_api_keys as api_key
set last_used_at = $3::timestamptz
where workspace_id = $1
and id = $2
from target
where api_key.workspace_id = $1
and api_key.id = target.id
and target.active
and (
last_used_at is null
or last_used_at < $3::timestamptz - interval '1 minute'
target.last_used_at is null
or target.last_used_at < $3::timestamptz - interval '1 minute'
)
returning id
returning api_key.id
)
select exists(select 1 from target)",
select
exists(select 1 from target) as exists,
coalesce((select active from target), false) as active",
)
.bind(workspace_id.as_str())
.bind(key_id.as_str())
@@ -421,11 +519,16 @@ impl PostgresRegistry {
.fetch_one(&self.pool)
.await?;
if !exists {
if !row.get::<bool, _>("exists") {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
if !row.get::<bool, _>("active") {
return Err(RegistryError::PlatformApiKeyInactive {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
+100 -15
View File
@@ -11,13 +11,15 @@ impl PostgresRegistry {
sqlx::query(
"update approval_requests
set status = 'expired'
where agent_id = $1
and operation_id = $2
and operation_version = $3
and request_fingerprint = $4
where workspace_id = $1
and agent_id = $2
and operation_id = $3
and operation_version = $4
and request_fingerprint = $5
and status = 'pending'
and expires_at <= $5",
and expires_at <= $6",
)
.bind(request.approval.workspace_id.as_str())
.bind(request.approval.agent_id.as_str())
.bind(request.approval.operation_id.as_str())
.bind(to_db_version(request.approval.operation_version))
@@ -34,6 +36,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -44,15 +48,22 @@ impl PostgresRegistry {
request_fingerprint
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10::timestamptz, $11::timestamptz, $12::timestamptz,
$13, $14, $15
$9, $10, $11, $12::timestamptz, $13::timestamptz,
$14::timestamptz, $15, $16, $17
)
on conflict (
workspace_id,
agent_id,
operation_id,
operation_version,
request_fingerprint
)
on conflict (agent_id, operation_id, operation_version, request_fingerprint)
where status = 'pending' and request_fingerprint is not null
do update set request_fingerprint = excluded.request_fingerprint
returning
id, workspace_id, agent_id, operation_id, operation_version,
status, risk_level, request_payload_json, response_payload_json,
status, risk_level, request_id, trace_id, request_payload_json,
response_payload_json,
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
)
.bind(request.approval.id.as_str())
@@ -68,6 +79,8 @@ impl PostgresRegistry {
&request.approval.risk_level,
"approval_risk_level",
)?)
.bind(request.approval.request_id.as_deref())
.bind(request.approval.trace_id.as_deref())
.bind(Json(&request.approval.request_payload))
.bind(request.approval.response_payload.as_ref().map(Json))
.bind(request.approval.created_at)
@@ -103,6 +116,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -140,6 +155,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -179,6 +196,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -215,6 +234,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -239,6 +260,7 @@ impl PostgresRegistry {
&self,
request: DecideApprovalRequest<'_>,
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
let expected_fingerprint = approval_request_fingerprint(request.request_payload)?;
let row = sqlx::query(
"update approval_requests
set status = $1,
@@ -249,6 +271,9 @@ impl PostgresRegistry {
where workspace_id = $6
and agent_id = $7
and id = $8
and operation_id = $9
and operation_version = $10
and request_fingerprint = $11
and status = 'pending'
and expires_at > $3::timestamptz
returning
@@ -259,6 +284,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -270,11 +297,14 @@ impl PostgresRegistry {
.bind(serialize_enum_text(&request.status, "approval_status")?)
.bind(request.response_payload.as_ref().map(Json))
.bind(request.decided_at)
.bind(request.decided_by_key_id.as_str())
.bind(request.decided_by_key_id.map(PlatformApiKeyId::as_str))
.bind(request.decision_note)
.bind(request.workspace_id.as_str())
.bind(request.agent_id.as_str())
.bind(request.approval_id.as_str())
.bind(request.operation_id.as_str())
.bind(to_db_version(request.operation_version))
.bind(expected_fingerprint)
.fetch_optional(&self.pool)
.await?;
@@ -285,6 +315,10 @@ impl PostgresRegistry {
&self,
request: FinishApprovalRequest<'_>,
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
let response_payload = request
.response_payload
.as_ref()
.map(crank_core::sanitize_invocation_preview);
let row = sqlx::query(
"update approval_requests
set status = $1,
@@ -302,6 +336,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -311,7 +347,7 @@ impl PostgresRegistry {
decision_note",
)
.bind(serialize_enum_text(&request.status, "approval_status")?)
.bind(request.response_payload.as_ref().map(Json))
.bind(response_payload.as_ref().map(Json))
.bind(request.decision_note)
.bind(request.workspace_id.as_str())
.bind(request.agent_id.as_str())
@@ -340,7 +376,8 @@ impl PostgresRegistry {
and status = 'approved'
returning
id, workspace_id, agent_id, operation_id, operation_version,
status, risk_level, request_payload_json, response_payload_json,
status, risk_level, request_id, trace_id, request_payload_json,
response_payload_json,
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
)
.bind(started_at)
@@ -377,7 +414,8 @@ impl PostgresRegistry {
returning
approval.id, approval.workspace_id, approval.agent_id,
approval.operation_id, approval.operation_version, approval.status,
approval.risk_level, approval.request_payload_json,
approval.risk_level, approval.request_id, approval.trace_id,
approval.request_payload_json,
approval.response_payload_json, approval.created_at, approval.expires_at,
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
)
@@ -421,7 +459,8 @@ impl PostgresRegistry {
returning
approval.id, approval.workspace_id, approval.agent_id,
approval.operation_id, approval.operation_version, approval.status,
approval.risk_level, approval.request_payload_json,
approval.risk_level, approval.request_id, approval.trace_id,
approval.request_payload_json,
approval.response_payload_json, approval.created_at, approval.expires_at,
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
)
@@ -453,6 +492,8 @@ impl PostgresRegistry {
operation_version,
status,
risk_level,
request_id,
trace_id,
request_payload_json,
response_payload_json,
created_at,
@@ -473,11 +514,53 @@ impl PostgresRegistry {
}
fn approval_request_fingerprint(payload: &Value) -> Result<String, RegistryError> {
let canonical = canonical_json(payload);
let canonical = canonical_json(&approval_fingerprint_payload(payload));
let encoded = serde_json::to_vec(&canonical)?;
Ok(format!("{:x}", Sha256::digest(encoded)))
}
fn approval_fingerprint_payload(value: &Value) -> Value {
approval_fingerprint_payload_at(value, 0)
}
fn approval_fingerprint_payload_at(value: &Value, depth: usize) -> Value {
match value {
Value::Object(object) => {
let filtered = object
.iter()
.filter_map(|(key, value)| {
if depth == 0 && is_approval_control_field(key) {
None
} else {
Some((
key.clone(),
approval_fingerprint_payload_at(value, depth + 1),
))
}
})
.collect::<serde_json::Map<String, Value>>();
Value::Object(filtered)
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|item| approval_fingerprint_payload_at(item, depth + 1))
.collect(),
),
_ => value.clone(),
}
}
fn is_approval_control_field(key: &str) -> bool {
matches!(
key,
"_crank_confirmation_token"
| "_crank_approval_id"
| "_crank_approval_token"
| "_crank_runtime_confirmation_token"
)
}
fn canonical_json(value: &Value) -> Value {
match value {
Value::Object(object) => {
@@ -505,6 +588,8 @@ fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, Registr
&row.get::<String, _>("risk_level"),
"approval_risk_level",
)?,
request_id: row.get("request_id"),
trace_id: row.get("trace_id"),
request_payload: row.get::<Value, _>("request_payload_json"),
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
created_at: row.get("created_at"),
+452 -56
View File
@@ -1,6 +1,7 @@
use super::*;
use crate::model::RecoverAdminPasswordRequest;
use time::OffsetDateTime;
const MAX_ADMIN_BOOTSTRAP_ATTEMPTS: i32 = 5;
fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
let status = row.try_get::<String, _>("status")?;
Ok(AuthUserRecord {
@@ -16,8 +17,199 @@ fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
.unwrap_or_default(),
})
}
impl PostgresRegistry {
pub async fn create_admin_bootstrap_contract(
&self,
request: CreateAdminBootstrapContractRequest<'_>,
) -> Result<AdminBootstrapContractRecord, RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"update admin_bootstrap_contracts
set status = 'expired'
where status = 'active'
and expires_at <= now()",
)
.execute(&mut *tx)
.await?;
let row = sqlx::query(
"insert into admin_bootstrap_contracts (
id, token_hash, email, display_name, status, expires_at, created_at
) values (
$1, $2, $3, $4, 'active', $5::timestamptz, now()
)
returning id, email, display_name, status, expires_at, created_at, used_at",
)
.bind(request.id)
.bind(request.token_hash)
.bind(request.email)
.bind(request.display_name)
.bind(*request.expires_at)
.fetch_one(&mut *tx)
.await
.map_err(|error| match error {
sqlx::Error::Database(db_error)
if db_error.constraint() == Some("admin_bootstrap_contracts_single_active_idx") =>
{
RegistryError::AdminBootstrapUnavailable
}
other => RegistryError::Storage(other),
})?;
tx.commit().await?;
Ok(AdminBootstrapContractRecord {
id: row.try_get("id")?,
email: row.try_get("email")?,
display_name: row.try_get("display_name")?,
status: row.try_get("status")?,
expires_at: row.try_get("expires_at")?,
created_at: row.try_get("created_at")?,
used_at: row.try_get("used_at")?,
})
}
pub async fn consume_admin_bootstrap_contract(
&self,
request: ConsumeAdminBootstrapContractRequest<'_>,
) -> Result<UserId, RegistryError> {
let mut tx = self.pool.begin().await?;
let existing_admins = sqlx::query_scalar::<_, i64>(
"select count(*)::bigint
from users
where coalesce(password_hash, '') <> ''",
)
.fetch_one(&mut *tx)
.await?;
if existing_admins > 0 {
return Err(RegistryError::AdminBootstrapRejected);
}
let contract = sqlx::query(
"select id, email, display_name
from admin_bootstrap_contracts
where token_hash = $1
and status = 'active'
and expires_at > $2::timestamptz
and attempts < $3
for update",
)
.bind(request.token_hash)
.bind(*request.now)
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
.fetch_optional(&mut *tx)
.await?;
let Some(contract) = contract else {
let _ = sqlx::query(
"update admin_bootstrap_contracts
set attempts = least(attempts + 1, 100),
status = case
when attempts + 1 >= $2 then 'revoked'
else status
end
where token_hash = $1
and status = 'active'",
)
.bind(request.token_hash)
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Err(RegistryError::AdminBootstrapRejected);
};
let user_id = format!("user_{}", uuid::Uuid::now_v7().simple());
let email: String = contract.try_get("email")?;
let display_name: String = contract.try_get("display_name")?;
let user_row = sqlx::query(
"insert into users (
id, email, display_name, password_hash, status, created_at
) values (
$1, $2, $3, $4, 'active', now()
)
on conflict (email) do update
set display_name = excluded.display_name,
password_hash = excluded.password_hash,
status = 'active'
where users.password_hash is null
returning id",
)
.bind(&user_id)
.bind(&email)
.bind(&display_name)
.bind(request.password_hash)
.fetch_optional(&mut *tx)
.await
.map_err(RegistryError::Storage)?;
let Some(user_row) = user_row else {
return Err(RegistryError::AdminBootstrapRejected);
};
let user_id: String = user_row.try_get("id")?;
sqlx::query(
"insert into memberships (
workspace_id, user_id, role, created_at
) values (
'ws_default', $1, 'owner', now()
)
on conflict (workspace_id, user_id) do update
set role = 'owner'",
)
.bind(&user_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"update admin_bootstrap_contracts
set status = 'used',
used_at = $2::timestamptz,
used_by_user_id = $3
where id = $1
and status = 'active'",
)
.bind(contract.try_get::<String, _>("id")?)
.bind(*request.now)
.bind(&user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(UserId::new(user_id))
}
pub async fn admin_bootstrap_contract_is_consumable(
&self,
token_hash: &str,
now: &OffsetDateTime,
) -> Result<bool, RegistryError> {
let existing_admins = sqlx::query_scalar::<_, i64>(
"select count(*)::bigint
from users
where coalesce(password_hash, '') <> ''",
)
.fetch_one(&self.pool)
.await?;
if existing_admins > 0 {
return Ok(false);
}
let exists = sqlx::query_scalar::<_, bool>(
"select exists(
select 1
from admin_bootstrap_contracts
where token_hash = $1
and status = 'active'
and expires_at > $2::timestamptz
and attempts < $3
)",
)
.bind(token_hash)
.bind(*now)
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
.fetch_one(&self.pool)
.await?;
Ok(exists)
}
pub async fn has_password_admin(&self) -> Result<bool, RegistryError> {
let exists = sqlx::query_scalar::<_, bool>(
"select exists(
select 1 from users
where coalesce(password_hash, '') <> ''
)",
)
.fetch_one(&self.pool)
.await?;
Ok(exists)
}
pub async fn ensure_bootstrap_user(
&self,
email: &str,
@@ -52,7 +244,6 @@ impl PostgresRegistry {
{
return Ok(UserId::new(id));
}
let existing = sqlx::query_scalar::<_, String>(
"select id
from users
@@ -62,10 +253,8 @@ impl PostgresRegistry {
.bind(email)
.fetch_one(&self.pool)
.await?;
Ok(UserId::new(existing))
}
pub async fn upsert_bootstrap_user(
&self,
email: &str,
@@ -75,7 +264,6 @@ impl PostgresRegistry {
self.ensure_bootstrap_user(email, display_name, password_hash)
.await
}
pub async fn ensure_membership(
&self,
workspace_id: &WorkspaceId,
@@ -99,10 +287,8 @@ impl PostgresRegistry {
.bind(serialize_enum_text(&role, "role")?)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_auth_user_by_email(
&self,
email: &str,
@@ -122,10 +308,8 @@ impl PostgresRegistry {
.bind(email)
.fetch_optional(&self.pool)
.await?;
row.as_ref().map(map_auth_user_row).transpose()
}
pub async fn get_auth_user_by_id(
&self,
user_id: &UserId,
@@ -145,10 +329,8 @@ impl PostgresRegistry {
.bind(user_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.as_ref().map(map_auth_user_row).transpose()
}
pub async fn update_user_profile(
&self,
user_id: &UserId,
@@ -173,7 +355,6 @@ impl PostgresRegistry {
.fetch_one(&self.pool)
.await
.map_err(|error| map_user_update_error(error, user_id, email))?;
build_user(
row.id,
row.email,
@@ -182,7 +363,6 @@ impl PostgresRegistry {
row.created_at,
)
}
pub async fn update_user_password(
&self,
user_id: &UserId,
@@ -197,16 +377,13 @@ impl PostgresRegistry {
.bind(password_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::UserNotFound {
user_id: user_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn update_user_password_and_revoke_other_sessions(
&self,
user_id: &UserId,
@@ -223,13 +400,11 @@ impl PostgresRegistry {
.bind(password_hash)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::UserNotFound {
user_id: user_id.as_str().to_owned(),
});
}
sqlx::query(
"update user_sessions
set status = 'revoked'
@@ -241,17 +416,49 @@ impl PostgresRegistry {
.bind(current_session_id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn update_user_password_and_revoke_all_sessions(
&self,
user_id: &UserId,
password_hash: &str,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query(
"update users
set password_hash = $2
where id = $1",
)
.bind(user_id.as_str())
.bind(password_hash)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::UserNotFound {
user_id: user_id.as_str().to_owned(),
});
}
sqlx::query(
"update user_sessions
set status = 'revoked',
revoked_at = coalesce(revoked_at, now())
where user_id = $1
and status = 'active'",
)
.bind(user_id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn create_user_session(
&self,
session_id: &UserSessionId,
user_id: &UserId,
current_workspace_id: Option<&WorkspaceId>,
secret_hash: &str,
csrf_hash: Option<&str>,
expires_at: &OffsetDateTime,
) -> Result<(), RegistryError> {
sqlx::query(
@@ -260,6 +467,7 @@ impl PostgresRegistry {
user_id,
current_workspace_id,
secret_hash,
csrf_hash,
status,
expires_at,
last_seen_at,
@@ -269,8 +477,9 @@ impl PostgresRegistry {
$2,
$3,
$4,
$5,
'active',
$5::timestamptz,
$6::timestamptz,
now(),
now()
)",
@@ -279,54 +488,55 @@ impl PostgresRegistry {
.bind(user_id.as_str())
.bind(current_workspace_id.map(|id| id.as_str()))
.bind(secret_hash)
.bind(csrf_hash)
.bind(*expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_user_session(
&self,
session_id: &UserSessionId,
secret_hash: &str,
) -> Result<Option<SessionRecord>, RegistryError> {
let row = sqlx::query!(
let row = sqlx::query(
"select
s.id,
s.user_id,
s.current_workspace_id,
s.csrf_hash,
u.email,
u.display_name,
u.status,
u.created_at as \"created_at!: OffsetDateTime\"
u.created_at as created_at
from user_sessions s
join users u on u.id = s.user_id
where s.id = $1
and s.secret_hash = $2
and s.status = 'active'
and u.status = 'active'
and s.expires_at > now()
limit 1",
session_id.as_str(),
secret_hash,
)
.bind(session_id.as_str())
.bind(secret_hash)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(None);
};
let user_id = UserId::new(row.user_id);
let user_id = UserId::new(row.try_get::<String, _>("user_id")?);
let user = User {
id: user_id.clone(),
email: row.email,
display_name: row.display_name,
status: deserialize_enum_text(&row.status, "status")?,
created_at: row.created_at,
email: row.try_get("email")?,
display_name: row.try_get("display_name")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
created_at: row.try_get("created_at")?,
};
let memberships = self.list_workspaces_for_user(&user_id).await?;
let stored_workspace_id = row.current_workspace_id.map(WorkspaceId::new);
let stored_workspace_id = row
.try_get::<Option<String>, _>("current_workspace_id")?
.map(WorkspaceId::new);
let default_workspace_id = memberships
.iter()
.find(|membership| membership.workspace.id.as_str() == "ws_default")
@@ -344,15 +554,56 @@ impl PostgresRegistry {
.map(|membership| membership.workspace.id.clone())
})
});
Ok(Some(SessionRecord {
session_id: UserSessionId::new(row.id),
session_id: UserSessionId::new(row.try_get::<String, _>("id")?),
user,
memberships,
current_workspace_id,
csrf_hash: row.try_get("csrf_hash")?,
}))
}
pub async fn verify_session_csrf(
&self,
session_id: &UserSessionId,
csrf_hash: &str,
) -> Result<bool, RegistryError> {
let allowed = sqlx::query_scalar::<_, bool>(
"select exists(
select 1
from user_sessions
where id = $1
and csrf_hash = $2
and status = 'active'
and expires_at > now()
)",
)
.bind(session_id.as_str())
.bind(csrf_hash)
.fetch_one(&self.pool)
.await?;
Ok(allowed)
}
pub async fn update_user_session_csrf(
&self,
session_id: &UserSessionId,
csrf_hash: &str,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update user_sessions
set csrf_hash = $2
where id = $1
and status = 'active'
and expires_at > now()",
)
.bind(session_id.as_str())
.bind(csrf_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AdminCsrfRejected);
}
Ok(())
}
pub async fn touch_user_session(
&self,
session_id: &UserSessionId,
@@ -369,26 +620,154 @@ impl PostgresRegistry {
.bind(session_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn revoke_user_session(
&self,
session_id: &UserSessionId,
) -> Result<(), RegistryError> {
sqlx::query(
"update user_sessions
set status = 'revoked'
set status = 'revoked',
revoked_at = coalesce(revoked_at, now())
where id = $1",
)
.bind(session_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn revoke_all_user_sessions(&self, user_id: &UserId) -> Result<(), RegistryError> {
sqlx::query(
"update user_sessions
set status = 'revoked',
revoked_at = coalesce(revoked_at, now())
where user_id = $1
and status = 'active'",
)
.bind(user_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn recover_admin_password(
&self,
request: RecoverAdminPasswordRequest<'_>,
) -> Result<UserId, RegistryError> {
let mut tx = self.pool.begin().await?;
let row = sqlx::query(
"update users
set password_hash = $2,
status = 'active'
where email = $1
and coalesce(password_hash, '') <> ''
returning id",
)
.bind(request.email)
.bind(request.password_hash)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(RegistryError::AdminRecoveryRejected);
};
let user_id = UserId::new(row.try_get::<String, _>("id")?);
sqlx::query(
"update user_sessions
set status = 'revoked',
revoked_at = coalesce(revoked_at, now())
where user_id = $1
and status = 'active'",
)
.bind(user_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"insert into admin_security_audit_events (
id, action, outcome, actor_user_id, session_id, request_id, trace_id, source, created_at
) values (
$1, 'recovery_completed', 'success', $2, null, null, null, 'local_cli', now()
)",
)
.bind(request.audit_id)
.bind(user_id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(user_id)
}
pub async fn login_backoff_locked_until(
&self,
scope_hash: &str,
) -> Result<Option<OffsetDateTime>, RegistryError> {
let value = sqlx::query_scalar::<_, Option<OffsetDateTime>>(
"select locked_until
from admin_login_backoff
where scope_hash = $1
and locked_until > now()",
)
.bind(scope_hash)
.fetch_optional(&self.pool)
.await?
.flatten();
Ok(value)
}
pub async fn record_login_failure(
&self,
scope_hash: &str,
now: &OffsetDateTime,
) -> Result<OffsetDateTime, RegistryError> {
let row = sqlx::query(
"insert into admin_login_backoff (
scope_hash, failure_count, locked_until, last_attempt_at, updated_at
) values (
$1, 1, $2::timestamptz + interval '1 second', $2::timestamptz, $2::timestamptz
)
on conflict (scope_hash) do update
set failure_count = least(admin_login_backoff.failure_count + 1, 1000),
locked_until = $2::timestamptz + (
least(300, power(2, least(admin_login_backoff.failure_count + 1, 8)))::int
* interval '1 second'
),
last_attempt_at = $2::timestamptz,
updated_at = $2::timestamptz
returning locked_until",
)
.bind(scope_hash)
.bind(*now)
.fetch_one(&self.pool)
.await?;
row.try_get("locked_until").map_err(RegistryError::Storage)
}
pub async fn reset_login_backoff(&self, scope_hash: &str) -> Result<(), RegistryError> {
sqlx::query("delete from admin_login_backoff where scope_hash = $1")
.bind(scope_hash)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn record_admin_security_audit(
&self,
request: AdminSecurityAuditRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into admin_security_audit_events (
id, action, outcome, actor_user_id, session_id, request_id, trace_id, source, created_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, now()
)",
)
.bind(request.id)
.bind(request.action)
.bind(request.outcome)
.bind(request.actor_user_id.map(|id| id.as_str()))
.bind(request.session_id.map(|id| id.as_str()))
.bind(request.request_id)
.bind(request.trace_id)
.bind(request.source)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn set_user_session_current_workspace(
&self,
session_id: &UserSessionId,
@@ -403,10 +782,8 @@ impl PostgresRegistry {
.bind(workspace_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn user_has_workspace_access(
&self,
user_id: &UserId,
@@ -424,14 +801,39 @@ impl PostgresRegistry {
)
.fetch_one(&self.pool)
.await?;
Ok(row.allowed)
}
pub async fn save_auth_profile(
&self,
request: SaveAuthProfileRequest<'_>,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
let mut secret_ids = request.profile.config.secret_ids();
secret_ids.sort_by(|left, right| left.as_str().cmp(right.as_str()));
secret_ids.dedup_by(|left, right| left.as_str() == right.as_str());
for secret_id in &secret_ids {
super::secret::lock_secret_reference(&mut tx, request.workspace_id, secret_id).await?;
let row = sqlx::query(
"select status
from secrets
where workspace_id = $1 and id = $2
for update",
)
.bind(request.workspace_id.as_str())
.bind(secret_id.as_str())
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(RegistryError::SecretNotFound {
secret_id: secret_id.as_str().to_owned(),
});
};
if row.get::<String, _>("status") != "active" {
return Err(RegistryError::SecretInactive {
secret_id: secret_id.as_str().to_owned(),
});
}
}
sqlx::query(
"insert into auth_profiles (
id,
@@ -456,12 +858,11 @@ impl PostgresRegistry {
.bind(Json(serialize_json_value(&request.profile.config)?))
.bind(request.profile.created_at)
.bind(request.profile.updated_at)
.execute(&self.pool)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn get_auth_profile(
&self,
workspace_id: &WorkspaceId,
@@ -483,7 +884,6 @@ impl PostgresRegistry {
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
build_auth_profile(
row.id,
@@ -497,7 +897,6 @@ impl PostgresRegistry {
})
.transpose()
}
pub async fn list_auth_profiles(
&self,
workspace_id: &WorkspaceId,
@@ -518,7 +917,6 @@ impl PostgresRegistry {
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
build_auth_profile(
@@ -533,14 +931,12 @@ impl PostgresRegistry {
})
.collect()
}
pub async fn list_auth_profiles_referencing_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Vec<AuthProfile>, RegistryError> {
let profiles = self.list_auth_profiles(workspace_id).await?;
Ok(profiles
.into_iter()
.filter(|profile| {
@@ -0,0 +1,934 @@
use super::*;
const MASTER_KEY_PAGE_LIMIT_MAX: i64 = 10_000;
impl PostgresRegistry {
pub async fn verify_or_register_master_key_identity(
&self,
candidate: MasterKeyIdentityCandidate<'_>,
) -> Result<MasterKeyIdentityRecord, RegistryError> {
validate_master_key_candidate(&candidate)?;
let mut tx = self.pool.begin().await?;
lock_master_key_authority(&mut tx).await?;
let row = sqlx::query(
"select
epoch,
fingerprint,
cipher_contract,
status,
backup_ref,
created_at,
activated_at,
retired_at
from master_key_identities
where status = 'active'
order by epoch desc
limit 1
for update",
)
.fetch_optional(&mut *tx)
.await?;
let record = if let Some(row) = row {
let record = map_master_key_identity(row);
if record.epoch != candidate.epoch
|| record.fingerprint != candidate.fingerprint
|| record.cipher_contract != candidate.cipher_contract
{
return Err(RegistryError::MasterKeyIdentityMismatch {
epoch: record.epoch,
});
}
record
} else {
sqlx::query(
"insert into master_key_identities (
epoch,
fingerprint,
cipher_contract,
status,
backup_ref,
created_at,
activated_at,
retired_at
) values (
$1, $2, $3, 'active', null, $4::timestamptz, $4::timestamptz, null
)",
)
.bind(candidate.epoch)
.bind(candidate.fingerprint)
.bind(candidate.cipher_contract)
.bind(candidate.observed_at)
.execute(&mut *tx)
.await?;
MasterKeyIdentityRecord {
epoch: candidate.epoch,
fingerprint: candidate.fingerprint.to_owned(),
cipher_contract: candidate.cipher_contract.to_owned(),
status: "active".to_owned(),
backup_ref: None,
created_at: *candidate.observed_at,
activated_at: Some(*candidate.observed_at),
retired_at: None,
}
};
tx.commit().await?;
Ok(record)
}
pub async fn master_key_rotation_status(
&self,
) -> Result<MasterKeyRotationStatus, RegistryError> {
let active_identity = self.active_master_key_identity().await?;
let rows = sqlx::query(
"select
id,
source_epoch,
target_epoch,
target_fingerprint,
state,
backup_ref,
checkpoint_secret_id,
total_secret_versions,
processed_secret_versions,
verified_secret_versions,
failure_code,
created_at,
updated_at
from master_key_rotations
order by created_at desc, id asc",
)
.fetch_all(&self.pool)
.await?;
let rotations = rows.into_iter().map(map_master_key_rotation).collect();
Ok(MasterKeyRotationStatus {
active_identity,
rotations,
})
}
pub async fn active_master_key_identity(
&self,
) -> Result<Option<MasterKeyIdentityRecord>, RegistryError> {
let row = sqlx::query(
"select
epoch,
fingerprint,
cipher_contract,
status,
backup_ref,
created_at,
activated_at,
retired_at
from master_key_identities
where status = 'active'
order by epoch desc
limit 1",
)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(map_master_key_identity))
}
pub async fn master_key_fingerprint_exists(
&self,
fingerprint: &str,
) -> Result<bool, RegistryError> {
let valid_fingerprint = fingerprint.len() == 64
&& fingerprint
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
if !valid_fingerprint {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
let count = sqlx::query_scalar::<_, i64>(
"select count(*) from master_key_identities where fingerprint = $1",
)
.bind(fingerprint)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
pub async fn list_secret_versions_for_master_key_epoch(
&self,
epoch: i64,
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
if epoch < 1 {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
let rows = sqlx::query(
"select
secret_id,
version,
ciphertext,
key_version,
master_key_epoch,
target_ciphertext,
target_key_version,
target_master_key_epoch,
created_at,
created_by
from secret_versions
where master_key_epoch = $1
order by secret_id asc, version asc",
)
.bind(epoch)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_secret_version_record).collect()
}
pub async fn list_secret_versions_for_master_key_epoch_page(
&self,
epoch: i64,
after_secret_id: Option<&str>,
after_version: Option<u32>,
limit: i64,
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
if epoch < 1 || !(1..=MASTER_KEY_PAGE_LIMIT_MAX).contains(&limit) {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
let after_db_version = after_version.map(to_db_version);
let rows = sqlx::query(
"select
secret_id,
version,
ciphertext,
key_version,
master_key_epoch,
target_ciphertext,
target_key_version,
target_master_key_epoch,
created_at,
created_by
from secret_versions
where master_key_epoch = $1
and (
$2::text is null
or (secret_id, version) > ($2::text, $3::integer)
)
order by secret_id asc, version asc
limit $4",
)
.bind(epoch)
.bind(after_secret_id)
.bind(after_db_version)
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_secret_version_record).collect()
}
pub async fn list_target_secret_versions_for_master_key_rotation(
&self,
target_epoch: i64,
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
if target_epoch < 1 {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
let rows = sqlx::query(
"select
secret_id,
version,
ciphertext,
key_version,
master_key_epoch,
target_ciphertext,
target_key_version,
target_master_key_epoch,
created_at,
created_by
from secret_versions
where target_master_key_epoch = $1
order by secret_id asc, version asc",
)
.bind(target_epoch)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_secret_version_record).collect()
}
pub async fn list_target_secret_versions_for_master_key_rotation_page(
&self,
target_epoch: i64,
after_secret_id: Option<&str>,
after_version: Option<u32>,
limit: i64,
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
if target_epoch < 1 || !(1..=MASTER_KEY_PAGE_LIMIT_MAX).contains(&limit) {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
let after_db_version = after_version.map(to_db_version);
let rows = sqlx::query(
"select
secret_id,
version,
ciphertext,
key_version,
master_key_epoch,
target_ciphertext,
target_key_version,
target_master_key_epoch,
created_at,
created_by
from secret_versions
where target_master_key_epoch = $1
and (
$2::text is null
or (secret_id, version) > ($2::text, $3::integer)
)
order by secret_id asc, version asc
limit $4",
)
.bind(target_epoch)
.bind(after_secret_id)
.bind(after_db_version)
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_secret_version_record).collect()
}
pub async fn begin_master_key_rotation(
&self,
source_epoch: i64,
target_epoch: i64,
target_fingerprint: &str,
backup_ref: Option<&str>,
now: &OffsetDateTime,
) -> Result<MasterKeyRotationRecord, RegistryError> {
validate_rotation_request(source_epoch, target_epoch, target_fingerprint, backup_ref)?;
let mut tx = self.pool.begin().await?;
lock_master_key_authority(&mut tx).await?;
let active = select_active_master_key_identity_for_update(&mut tx).await?;
let Some(active) = active else {
return Err(RegistryError::MasterKeyRotationConflict);
};
if active.epoch != source_epoch || active.fingerprint == target_fingerprint {
return Err(RegistryError::MasterKeyRotationConflict);
}
let existing_active = sqlx::query(
"select
id,
source_epoch,
target_epoch,
target_fingerprint,
state,
backup_ref,
checkpoint_secret_id,
total_secret_versions,
processed_secret_versions,
verified_secret_versions,
failure_code,
created_at,
updated_at
from master_key_rotations
where state in ('running', 'verifying', 'verified')
order by created_at asc
limit 1
for update",
)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing_active {
let record = map_master_key_rotation(row);
if record.source_epoch == source_epoch
&& record.target_epoch == target_epoch
&& record.target_fingerprint == target_fingerprint
{
tx.commit().await?;
return Ok(record);
}
return Err(RegistryError::MasterKeyRotationInProgress);
}
if sqlx::query_scalar::<_, i64>(
"select count(*) from master_key_identities where fingerprint = $1",
)
.bind(target_fingerprint)
.fetch_one(&mut *tx)
.await?
> 0
{
return Err(RegistryError::MasterKeyRotationConflict);
}
let total = sqlx::query_scalar::<_, i64>(
"select count(*) from secret_versions where master_key_epoch = $1",
)
.bind(source_epoch)
.fetch_one(&mut *tx)
.await?;
let rotation_id = format!("master-key-e{source_epoch}-to-e{target_epoch}");
let aborted = sqlx::query(
"select
id,
source_epoch,
target_epoch,
target_fingerprint,
state,
backup_ref,
checkpoint_secret_id,
total_secret_versions,
processed_secret_versions,
verified_secret_versions,
failure_code,
created_at,
updated_at
from master_key_rotations
where id = $1 and state in ('aborted', 'failed')
for update",
)
.bind(&rotation_id)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = aborted {
let record = map_master_key_rotation(row);
if record.source_epoch != source_epoch
|| record.target_epoch != target_epoch
|| record.target_fingerprint != target_fingerprint
{
return Err(RegistryError::MasterKeyRotationConflict);
}
sqlx::query(
"update secret_versions
set target_ciphertext = null,
target_key_version = null,
target_master_key_epoch = null
where target_master_key_epoch = $1",
)
.bind(target_epoch)
.execute(&mut *tx)
.await?;
sqlx::query(
"update master_key_rotations
set state = 'running',
backup_ref = $2,
checkpoint_secret_id = null,
total_secret_versions = $3,
processed_secret_versions = 0,
verified_secret_versions = 0,
failure_code = null,
updated_at = $4::timestamptz
where id = $1",
)
.bind(&rotation_id)
.bind(backup_ref)
.bind(total)
.bind(now)
.execute(&mut *tx)
.await?;
let record = select_master_key_rotation_for_update(&mut tx, &rotation_id).await?;
tx.commit().await?;
return Ok(record);
}
sqlx::query(
"insert into master_key_rotations (
id,
source_epoch,
target_epoch,
target_fingerprint,
state,
backup_ref,
checkpoint_secret_id,
total_secret_versions,
processed_secret_versions,
verified_secret_versions,
failure_code,
created_at,
updated_at
) values (
$1, $2, $3, $4, 'running', $5, null, $6, 0, 0, null, $7::timestamptz, $7::timestamptz
)",
)
.bind(&rotation_id)
.bind(source_epoch)
.bind(target_epoch)
.bind(target_fingerprint)
.bind(backup_ref)
.bind(total)
.bind(now)
.execute(&mut *tx)
.await?;
let record = select_master_key_rotation_for_update(&mut tx, &rotation_id).await?;
tx.commit().await?;
Ok(record)
}
#[allow(clippy::too_many_arguments)]
pub async fn stage_master_key_rotation_ciphertext(
&self,
rotation_id: &str,
secret_id: &SecretId,
version: u32,
source_epoch: i64,
target_ciphertext: &str,
target_key_version: &str,
target_epoch: i64,
now: &OffsetDateTime,
) -> Result<MasterKeyRotationRecord, RegistryError> {
let mut tx = self.pool.begin().await?;
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
if rotation.state != "running"
|| rotation.source_epoch != source_epoch
|| rotation.target_epoch != target_epoch
{
return Err(RegistryError::MasterKeyRotationConflict);
}
let result = sqlx::query(
"update secret_versions
set target_ciphertext = $4,
target_key_version = $5,
target_master_key_epoch = $6
where secret_id = $1
and version = $2
and master_key_epoch = $3
and (
target_master_key_epoch is null
or target_master_key_epoch = $6
)
and (
target_ciphertext is null
or target_ciphertext = $4
)",
)
.bind(secret_id.as_str())
.bind(to_db_version(version))
.bind(source_epoch)
.bind(target_ciphertext)
.bind(target_key_version)
.bind(target_epoch)
.execute(&mut *tx)
.await?;
if result.rows_affected() != 1 {
return Err(RegistryError::MasterKeyRotationConflict);
}
let processed = count_staged_rotation_targets(&mut tx, source_epoch, target_epoch).await?;
sqlx::query(
"update master_key_rotations
set checkpoint_secret_id = $2,
processed_secret_versions = $3,
updated_at = $4::timestamptz
where id = $1",
)
.bind(rotation_id)
.bind(secret_id.as_str())
.bind(processed)
.bind(now)
.execute(&mut *tx)
.await?;
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
tx.commit().await?;
Ok(updated)
}
pub async fn finish_master_key_rotation_batches(
&self,
rotation_id: &str,
now: &OffsetDateTime,
) -> Result<MasterKeyRotationRecord, RegistryError> {
let mut tx = self.pool.begin().await?;
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
if rotation.state != "running"
|| rotation.processed_secret_versions != rotation.total_secret_versions
{
return Err(RegistryError::MasterKeyRotationConflict);
}
sqlx::query(
"update master_key_rotations
set state = 'verifying',
updated_at = $2::timestamptz
where id = $1",
)
.bind(rotation_id)
.bind(now)
.execute(&mut *tx)
.await?;
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
tx.commit().await?;
Ok(updated)
}
pub async fn verify_master_key_rotation(
&self,
rotation_id: &str,
verified_secret_versions: i64,
now: &OffsetDateTime,
) -> Result<MasterKeyRotationRecord, RegistryError> {
let mut tx = self.pool.begin().await?;
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
if rotation.state != "verifying"
|| verified_secret_versions != rotation.total_secret_versions
|| rotation.processed_secret_versions != rotation.total_secret_versions
{
return Err(RegistryError::MasterKeyRotationVerificationFailed);
}
sqlx::query(
"update master_key_rotations
set state = 'verified',
verified_secret_versions = $2,
updated_at = $3::timestamptz
where id = $1",
)
.bind(rotation_id)
.bind(verified_secret_versions)
.bind(now)
.execute(&mut *tx)
.await?;
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
tx.commit().await?;
Ok(updated)
}
pub async fn promote_master_key_rotation(
&self,
rotation_id: &str,
target_identity: MasterKeyIdentityCandidate<'_>,
now: &OffsetDateTime,
) -> Result<MasterKeyRotationRecord, RegistryError> {
validate_master_key_candidate(&target_identity)?;
let mut tx = self.pool.begin().await?;
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
if rotation.state != "verified"
|| rotation.target_epoch != target_identity.epoch
|| rotation.target_fingerprint != target_identity.fingerprint
|| rotation.verified_secret_versions != rotation.total_secret_versions
{
return Err(RegistryError::MasterKeyRotationConflict);
}
let processed =
count_staged_rotation_targets(&mut tx, rotation.source_epoch, rotation.target_epoch)
.await?;
if processed != rotation.total_secret_versions {
return Err(RegistryError::MasterKeyRotationVerificationFailed);
}
sqlx::query(
"update master_key_identities
set status = 'retired',
retired_at = $2::timestamptz
where status = 'active' and epoch = $1",
)
.bind(rotation.source_epoch)
.bind(now)
.execute(&mut *tx)
.await
.and_then(|result| {
if result.rows_affected() == 1 {
Ok(result)
} else {
Err(sqlx::Error::RowNotFound)
}
})
.map_err(|error| match error {
sqlx::Error::RowNotFound => RegistryError::MasterKeyRotationConflict,
other => RegistryError::Storage(other),
})?;
sqlx::query(
"insert into master_key_identities (
epoch,
fingerprint,
cipher_contract,
status,
backup_ref,
created_at,
activated_at,
retired_at
) values (
$1, $2, $3, 'active', $4, $5::timestamptz, $5::timestamptz, null
)",
)
.bind(target_identity.epoch)
.bind(target_identity.fingerprint)
.bind(target_identity.cipher_contract)
.bind(rotation.backup_ref.as_deref())
.bind(now)
.execute(&mut *tx)
.await?;
sqlx::query(
"update secret_versions
set ciphertext = target_ciphertext,
key_version = target_key_version,
master_key_epoch = target_master_key_epoch,
target_ciphertext = null,
target_key_version = null,
target_master_key_epoch = null
where master_key_epoch = $1
and target_master_key_epoch = $2",
)
.bind(rotation.source_epoch)
.bind(rotation.target_epoch)
.execute(&mut *tx)
.await?;
sqlx::query(
"update master_key_rotations
set state = 'promoted',
updated_at = $2::timestamptz
where id = $1",
)
.bind(rotation_id)
.bind(now)
.execute(&mut *tx)
.await?;
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
tx.commit().await?;
Ok(updated)
}
pub async fn abort_master_key_rotation(
&self,
rotation_id: &str,
now: &OffsetDateTime,
) -> Result<MasterKeyRotationRecord, RegistryError> {
let mut tx = self.pool.begin().await?;
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
if rotation.state == "promoted" {
return Err(RegistryError::MasterKeyRotationConflict);
}
sqlx::query(
"update secret_versions
set target_ciphertext = null,
target_key_version = null,
target_master_key_epoch = null
where target_master_key_epoch = $1",
)
.bind(rotation.target_epoch)
.execute(&mut *tx)
.await?;
sqlx::query(
"update master_key_rotations
set state = 'aborted',
updated_at = $2::timestamptz
where id = $1",
)
.bind(rotation_id)
.bind(now)
.execute(&mut *tx)
.await?;
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
tx.commit().await?;
Ok(updated)
}
}
fn validate_master_key_candidate(
candidate: &MasterKeyIdentityCandidate<'_>,
) -> Result<(), RegistryError> {
let valid_fingerprint = candidate.fingerprint.len() == 64
&& candidate
.fingerprint
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
if candidate.epoch < 1
|| !valid_fingerprint
|| candidate.cipher_contract != crate::model::MASTER_KEY_CIPHER_CONTRACT
{
return Err(RegistryError::InvalidMasterKeyIdentity);
}
Ok(())
}
fn map_master_key_identity(row: sqlx::postgres::PgRow) -> MasterKeyIdentityRecord {
MasterKeyIdentityRecord {
epoch: row.get::<i64, _>("epoch"),
fingerprint: row.get::<String, _>("fingerprint"),
cipher_contract: row.get::<String, _>("cipher_contract"),
status: row.get::<String, _>("status"),
backup_ref: row.get::<Option<String>, _>("backup_ref"),
created_at: row.get("created_at"),
activated_at: row.get("activated_at"),
retired_at: row.get("retired_at"),
}
}
fn map_master_key_rotation(row: sqlx::postgres::PgRow) -> MasterKeyRotationRecord {
MasterKeyRotationRecord {
id: row.get::<String, _>("id"),
source_epoch: row.get::<i64, _>("source_epoch"),
target_epoch: row.get::<i64, _>("target_epoch"),
target_fingerprint: row.get::<String, _>("target_fingerprint"),
state: row.get::<String, _>("state"),
backup_ref: row.get::<Option<String>, _>("backup_ref"),
checkpoint_secret_id: row.get::<Option<String>, _>("checkpoint_secret_id"),
total_secret_versions: row.get::<i64, _>("total_secret_versions"),
processed_secret_versions: row.get::<i64, _>("processed_secret_versions"),
verified_secret_versions: row.get::<i64, _>("verified_secret_versions"),
failure_code: row.get::<Option<String>, _>("failure_code"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}
fn map_secret_version_record(
row: sqlx::postgres::PgRow,
) -> Result<SecretVersionRecord, RegistryError> {
Ok(SecretVersionRecord {
secret_version: SecretVersion {
secret_id: SecretId::new(row.get::<String, _>("secret_id")),
version: from_db_version(row.get::<i32, _>("version"), "version")?,
ciphertext: row.get::<String, _>("ciphertext"),
key_version: row.get::<String, _>("key_version"),
created_at: row.get("created_at"),
created_by: row.get::<Option<String>, _>("created_by").map(UserId::new),
},
master_key_epoch: row.get::<i64, _>("master_key_epoch"),
target_ciphertext: row.get::<Option<String>, _>("target_ciphertext"),
target_key_version: row.get::<Option<String>, _>("target_key_version"),
target_master_key_epoch: row.get::<Option<i64>, _>("target_master_key_epoch"),
})
}
fn validate_rotation_request(
source_epoch: i64,
target_epoch: i64,
target_fingerprint: &str,
backup_ref: Option<&str>,
) -> Result<(), RegistryError> {
let candidate_observed_at = OffsetDateTime::UNIX_EPOCH;
validate_master_key_candidate(&MasterKeyIdentityCandidate {
epoch: target_epoch,
fingerprint: target_fingerprint,
cipher_contract: crate::model::MASTER_KEY_CIPHER_CONTRACT,
observed_at: &candidate_observed_at,
})?;
if source_epoch < 1 || target_epoch <= source_epoch {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
if let Some(value) = backup_ref {
let invalid = value.is_empty()
|| value.len() > 256
|| value.bytes().any(|byte| byte.is_ascii_control())
|| value.contains("://");
if invalid {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
}
Ok(())
}
pub(super) async fn ensure_active_master_key_epoch(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
master_key_epoch: i64,
) -> Result<(), RegistryError> {
if master_key_epoch < 1 {
return Err(RegistryError::InvalidMasterKeyIdentity);
}
let active = select_active_master_key_identity_for_update(transaction).await?;
match active {
Some(identity) if identity.epoch == master_key_epoch => Ok(()),
Some(identity) => Err(RegistryError::MasterKeyIdentityMismatch {
epoch: identity.epoch,
}),
None => Err(RegistryError::InvalidMasterKeyIdentity),
}
}
pub(super) async fn ensure_no_active_master_key_rotation(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), RegistryError> {
lock_master_key_authority(transaction).await?;
let row = sqlx::query(
"select id
from master_key_rotations
where state in ('running', 'verifying', 'verified')
order by created_at asc
limit 1
for update",
)
.fetch_optional(&mut **transaction)
.await?;
if row.is_some() {
return Err(RegistryError::MasterKeyRotationInProgress);
}
Ok(())
}
async fn lock_master_key_authority(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), RegistryError> {
sqlx::query(
"lock table
master_key_identities,
master_key_rotations
in share row exclusive mode",
)
.execute(&mut **transaction)
.await?;
Ok(())
}
async fn select_active_master_key_identity_for_update(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<MasterKeyIdentityRecord>, RegistryError> {
let row = sqlx::query(
"select
epoch,
fingerprint,
cipher_contract,
status,
backup_ref,
created_at,
activated_at,
retired_at
from master_key_identities
where status = 'active'
order by epoch desc
limit 1
for update",
)
.fetch_optional(&mut **transaction)
.await?;
Ok(row.map(map_master_key_identity))
}
async fn select_master_key_rotation_for_update(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
rotation_id: &str,
) -> Result<MasterKeyRotationRecord, RegistryError> {
let row = sqlx::query(
"select
id,
source_epoch,
target_epoch,
target_fingerprint,
state,
backup_ref,
checkpoint_secret_id,
total_secret_versions,
processed_secret_versions,
verified_secret_versions,
failure_code,
created_at,
updated_at
from master_key_rotations
where id = $1
for update",
)
.bind(rotation_id)
.fetch_optional(&mut **transaction)
.await?;
row.map(map_master_key_rotation)
.ok_or_else(|| RegistryError::MasterKeyRotationNotFound {
rotation_id: rotation_id.to_owned(),
})
}
async fn count_staged_rotation_targets(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
source_epoch: i64,
target_epoch: i64,
) -> Result<i64, RegistryError> {
let count = sqlx::query_scalar::<_, i64>(
"select count(*)
from secret_versions
where master_key_epoch = $1
and target_master_key_epoch = $2",
)
.bind(source_epoch)
.bind(target_epoch)
.fetch_one(&mut **transaction)
.await?;
Ok(count)
}
+118 -42
View File
@@ -1,13 +1,18 @@
mod agent;
mod agent_catalog;
mod api_key;
mod approval;
mod auth;
mod connection;
mod import_job;
mod master_key;
mod observability;
mod onboarding;
mod operation;
mod operation_artifact;
mod operation_published;
mod pool_config;
mod product_event;
mod secret;
mod upstream;
mod workspace;
@@ -17,8 +22,8 @@ use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId,
AuthProfile, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId,
MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
PlatformApiKeyStatus, Secret, SecretId, SecretVersion, Target, UsageRollup, User, UserId,
UserSessionId, Workspace, WorkspaceId,
PlatformApiKeyStatus, ProductEventId, Secret, SecretId, SecretVersion, Target, UsageRollup,
User, UserId, UserSessionId, Workspace, WorkspaceId,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
@@ -30,25 +35,34 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
use crate::{
error::RegistryError,
model::{
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DecideApprovalRequest, DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest,
FinishImportJobRequest, ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId,
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentSummary, AgentVersionRecord,
AppendProductEventOutcome, AppendProductEventRequest, AppliedImportOperation,
ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobStatus,
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
ListInvocationLogsQuery, ListProductEventsQuery, MasterKeyIdentityCandidate,
MasterKeyIdentityRecord, MasterKeyRotationRecord, MasterKeyRotationStatus,
MembershipRecord, OnboardingMilestoneResult, OnboardingPresentationMilestone,
OperationAgentRef, OperationSampleMetadata, OperationStateExpectation, OperationSummary,
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, ProductEventRecord,
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest,
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest, RegistryOperation,
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
UsageOperationBreakdown, UsageOutcomeBreakdown, UsageQuery, UsageRollupRecord,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
YamlImportJobStatus,
},
};
@@ -106,12 +120,23 @@ async fn insert_version_row(
generated_draft_json,
config_export_json,
wizard_state_json,
name,
display_name,
category,
protocol,
security_level,
snapshot_provenance,
snapshot_observed_at,
published_at,
published_by,
change_note,
created_at,
created_by
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17
$9, $10, $11, $12, $13, $14,
$15, $16, $17, $18, $19, 'native_v4', $20::timestamptz,
$21::timestamptz, $22, $23, $24::timestamptz, $25
)",
)
.bind(snapshot.id.as_str())
@@ -128,6 +153,17 @@ async fn insert_version_row(
.bind(serialize_option_json_value(&snapshot.generated_draft)?.map(Json))
.bind(serialize_option_json_value(&snapshot.config_export)?.map(Json))
.bind(serialize_option_json_value(&snapshot.wizard_state)?.map(Json))
.bind(&snapshot.name)
.bind(&snapshot.display_name)
.bind(&snapshot.category)
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
.bind(serialize_enum_text(
&snapshot.security_level,
"security_level",
)?)
.bind(snapshot.updated_at)
.bind(snapshot.published_at)
.bind(Option::<&str>::None)
.bind(change_note)
.bind(snapshot.updated_at)
.bind(created_by)
@@ -222,10 +258,39 @@ async fn insert_agent_version_row(
async fn replace_agent_bindings_rows(
tx: &mut Transaction<'_, Postgres>,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
version: u32,
bindings: &[AgentOperationBinding],
) -> Result<(), RegistryError> {
let operation_ids = bindings
.iter()
.map(|binding| binding.operation_id.as_str())
.collect::<std::collections::BTreeSet<_>>();
for operation_id in operation_ids {
let status = sqlx::query_scalar::<_, String>(
"select status from operations
where workspace_id = $1 and id = $2
for update",
)
.bind(workspace_id.as_str())
.bind(operation_id)
.fetch_optional(&mut **tx)
.await?;
match status.as_deref() {
Some("archived") => {
return Err(RegistryError::OperationArchived {
operation_id: operation_id.to_owned(),
});
}
Some(_) => {}
None => {
return Err(RegistryError::OperationNotFound {
operation_id: operation_id.to_owned(),
});
}
}
}
sqlx::query(
"delete from agent_operation_bindings
where agent_id = $1 and agent_version = $2",
@@ -263,27 +328,6 @@ async fn replace_agent_bindings_rows(
Ok(())
}
fn assert_immutable_fields(
summary: &OperationSummary,
snapshot: &RegistryOperation,
) -> Result<(), RegistryError> {
if summary.name != snapshot.name {
return Err(RegistryError::ImmutableOperationFieldChanged {
operation_id: snapshot.id.as_str().to_owned(),
field: "name",
});
}
if summary.protocol != snapshot.protocol {
return Err(RegistryError::ImmutableOperationFieldChanged {
operation_id: snapshot.id.as_str().to_owned(),
field: "protocol",
});
}
Ok(())
}
fn build_user(
id: String,
email: String,
@@ -324,7 +368,19 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
agent_id: row
.try_get::<Option<String>, _>("agent_id")?
.map(AgentId::new),
platform_api_key_id: row
.try_get::<Option<String>, _>("platform_api_key_id")?
.map(PlatformApiKeyId::new),
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
operation_version: row
.try_get::<Option<i32>, _>("operation_version")?
.map(|value| {
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
field: "operation_version",
value: i64::from(value),
})
})
.transpose()?,
source: deserialize_enum_text(&row.try_get::<String, _>("source")?, "source")?,
level: deserialize_enum_text(&row.try_get::<String, _>("level")?, "level")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
@@ -345,6 +401,22 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
},
duration_ms: to_u64(row.try_get::<i64, _>("duration_ms")?, "duration_ms")?,
error_kind: row.try_get("error_kind")?,
execution_stage: row
.try_get::<Option<String>, _>("execution_stage")?
.map(|value| deserialize_enum_text(&value, "execution_stage"))
.transpose()?,
execution_error_code: row
.try_get::<Option<String>, _>("execution_error_code")?
.map(|value| deserialize_enum_text(&value, "execution_error_code"))
.transpose()?,
retryability: row
.try_get::<Option<String>, _>("retryability")?
.map(|value| deserialize_enum_text(&value, "retryability"))
.transpose()?,
outcome_certainty: row
.try_get::<Option<String>, _>("outcome_certainty")?
.map(|value| deserialize_enum_text(&value, "outcome_certainty"))
.transpose()?,
request_preview: row.try_get::<Json<Value>, _>("request_preview_json")?.0,
response_preview: row.try_get::<Json<Value>, _>("response_preview_json")?.0,
created_at: row.try_get("created_at")?,
@@ -423,6 +495,7 @@ fn build_agent_summary(
status: String,
current_draft_version: i32,
latest_published_version: Option<i32>,
catalog_revision: i64,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
published_at: Option<OffsetDateTime>,
@@ -438,6 +511,7 @@ fn build_agent_summary(
latest_published_version: latest_published_version
.map(|value| from_db_version(value, "latest_published_version"))
.transpose()?,
catalog_revision,
created_at,
updated_at,
published_at,
@@ -457,6 +531,7 @@ fn build_operation_summary(
status: String,
current_draft_version: i32,
latest_published_version: Option<i32>,
can_delete: bool,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
published_at: Option<OffsetDateTime>,
@@ -479,6 +554,7 @@ fn build_operation_summary(
latest_published_version: latest_published_version
.map(|value| from_db_version(value, "latest_published_version"))
.transpose()?,
can_delete,
created_at,
updated_at,
published_at,
@@ -1,5 +1,56 @@
use super::*;
const PRESERVED_USAGE_WINDOW_DAYS: u16 = 90;
fn usage_outcome_group_case_sql(prefix: &str) -> String {
debug_assert!(matches!(prefix, "" | "l."));
format!(
"case
when {prefix}status = 'ok' then 'success'
when {prefix}execution_error_code in (
'upstream_auth_error',
'upstream_not_found',
'upstream_rate_limited',
'upstream_server_error',
'upstream_status_error',
'upstream_timeout',
'upstream_transport_error',
'upstream_request_too_large',
'upstream_response_too_large'
) then 'upstream'
when execution_error_code in (
'authorization_denied',
'auth_profile_not_found',
'secret_not_found',
'secret_invalid',
'input_schema_invalid',
'input_mapping_invalid',
'outbound_target_rejected',
'adapter_configuration_invalid',
'confirmation_required',
'confirmation_invalid',
'idempotency_in_progress',
'idempotency_conflict',
'idempotency_outcome_unknown'
) then 'client'
when execution_error_code in (
'prepared_request_invalid',
'output_mapping_invalid',
'output_schema_invalid'
) then 'schema'
when execution_error_code in (
'execution_overloaded',
'safety_store_unavailable',
'protocol_unsupported',
'execution_mode_unsupported',
'persistence_unavailable',
'runtime_internal'
) then 'crank'
else 'crank'
end"
)
}
fn invocation_history_loss_category(error: &RegistryError) -> InvocationHistoryLossCategory {
match error {
RegistryError::Storage(error)
@@ -19,12 +70,36 @@ impl PostgresRegistry {
pub async fn delete_invocation_logs_before(
&self,
cutoff: OffsetDateTime,
) -> Result<u64, RegistryError> {
let result = sqlx::query("delete from invocation_logs where created_at < $1::timestamptz")
.bind(cutoff)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
) -> Result<InvocationRetentionOutcome, RegistryError> {
let usage_floor = OffsetDateTime::now_utc()
- time::Duration::days(i64::from(PRESERVED_USAGE_WINDOW_DAYS));
let effective_cutoff = cutoff.min(usage_floor);
let result = sqlx::query(
"delete from invocation_logs l
where l.created_at < $1::timestamptz
and not exists (
select 1 from onboarding_selections s
where s.invocation_log_id = l.id or s.test_log_id = l.id
)",
)
.bind(effective_cutoff)
.execute(&self.pool)
.await?;
let deleted_records = result.rows_affected();
Ok(InvocationRetentionOutcome {
status: if deleted_records == 0 {
InvocationRetentionStatus::Noop
} else {
InvocationRetentionStatus::Completed
},
deleted_records,
policy: InvocationRetentionPolicy {
requested_cutoff: cutoff,
effective_cutoff,
usage_preservation_floor: usage_floor,
preserved_usage_window_days: PRESERVED_USAGE_WINDOW_DAYS,
},
})
}
pub async fn create_invocation_log(
@@ -63,15 +138,77 @@ impl PostgresRegistry {
.ok_or(RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
crank_core::TraceId::parse(trace_id)
.map_err(|_| RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
let operation_version =
request
.log
.operation_version
.ok_or(RegistryError::InvalidExecutionRecord {
field: "operation_version",
})?;
let _stage = request
.log
.execution_stage
.ok_or(RegistryError::InvalidExecutionRecord {
field: "execution_stage",
})?;
let _retryability =
request
.log
.retryability
.ok_or(RegistryError::InvalidExecutionRecord {
field: "retryability",
})?;
let _certainty =
request
.log
.outcome_certainty
.ok_or(RegistryError::InvalidExecutionRecord {
field: "outcome_certainty",
})?;
match request.log.status {
crank_core::InvocationStatus::Ok if request.log.execution_error_code.is_some() => {
return Err(RegistryError::InvalidExecutionRecord {
field: "execution_error_code",
});
}
crank_core::InvocationStatus::Error if request.log.execution_error_code.is_none() => {
return Err(RegistryError::InvalidExecutionRecord {
field: "execution_error_code",
});
}
_ => {}
}
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
let response_preview =
crank_core::sanitize_invocation_preview(&request.log.response_preview);
let operation_version =
i32::try_from(operation_version).map_err(|_| RegistryError::InvalidNumericValue {
field: "operation_version",
value: i64::MAX,
})?;
let should_anchor_onboarding = request.log.source
== crank_core::InvocationSource::AgentToolCall
&& request.log.status == crank_core::InvocationStatus::Ok
&& request.log.execution_stage == Some(crank_core::ExecutionStage::Runtime)
&& request.log.outcome_certainty == Some(crank_core::OutcomeCertainty::Certain)
&& request.log.execution_error_code.is_none()
&& request.log.agent_id.is_some()
&& request.log.platform_api_key_id.is_some();
let mut transaction = self.pool.begin().await?;
if should_anchor_onboarding {
sqlx::query("select id from workspaces where id = $1 for update")
.bind(request.log.workspace_id.as_str())
.fetch_one(&mut *transaction)
.await?;
}
sqlx::query(
"insert into invocation_logs (
id,
workspace_id,
agent_id,
platform_api_key_id,
operation_id,
operation_version,
source,
level,
status,
@@ -82,17 +219,29 @@ impl PostgresRegistry {
status_code,
duration_ms,
error_kind,
execution_stage,
execution_error_code,
retryability,
outcome_certainty,
request_preview_json,
response_preview_json,
created_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::timestamptz
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23::timestamptz
)",
)
.bind(request.log.id.as_str())
.bind(request.log.workspace_id.as_str())
.bind(request.log.agent_id.as_ref().map(|value| value.as_str()))
.bind(
request
.log
.platform_api_key_id
.as_ref()
.map(|value| value.as_str()),
)
.bind(request.log.operation_id.as_str())
.bind(operation_version)
.bind(serialize_enum_text(&request.log.source, "source")?)
.bind(serialize_enum_text(&request.log.level, "level")?)
.bind(serialize_enum_text(&request.log.status, "status")?)
@@ -108,12 +257,157 @@ impl PostgresRegistry {
}
})?)
.bind(&request.log.error_kind)
.bind(
request
.log
.execution_stage
.as_ref()
.map(|value| serialize_enum_text(value, "execution_stage"))
.transpose()?,
)
.bind(
request
.log
.execution_error_code
.as_ref()
.map(|value| serialize_enum_text(value, "execution_error_code"))
.transpose()?,
)
.bind(
request
.log
.retryability
.as_ref()
.map(|value| serialize_enum_text(value, "retryability"))
.transpose()?,
)
.bind(
request
.log
.outcome_certainty
.as_ref()
.map(|value| serialize_enum_text(value, "outcome_certainty"))
.transpose()?,
)
.bind(Json(request_preview))
.bind(Json(response_preview))
.bind(request.log.created_at)
.execute(&self.pool)
.execute(&mut *transaction)
.await?;
if should_anchor_onboarding {
let agent_id =
request
.log
.agent_id
.as_ref()
.ok_or(RegistryError::InvalidExecutionRecord {
field: "onboarding.agent_id",
})?;
let key_id = request.log.platform_api_key_id.as_ref().ok_or(
RegistryError::InvalidExecutionRecord {
field: "onboarding.platform_api_key_id",
},
)?;
let anchored = sqlx::query(
"insert into onboarding_selections (
workspace_id, operation_id, operation_version, agent_id, catalog_revision,
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
)
select $1, $2, $3, a.id, pa.catalog_revision, k.id, $6,
(
select l.id from invocation_logs l
where l.workspace_id = $1 and l.operation_id = $2
and l.operation_version = $3 and l.source = 'admin_test_run'
and l.status = 'ok' and l.execution_stage = 'runtime'
and l.outcome_certainty = 'certain' and l.execution_error_code is null
and l.created_at >= coalesce(
(select s.evidence_after from onboarding_selections s
where s.workspace_id = $1),
'-infinity'::timestamptz
)
order by l.created_at desc, l.id desc limit 1
),
coalesce(
(select s.evidence_after from onboarding_selections s
where s.workspace_id = $1),
'-infinity'::timestamptz
),
$7
from agents a
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b
on b.agent_id = pa.agent_id and b.agent_version = pa.version
and b.operation_id = $2 and b.operation_version = $3 and b.enabled
join published_operations po on po.operation_id = b.operation_id and po.version = b.operation_version
join operations o on o.id = po.operation_id and o.workspace_id = $1
and o.status = 'published' and o.current_draft_version = po.version
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id and k.id = $5
where a.workspace_id = $1 and a.id = $4 and a.status = 'published'
and k.key_kind = 'mcp_client' and k.status = 'active'
and (k.expires_at is null or k.expires_at > $7)
on conflict (workspace_id) do update
set operation_id = excluded.operation_id,
operation_version = excluded.operation_version,
agent_id = excluded.agent_id,
catalog_revision = excluded.catalog_revision,
platform_api_key_id = excluded.platform_api_key_id,
invocation_log_id = excluded.invocation_log_id,
test_log_id = excluded.test_log_id,
selected_at = excluded.selected_at
where onboarding_selections.operation_id is null
and excluded.selected_at >= onboarding_selections.evidence_after",
)
.bind(request.log.workspace_id.as_str())
.bind(request.log.operation_id.as_str())
.bind(operation_version)
.bind(agent_id.as_str())
.bind(key_id.as_str())
.bind(request.log.id.as_str())
.bind(request.log.created_at)
.execute(&mut *transaction)
.await?
.rows_affected()
> 0;
if anchored {
let projection = super::onboarding::get_onboarding_projection_in_transaction(
&mut transaction,
&request.log.workspace_id,
)
.await?;
let eligible =
super::product_event::get_product_event_by_idempotency_key_in_transaction(
&mut transaction,
&request.log.workspace_id,
"onboarding:eligible:v1",
)
.await?;
if let Some(eligible_since) = eligible
.filter(|_| projection.completed)
.and_then(|record| record.event.eligible_since)
{
let completed = crank_core::ProductEvent {
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
workspace_id: request.log.workspace_id.clone(),
kind: crank_core::ProductEventKind::OnboardingCompleted,
schema_version: crank_core::PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: true,
eligible_since: Some(eligible_since),
idempotency_key: "onboarding:completed:v1".to_owned(),
occurred_at: request.log.created_at,
};
super::product_event::append_product_event_in_transaction(
&mut transaction,
&completed,
)
.await?;
}
}
}
transaction.commit().await?;
Ok(())
}
@@ -121,12 +415,15 @@ impl PostgresRegistry {
&self,
query: ListInvocationLogsQuery<'_>,
) -> Result<Vec<InvocationLogRecord>, RegistryError> {
let rows = sqlx::query(
let outcome_group_case = usage_outcome_group_case_sql("l.");
let sql = format!(
"select
l.id,
l.workspace_id,
l.agent_id,
l.platform_api_key_id,
l.operation_id,
l.operation_version,
l.source,
l.level,
l.status,
@@ -137,6 +434,10 @@ impl PostgresRegistry {
l.status_code,
l.duration_ms,
l.error_kind,
l.execution_stage,
l.execution_error_code,
l.retryability,
l.outcome_certainty,
l.request_preview_json,
l.response_preview_json,
l.created_at as created_at,
@@ -149,42 +450,67 @@ impl PostgresRegistry {
left join agents a on a.id = l.agent_id
where l.workspace_id = $1
and ($2::text is null or l.level = $2)
and ($3::text is null or l.source = $3)
and ($4::text is null or l.operation_id = $4)
and ($5::text is null or l.agent_id = $5)
and ($6::timestamptz is null or l.created_at >= $6::timestamptz)
and ($3::text is null or l.status = $3)
and ($4::text is null or l.source = $4)
and ($5::text is null or l.operation_id = $5)
and ($6::text is null or l.agent_id = $6)
and ($7::text is null or ({outcome_group_case}) = $7)
and ($8::timestamptz is null or l.created_at >= $8::timestamptz)
and ($9::timestamptz is null or l.created_at < $9::timestamptz)
and (
$7::text is null
or l.tool_name ilike '%' || $7 || '%'
or l.message ilike '%' || $7 || '%'
or o.name ilike '%' || $7 || '%'
or o.display_name ilike '%' || $7 || '%'
$10::timestamptz is null
or (l.created_at, l.id) < ($10::timestamptz, $11::text)
)
order by l.created_at desc
limit $8",
)
.bind(query.workspace_id.as_str())
.bind(
query
.level
.as_ref()
.map(|value| serialize_enum_text(value, "level"))
.transpose()?,
)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.bind(query.operation_id.map(|value| value.as_str()))
.bind(query.agent_id.map(|value| value.as_str()))
.bind(query.created_after)
.bind(query.search_text)
.bind(i64::from(query.limit))
.fetch_all(&self.pool)
.await?;
and (
$12::text is null
or l.tool_name ilike '%' || $12 || '%'
or l.message ilike '%' || $12 || '%'
or o.name ilike '%' || $12 || '%'
or o.display_name ilike '%' || $12 || '%'
)
order by l.created_at desc, l.id desc
limit $13"
);
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(query.workspace_id.as_str())
.bind(
query
.level
.as_ref()
.map(|value| serialize_enum_text(value, "level"))
.transpose()?,
)
.bind(
query
.status
.as_ref()
.map(|value| serialize_enum_text(value, "status"))
.transpose()?,
)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.bind(query.operation_id.map(|value| value.as_str()))
.bind(query.agent_id.map(|value| value.as_str()))
.bind(
query
.outcome_group
.as_ref()
.map(|value| serialize_enum_text(value, "outcome_group"))
.transpose()?,
)
.bind(query.created_after)
.bind(query.created_before)
.bind(query.cursor_created_at)
.bind(query.cursor_id.map(|value| value.as_str()))
.bind(query.search_text)
.bind(i64::from(query.limit))
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_invocation_log_record).collect()
}
@@ -199,7 +525,9 @@ impl PostgresRegistry {
l.id,
l.workspace_id,
l.agent_id,
l.platform_api_key_id,
l.operation_id,
l.operation_version,
l.source,
l.level,
l.status,
@@ -210,6 +538,10 @@ impl PostgresRegistry {
l.status_code,
l.duration_ms,
l.error_kind,
l.execution_stage,
l.execution_error_code,
l.retryability,
l.outcome_certainty,
l.request_preview_json,
l.response_preview_json,
l.created_at as created_at,
@@ -245,10 +577,12 @@ impl PostgresRegistry {
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
and ($3::text is null or source = $3)",
and created_at < $3::timestamptz
and ($4::text is null or source = $4)",
)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
@@ -300,13 +634,15 @@ impl PostgresRegistry {
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
and ($3::text is null or source = $3)
and created_at < $3::timestamptz
and ($4::text is null or source = $4)
group by 1
order by 1 asc"
);
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
@@ -347,12 +683,14 @@ impl PostgresRegistry {
join operations o on o.id = l.operation_id
where l.workspace_id = $1
and l.created_at >= $2::timestamptz
and ($3::text is null or l.source = $3)
and l.created_at < $3::timestamptz
and ($4::text is null or l.source = $4)
group by o.id, o.name, o.display_name, o.protocol
order by calls_total desc, o.name asc",
)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
@@ -383,11 +721,13 @@ impl PostgresRegistry {
where workspace_id = $1
and operation_id = $2
and created_at >= $3::timestamptz
and ($4::text is null or source = $4)",
and created_at < $4::timestamptz
and ($5::text is null or source = $5)",
)
.bind(query.workspace_id.as_str())
.bind(operation_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
@@ -437,12 +777,14 @@ impl PostgresRegistry {
join agents a on a.id = l.agent_id
where l.workspace_id = $1
and l.created_at >= $2::timestamptz
and ($3::text is null or l.source = $3)
and l.created_at < $3::timestamptz
and ($4::text is null or l.source = $4)
group by a.id, a.slug, a.display_name
order by calls_total desc, a.slug asc",
)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
@@ -473,11 +815,13 @@ impl PostgresRegistry {
where workspace_id = $1
and agent_id = $2
and created_at >= $3::timestamptz
and ($4::text is null or source = $4)",
and created_at < $4::timestamptz
and ($5::text is null or source = $5)",
)
.bind(query.workspace_id.as_str())
.bind(agent_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
@@ -508,4 +852,78 @@ impl PostgresRegistry {
},
}))
}
pub async fn list_usage_outcomes(
&self,
query: UsageQuery<'_>,
) -> Result<Vec<UsageOutcomeBreakdown>, RegistryError> {
let outcome_group_case = usage_outcome_group_case_sql("");
let sql = format!(
"select
{outcome_group_case} as outcome_group,
execution_error_code,
count(*)::bigint as calls_total,
coalesce(percentile_cont(0.5) within group (order by duration_ms), 0)::bigint as p50_ms,
coalesce(percentile_cont(0.95) within group (order by duration_ms), 0)::bigint as p95_ms,
coalesce(percentile_cont(0.99) within group (order by duration_ms), 0)::bigint as p99_ms
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
and ($4::text is null or source = $4)
group by 1, execution_error_code
order by outcome_group asc, execution_error_code asc nulls first"
);
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(query.created_before)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(|row| {
Ok(UsageOutcomeBreakdown {
group: deserialize_enum_text(
&row.try_get::<String, _>("outcome_group")?,
"outcome_group",
)?,
execution_error_code: row
.try_get::<Option<String>, _>("execution_error_code")?
.map(|value| deserialize_enum_text(&value, "execution_error_code"))
.transpose()?,
calls_total: to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?,
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use crank_core::ExecutionErrorCode;
use super::usage_outcome_group_case_sql;
#[test]
fn usage_outcome_group_sql_covers_every_execution_error_code() {
let sql = usage_outcome_group_case_sql("");
for code in ExecutionErrorCode::ALL {
assert!(
sql.contains(code.as_str()),
"missing execution outcome grouping for {}",
code.as_str()
);
}
}
}
@@ -0,0 +1,603 @@
use crank_core::product_event::PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX;
use crank_core::{
AgentId, InvocationLogId, OnboardingProjection, OnboardingStep, OnboardingStepId, OperationId,
PRODUCT_EVENT_SCHEMA_VERSION, PlatformApiKeyId, ProductEvent, ProductEventKind,
};
use sqlx::Row;
use super::*;
const ONBOARDING_PROJECTION_SQL: &str = r#"
with selection_state as (
select operation_id, operation_version, agent_id, catalog_revision,
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
from onboarding_selections
where workspace_id = $1
), operation_candidates as (
select o.id, o.current_draft_version, o.latest_published_version, o.updated_at,
coalesce(ss.operation_version, o.latest_published_version, o.current_draft_version) as target_version,
(o.status <> 'archived') as operation_active,
exists (
select 1 from invocation_logs l
where l.workspace_id = $1 and l.operation_id = o.id
and l.operation_version = coalesce(ss.operation_version, o.latest_published_version, o.current_draft_version)
and (ss.test_log_id is null or l.id = ss.test_log_id)
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
and l.source = 'admin_test_run' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null
) as tested,
exists (
select 1 from published_operations po
where po.operation_id = o.id
and po.version = coalesce(ss.operation_version, o.latest_published_version)
) as published,
exists (
select 1 from agent_operation_bindings b
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
join agents a on a.id = pa.agent_id
where b.operation_id = o.id
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
and (ss.agent_id is null or a.id = ss.agent_id)
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
and b.enabled and a.workspace_id = $1 and a.status = 'published'
and o.status <> 'archived'
) as published_agent,
exists (
select 1 from agent_operation_bindings b
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
join agents a on a.id = pa.agent_id
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
where b.operation_id = o.id
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
and (ss.agent_id is null or a.id = ss.agent_id)
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
and b.enabled and a.workspace_id = $1 and a.status = 'published'
and o.status <> 'archived'
and k.key_kind = 'mcp_client' and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
) as active_key,
exists (
select 1 from agent_operation_bindings b
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
join agents a on a.id = pa.agent_id
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
join invocation_logs l
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
and l.operation_id = o.id and l.operation_version = b.operation_version
where b.operation_id = o.id
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
and (ss.agent_id is null or a.id = ss.agent_id)
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
and b.enabled and a.workspace_id = $1 and a.status = 'published'
and o.status <> 'archived'
and k.key_kind = 'mcp_client' and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
and l.source = 'agent_tool_call' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null and l.created_at >= k.created_at
and l.created_at >= pa.published_at
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
and (ss.invocation_log_id is null or l.id = ss.invocation_log_id)
) as first_call_complete
, exists (
select 1 from agent_operation_bindings b
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
join agents a on a.id = pa.agent_id
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
join invocation_logs l
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
and l.operation_id = o.id and l.operation_version = b.operation_version
where b.operation_id = o.id
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
and (ss.agent_id is null or a.id = ss.agent_id)
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
and b.enabled and a.workspace_id = $1
and k.key_kind = 'mcp_client'
and l.source = 'agent_tool_call' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null and l.created_at >= k.created_at
and l.created_at >= pa.published_at
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
and (ss.invocation_log_id is null or l.id = ss.invocation_log_id)
) as historical_first_call
from operations o
left join selection_state ss on true
where o.workspace_id = $1 and (ss.operation_id is null or o.id = ss.operation_id)
), selected_operation as (
select o.id, o.current_draft_version, o.latest_published_version, o.updated_at, o.target_version,
o.operation_active
from operation_candidates o
order by o.historical_first_call desc, o.operation_active desc,
o.first_call_complete desc, o.active_key desc, o.published_agent desc,
o.published desc, o.tested desc, o.updated_at desc, o.id
limit 1
), operation_state as (
select so.*,
(so.operation_active and exists (
select 1 from invocation_logs l
where l.workspace_id = $1 and l.operation_id = so.id
and l.operation_version = so.target_version
and l.created_at >= coalesce((select evidence_after from selection_state), '-infinity'::timestamptz)
and ((select test_log_id from selection_state) is null
or l.id = (select test_log_id from selection_state))
and l.source = 'admin_test_run' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null
)) as tested,
(so.operation_active and so.current_draft_version = so.target_version
and po.operation_id is not null and po.version = so.target_version) as published,
po.version as published_version
from selected_operation so
left join published_operations po on po.operation_id = so.id and po.version = so.target_version
), agent_candidates as (
select a.id, pa.catalog_revision, pa.published_at as agent_published_at,
a.updated_at, b.operation_id, b.operation_version, os.operation_active,
(os.published and a.status = 'published') as agent_active,
exists (
select 1 from platform_api_keys k
where k.workspace_id = $1 and k.agent_id = a.id
and os.operation_active and a.status = 'published'
and k.key_kind = 'mcp_client' and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
) as active_key,
exists (
select 1 from platform_api_keys k
join invocation_logs l
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
and l.operation_id = b.operation_id and l.operation_version = b.operation_version
where k.workspace_id = $1 and k.agent_id = a.id
and os.operation_active and a.status = 'published'
and k.key_kind = 'mcp_client' and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
and l.source = 'agent_tool_call' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null and l.created_at >= k.created_at
and l.created_at >= pa.published_at
) as first_call_complete
, exists (
select 1 from platform_api_keys k
join invocation_logs l
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
and l.operation_id = b.operation_id and l.operation_version = b.operation_version
where k.workspace_id = $1 and k.agent_id = a.id
and k.key_kind = 'mcp_client'
and l.source = 'agent_tool_call' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null and l.created_at >= k.created_at
and l.created_at >= pa.published_at
) as historical_first_call
from operation_state os
join agent_operation_bindings b
on b.operation_id = os.id and b.operation_version = os.published_version and b.enabled
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
join agents a on a.id = pa.agent_id
where a.workspace_id = $1
and ((select agent_id from selection_state) is null
or a.id = (select agent_id from selection_state))
and ((select catalog_revision from selection_state) is null
or pa.catalog_revision = (select catalog_revision from selection_state))
), selected_agent as (
select id, catalog_revision, agent_published_at, updated_at, operation_id, operation_version,
operation_active, agent_active
from agent_candidates
order by historical_first_call desc, agent_active desc,
first_call_complete desc, active_key desc, updated_at desc, id
limit 1
), key_candidates as (
select k.id, k.agent_id, k.last_used_at, k.created_at, k.expires_at, k.status,
(sa.agent_active and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())) as key_active,
exists (
select 1 from invocation_logs l
where l.workspace_id = $1 and l.agent_id = sa.id and l.platform_api_key_id = k.id
and l.operation_id = sa.operation_id and l.operation_version = sa.operation_version
and l.source = 'agent_tool_call' and l.status = 'ok'
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null and l.created_at >= k.created_at
and l.created_at >= sa.agent_published_at
) as first_call_complete
from selected_agent sa
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = sa.id
where k.key_kind = 'mcp_client'
and ((select platform_api_key_id from selection_state) is null
or k.id = (select platform_api_key_id from selection_state))
), selected_key as (
select id, agent_id, last_used_at, created_at, expires_at, status, key_active
from key_candidates
order by first_call_complete desc, key_active desc, (last_used_at is not null) desc, created_at desc, id
limit 1
), first_call as (
select l.id, l.tool_name, l.created_at, l.request_id, l.trace_id
from selected_agent sa
join selected_key sk on sk.agent_id = sa.id
join invocation_logs l
on l.workspace_id = $1 and l.agent_id = sa.id and l.platform_api_key_id = sk.id
and l.operation_id = sa.operation_id and l.operation_version = sa.operation_version
where l.source = 'agent_tool_call' and l.status = 'ok'
and sk.key_active
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
and l.execution_error_code is null
and l.created_at >= sk.created_at
and l.created_at >= sa.agent_published_at
and l.created_at >= coalesce((select evidence_after from selection_state), '-infinity'::timestamptz)
and ((select invocation_log_id from selection_state) is null
or l.id = (select invocation_log_id from selection_state))
order by l.created_at, l.id limit 1
), event_state as (
select count(*)::bigint as event_count, max(occurred_at) as last_event_at, max(id) as last_event_id,
coalesce(bool_or(event_name = 'onboarding_completed'), false) as was_completed,
min((properties_json ->> 'eligible_since')::timestamptz)
filter (where event_name = 'onboarding_eligible') as eligible_since
from product_events where workspace_id = $1
)
select os.id as operation_id, os.operation_active, os.tested, os.published, os.published_version,
sa.id as agent_id, sa.agent_active, sa.catalog_revision, sk.id as platform_api_key_id,
(sk.key_active and sk.last_used_at is not null) as connected, sk.key_active,
fc.id as first_call_log_id, fc.tool_name as first_call_tool_name,
fc.created_at as first_call_at, fc.request_id as first_call_request_id,
fc.trace_id as first_call_trace_id, es.eligible_since, es.was_completed,
(hashtextextended(concat_ws('|',
coalesce(os.id, ''), coalesce(os.updated_at::text, ''), coalesce(os.tested::text, ''),
coalesce(os.published_version::text, ''), coalesce(sa.id, ''),
coalesce(sa.catalog_revision::text, ''), coalesce(sa.updated_at::text, ''),
coalesce(sk.id, ''), coalesce(sk.status, ''), coalesce(sk.last_used_at::text, ''),
coalesce(sk.expires_at::text, ''), coalesce(sk.key_active::text, ''),
coalesce(fc.id, ''), coalesce(es.event_count::text, ''),
coalesce(es.last_event_at::text, ''), coalesce(es.last_event_id, ''),
coalesce((select evidence_after::text from selection_state), ''),
coalesce((select selected_at::text from selection_state), '')
), 0) & 9223372036854775807) as revision
from event_state es
left join operation_state os on true
left join selected_agent sa on true
left join selected_key sk on true
left join first_call fc on true
"#;
impl PostgresRegistry {
/// Ensures the workspace enters the server-owned onboarding cohort exactly once.
pub async fn ensure_onboarding_eligibility(
&self,
workspace_id: &WorkspaceId,
occurred_at: OffsetDateTime,
) -> Result<OnboardingProjection, RegistryError> {
let mut transaction = self.pool.begin().await?;
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
let eligible = super::product_event::get_product_event_by_idempotency_key_in_transaction(
&mut transaction,
workspace_id,
"onboarding:eligible:v1",
)
.await?;
let eligible_since = if let Some(record) = eligible {
record.event.eligible_since.unwrap_or(occurred_at)
} else {
let event = ProductEvent {
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
workspace_id: workspace_id.clone(),
kind: ProductEventKind::OnboardingEligible,
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: true,
eligible_since: Some(occurred_at),
idempotency_key: "onboarding:eligible:v1".to_owned(),
occurred_at,
};
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
.await?;
occurred_at
};
let mut projection =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
if projection.completed && !projection.was_completed {
let event = ProductEvent {
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
workspace_id: workspace_id.clone(),
kind: ProductEventKind::OnboardingCompleted,
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: true,
eligible_since: Some(eligible_since),
idempotency_key: "onboarding:completed:v1".to_owned(),
occurred_at: projection.first_call_at.unwrap_or(occurred_at),
};
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
.await?;
projection =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
}
transaction.commit().await?;
Ok(projection)
}
/// Records the server-owned completion once the authoritative projection is terminal.
pub async fn ensure_onboarding_completion(
&self,
workspace_id: &WorkspaceId,
_occurred_at: OffsetDateTime,
) -> Result<OnboardingProjection, RegistryError> {
let mut transaction = self.pool.begin().await?;
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
let projection =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
if !projection.completed {
transaction.commit().await?;
return Ok(projection);
}
let Some(eligible_since) = projection.eligible_since else {
transaction.commit().await?;
return Ok(projection);
};
let event = ProductEvent {
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
workspace_id: workspace_id.clone(),
kind: ProductEventKind::OnboardingCompleted,
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: true,
eligible_since: Some(eligible_since),
idempotency_key: "onboarding:completed:v1".to_owned(),
occurred_at: projection
.first_call_at
.ok_or(RegistryError::InvalidExecutionRecord {
field: "onboarding.first_call_at",
})?,
};
super::product_event::append_product_event_in_transaction(&mut transaction, &event).await?;
let projection =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
transaction.commit().await?;
Ok(projection)
}
pub async fn get_onboarding_projection(
&self,
workspace_id: &WorkspaceId,
) -> Result<OnboardingProjection, RegistryError> {
let row = sqlx::query(ONBOARDING_PROJECTION_SQL)
.bind(workspace_id.as_str())
.fetch_one(&self.pool)
.await?;
map_projection(workspace_id, &row)
}
pub async fn reset_onboarding_selection(
&self,
workspace_id: &WorkspaceId,
expected_revision: i64,
occurred_at: OffsetDateTime,
) -> Result<OnboardingProjection, RegistryError> {
let mut transaction = self.pool.begin().await?;
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
let current =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
if current.revision != expected_revision {
return Err(RegistryError::OnboardingStaleRevision);
}
sqlx::query(
"insert into onboarding_selections (
workspace_id, operation_id, operation_version, agent_id, catalog_revision,
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
) values ($1, null, null, null, null, null, null, null, $2, null)
on conflict (workspace_id) do update
set operation_id = null, operation_version = null, agent_id = null,
catalog_revision = null, platform_api_key_id = null,
invocation_log_id = null, test_log_id = null,
evidence_after = excluded.evidence_after, selected_at = null",
)
.bind(workspace_id.as_str())
.bind(occurred_at)
.execute(&mut *transaction)
.await?;
let projection =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
transaction.commit().await?;
Ok(projection)
}
pub async fn record_onboarding_milestone(
&self,
request: RecordOnboardingMilestoneRequest<'_>,
) -> Result<OnboardingMilestoneResult, RegistryError> {
let kind = match request.milestone {
OnboardingPresentationMilestone::Eligible => ProductEventKind::OnboardingEligible,
OnboardingPresentationMilestone::Started => ProductEventKind::OnboardingStarted,
OnboardingPresentationMilestone::Resumed => ProductEventKind::OnboardingResumed,
OnboardingPresentationMilestone::Dismissed => ProductEventKind::OnboardingDismissed,
OnboardingPresentationMilestone::Abandoned => ProductEventKind::OnboardingAbandoned,
};
self.record_onboarding_event(
request.workspace_id,
request.event_id,
kind,
request.idempotency_key,
request.expected_revision,
request.occurred_at,
request.eligible_since,
)
.await
}
pub async fn record_onboarding_completion(
&self,
request: RecordOnboardingCompletionRequest<'_>,
) -> Result<OnboardingMilestoneResult, RegistryError> {
self.record_onboarding_event(
request.workspace_id,
request.event_id,
ProductEventKind::OnboardingCompleted,
request.idempotency_key,
request.expected_revision,
request.occurred_at,
Some(request.eligible_since),
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn record_onboarding_event(
&self,
workspace_id: &WorkspaceId,
event_id: &ProductEventId,
kind: ProductEventKind,
idempotency_key: &str,
expected_revision: i64,
occurred_at: OffsetDateTime,
eligible_since: Option<OffsetDateTime>,
) -> Result<OnboardingMilestoneResult, RegistryError> {
if idempotency_key.starts_with(PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX) {
return Err(RegistryError::InvalidExecutionRecord {
field: "product_event.reserved_idempotency_key",
});
}
let mut transaction = self.pool.begin().await?;
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
let current =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
if kind == ProductEventKind::OnboardingCompleted && !current.completed {
return Err(RegistryError::OnboardingIncomplete);
}
let event = ProductEvent {
id: event_id.clone(),
workspace_id: workspace_id.clone(),
kind,
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
milestone: None,
eligible: eligible_since.is_some(),
eligible_since,
idempotency_key: idempotency_key.to_owned(),
occurred_at,
};
if current.revision != expected_revision {
let replay = super::product_event::get_product_event_by_idempotency_key_in_transaction(
&mut transaction,
workspace_id,
idempotency_key,
)
.await?;
if replay.is_some_and(|record| event.is_semantic_replay_of(&record.event)) {
transaction.commit().await?;
return Ok(OnboardingMilestoneResult {
accepted: false,
projection: current,
});
}
return Err(RegistryError::OnboardingStaleRevision);
}
let outcome =
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
.await?;
let projection =
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
transaction.commit().await?;
Ok(OnboardingMilestoneResult {
accepted: outcome == AppendProductEventOutcome::Recorded,
projection,
})
}
}
async fn lock_onboarding_workspace(
transaction: &mut Transaction<'_, Postgres>,
workspace_id: &WorkspaceId,
) -> Result<(), RegistryError> {
let present = sqlx::query("select id from workspaces where id = $1 for update")
.bind(workspace_id.as_str())
.fetch_optional(&mut **transaction)
.await?
.is_some();
if !present {
return Err(RegistryError::WorkspaceNotFound {
workspace_id: workspace_id.as_str().to_owned(),
});
}
Ok(())
}
pub(super) async fn get_onboarding_projection_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
workspace_id: &WorkspaceId,
) -> Result<OnboardingProjection, RegistryError> {
let row = sqlx::query(ONBOARDING_PROJECTION_SQL)
.bind(workspace_id.as_str())
.fetch_one(&mut **transaction)
.await?;
map_projection(workspace_id, &row)
}
fn map_projection(
workspace_id: &WorkspaceId,
row: &sqlx::postgres::PgRow,
) -> Result<OnboardingProjection, RegistryError> {
let operation_id = row
.try_get::<Option<String>, _>("operation_id")?
.map(OperationId::new);
let operation_active = row
.try_get::<Option<bool>, _>("operation_active")?
.unwrap_or(false);
let tested = row.try_get::<Option<bool>, _>("tested")?.unwrap_or(false);
let published = row
.try_get::<Option<bool>, _>("published")?
.unwrap_or(false);
let agent_id = row
.try_get::<Option<String>, _>("agent_id")?
.map(AgentId::new);
let agent_active = row
.try_get::<Option<bool>, _>("agent_active")?
.unwrap_or(false);
let key_id = row
.try_get::<Option<String>, _>("platform_api_key_id")?
.map(PlatformApiKeyId::new);
let key_active = row
.try_get::<Option<bool>, _>("key_active")?
.unwrap_or(false);
let connected = row
.try_get::<Option<bool>, _>("connected")?
.unwrap_or(false);
let first_call_log_id = row
.try_get::<Option<String>, _>("first_call_log_id")?
.map(InvocationLogId::new);
let flags = [
operation_id.is_some() && operation_active,
tested,
published,
agent_id.is_some() && agent_active,
key_id.is_some() && key_active,
connected,
first_call_log_id.is_some(),
];
Ok(OnboardingProjection {
workspace_id: workspace_id.clone(),
revision: row.try_get("revision")?,
completed: flags.into_iter().all(|flag| flag),
was_completed: row.try_get("was_completed")?,
steps: OnboardingStepId::ORDERED
.into_iter()
.zip(flags)
.map(|(id, completed)| OnboardingStep { id, completed })
.collect(),
operation_id,
operation_version: row
.try_get::<Option<i32>, _>("published_version")?
.map(|value| {
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
field: "onboarding.operation_version",
value: i64::from(value),
})
})
.transpose()?,
agent_id,
catalog_revision: row.try_get("catalog_revision")?,
platform_api_key_id: key_id,
first_call_log_id,
first_call_tool_name: row.try_get("first_call_tool_name")?,
first_call_at: row.try_get("first_call_at")?,
first_call_request_id: row.try_get("first_call_request_id")?,
first_call_trace_id: row.try_get("first_call_trace_id")?,
eligible_since: row.try_get("eligible_since")?,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
use super::*;
impl PostgresRegistry {
pub async fn get_published_operation(
&self,
operation_id: &OperationId,
) -> Result<Option<RegistryOperation>, RegistryError> {
let row = sqlx::query!(
"select
o.id,
o.workspace_id,
ov.name,
ov.display_name,
ov.category,
ov.protocol,
ov.security_level,
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
ov.version,
ov.status,
ov.target_json,
ov.input_schema_json,
ov.output_schema_json,
ov.input_mapping_json,
ov.output_mapping_json,
ov.execution_config_json,
ov.tool_description_json,
ov.samples_json,
ov.generated_draft_json,
ov.config_export_json,
ov.wizard_state_json,
ov.change_note,
ov.created_at as \"created_at!: time::OffsetDateTime\",
ov.created_by
from published_operations po
join operation_versions ov
on ov.operation_id = po.operation_id and ov.version = po.version
join operations o on o.id = po.operation_id
where po.operation_id = $1",
operation_id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
build_operation_version_record(
row.id,
row.workspace_id,
row.name,
row.display_name,
row.category,
row.protocol,
row.security_level,
row.operation_created_at,
row.operation_updated_at,
row.operation_published_at,
row.version,
row.status,
row.target_json,
row.input_schema_json,
row.output_schema_json,
row.input_mapping_json,
row.output_mapping_json,
row.execution_config_json,
row.tool_description_json,
row.samples_json,
row.generated_draft_json,
row.config_export_json,
row.wizard_state_json,
row.change_note,
row.created_at,
row.created_by,
)
.map(|record| record.snapshot)
})
.transpose()
}
pub async fn list_published_operations(&self) -> Result<Vec<RegistryOperation>, RegistryError> {
let rows = sqlx::query!(
"select
o.id,
o.workspace_id,
ov.name,
ov.display_name,
ov.category,
ov.protocol,
ov.security_level,
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
ov.version,
ov.status,
ov.target_json,
ov.input_schema_json,
ov.output_schema_json,
ov.input_mapping_json,
ov.output_mapping_json,
ov.execution_config_json,
ov.tool_description_json,
ov.samples_json,
ov.generated_draft_json,
ov.config_export_json,
ov.wizard_state_json,
ov.change_note,
ov.created_at as \"created_at!: time::OffsetDateTime\",
ov.created_by
from published_operations po
join operation_versions ov
on ov.operation_id = po.operation_id and ov.version = po.version
join operations o on o.id = po.operation_id
order by o.name asc",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
build_operation_version_record(
row.id,
row.workspace_id,
row.name,
row.display_name,
row.category,
row.protocol,
row.security_level,
row.operation_created_at,
row.operation_updated_at,
row.operation_published_at,
row.version,
row.status,
row.target_json,
row.input_schema_json,
row.output_schema_json,
row.input_mapping_json,
row.output_mapping_json,
row.execution_config_json,
row.tool_description_json,
row.samples_json,
row.generated_draft_json,
row.config_export_json,
row.wizard_state_json,
row.change_note,
row.created_at,
row.created_by,
)
.map(|record| record.snapshot)
})
.collect()
}
}
@@ -0,0 +1,227 @@
use crank_core::{
OnboardingMilestone, PRODUCT_EVENT_SCHEMA_VERSION, ProductEvent, ProductEventKind,
};
use serde_json::json;
use sqlx::{Postgres, Row, Transaction, types::Json};
use time::format_description::well_known::Rfc3339;
use super::*;
impl PostgresRegistry {
pub async fn append_product_event(
&self,
request: AppendProductEventRequest<'_>,
) -> Result<AppendProductEventOutcome, RegistryError> {
if request
.event
.idempotency_key
.starts_with(crank_core::product_event::PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX)
{
return Err(RegistryError::InvalidExecutionRecord {
field: "product_event.reserved_idempotency_key",
});
}
if !request.event.is_valid() {
return Err(RegistryError::InvalidExecutionRecord {
field: "product_event",
});
}
let mut transaction = self.pool.begin().await?;
let outcome = append_product_event_in_transaction(&mut transaction, request.event).await?;
transaction.commit().await?;
Ok(outcome)
}
pub async fn get_product_event_by_idempotency_key(
&self,
workspace_id: &WorkspaceId,
idempotency_key: &str,
) -> Result<Option<ProductEventRecord>, RegistryError> {
let mut transaction = self.pool.begin().await?;
let record = get_product_event_by_idempotency_key_in_transaction(
&mut transaction,
workspace_id,
idempotency_key,
)
.await?;
transaction.commit().await?;
Ok(record)
}
pub async fn list_product_events(
&self,
query: ListProductEventsQuery<'_>,
) -> Result<Vec<ProductEventRecord>, RegistryError> {
let rows = sqlx::query(
"select id, workspace_id, event_name, schema_version, occurred_at,
idempotency_key, properties_json
from product_events
where workspace_id = $1
and ($2::text is null or event_name = $2)
and occurred_at >= $3 and occurred_at < $4
order by occurred_at desc, id desc
limit $5",
)
.bind(query.workspace_id.as_str())
.bind(query.kind.map(ProductEventKind::as_str))
.bind(query.created_after)
.bind(query.created_before)
.bind(i64::from(query.limit.min(1_000)))
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_product_event).collect()
}
}
pub(super) async fn get_product_event_by_idempotency_key_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
workspace_id: &WorkspaceId,
idempotency_key: &str,
) -> Result<Option<ProductEventRecord>, RegistryError> {
let row = sqlx::query(
"select id, workspace_id, event_name, schema_version, occurred_at,
idempotency_key, properties_json
from product_events
where workspace_id = $1 and idempotency_key = $2",
)
.bind(workspace_id.as_str())
.bind(idempotency_key)
.fetch_optional(&mut **transaction)
.await?;
row.map(map_product_event).transpose()
}
pub(super) async fn append_product_event_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
event: &ProductEvent,
) -> Result<AppendProductEventOutcome, RegistryError> {
if !event.is_valid() {
return Err(RegistryError::InvalidExecutionRecord {
field: "product_event",
});
}
let eligible_since = event
.eligible_since
.map(|value| value.format(&Rfc3339))
.transpose()
.map_err(|_| RegistryError::InvalidExecutionRecord {
field: "product_event.eligible_since",
})?;
let properties = json!({
"eligible": event.eligible,
"eligible_since": eligible_since,
"milestone": event.milestone,
});
let inserted = sqlx::query(
"insert into product_events (
id, workspace_id, event_name, schema_version, occurred_at,
idempotency_key, properties_json
) values ($1, $2, $3, $4, $5, $6, $7)
on conflict (workspace_id, idempotency_key) do nothing",
)
.bind(event.id.as_str())
.bind(event.workspace_id.as_str())
.bind(event.kind.as_str())
.bind(i32::from(event.schema_version))
.bind(event.occurred_at)
.bind(&event.idempotency_key)
.bind(Json(properties))
.execute(&mut **transaction)
.await?
.rows_affected();
if inserted == 0 {
let recorded = sqlx::query(
"select id, workspace_id, event_name, schema_version, occurred_at,
idempotency_key, properties_json
from product_events
where workspace_id = $1 and idempotency_key = $2",
)
.bind(event.workspace_id.as_str())
.bind(&event.idempotency_key)
.fetch_one(&mut **transaction)
.await?;
let recorded = map_product_event(recorded)?;
if event.is_semantic_replay_of(&recorded.event) {
return Ok(AppendProductEventOutcome::Duplicate);
}
return Err(RegistryError::InvalidExecutionRecord {
field: "product_event.idempotency_conflict",
});
}
sqlx::query(
"insert into product_event_daily_rollups (
workspace_id, event_name, day, events_total, eligible_total
) values ($1, $2, $3, 1, $4)
on conflict (workspace_id, event_name, day) do update
set events_total = product_event_daily_rollups.events_total + 1,
eligible_total = product_event_daily_rollups.eligible_total + excluded.eligible_total",
)
.bind(event.workspace_id.as_str())
.bind(event.kind.as_str())
.bind(event.occurred_on_utc())
.bind(i64::from(
event.kind == ProductEventKind::OnboardingEligible,
))
.execute(&mut **transaction)
.await?;
Ok(AppendProductEventOutcome::Recorded)
}
fn map_product_event(row: sqlx::postgres::PgRow) -> Result<ProductEventRecord, RegistryError> {
let event_name = row.try_get::<String, _>("event_name")?;
let kind = ProductEventKind::ALL
.into_iter()
.find(|kind| kind.as_str() == event_name)
.ok_or(RegistryError::InvalidExecutionRecord {
field: "product_event.event_name",
})?;
let properties = row.try_get::<serde_json::Value, _>("properties_json")?;
let milestone = properties
.get("milestone")
.filter(|value| !value.is_null())
.map(|value| serde_json::from_value::<OnboardingMilestone>(value.clone()))
.transpose()
.map_err(|_| RegistryError::InvalidExecutionRecord {
field: "product_event.milestone",
})?;
let eligible_since = properties
.get("eligible_since")
.filter(|value| !value.is_null())
.map(|value| {
value
.as_str()
.ok_or(())
.and_then(|value| OffsetDateTime::parse(value, &Rfc3339).map_err(|_| ()))
})
.transpose()
.map_err(|_| RegistryError::InvalidExecutionRecord {
field: "product_event.eligible_since",
})?;
let schema_version = row.try_get::<i32, _>("schema_version")?;
let schema_version =
u16::try_from(schema_version).map_err(|_| RegistryError::InvalidNumericValue {
field: "product_event.schema_version",
value: i64::from(schema_version),
})?;
if schema_version != PRODUCT_EVENT_SCHEMA_VERSION {
return Err(RegistryError::InvalidExecutionRecord {
field: "product_event.schema_version",
});
}
Ok(ProductEventRecord {
event: ProductEvent {
id: ProductEventId::new(row.try_get::<String, _>("id")?),
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
kind,
schema_version,
milestone,
eligible: properties
.get("eligible")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
eligible_since,
idempotency_key: row.try_get("idempotency_key")?,
occurred_at: row.try_get("occurred_at")?,
},
})
}
+175 -35
View File
@@ -1,3 +1,4 @@
use super::master_key::{ensure_active_master_key_epoch, ensure_no_active_master_key_rotation};
use super::*;
impl PostgresRegistry {
@@ -90,34 +91,42 @@ impl PostgresRegistry {
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Option<SecretVersionRecord>, RegistryError> {
let row = sqlx::query!(
let row = sqlx::query(
"select
sv.secret_id,
sv.version,
sv.ciphertext,
sv.key_version,
sv.created_at as \"created_at!: time::OffsetDateTime\",
sv.master_key_epoch,
sv.target_ciphertext,
sv.target_key_version,
sv.target_master_key_epoch,
sv.created_at,
sv.created_by
from secrets s
join secret_versions sv
on sv.secret_id = s.id and sv.version = s.current_version
where s.workspace_id = $1 and s.id = $2",
workspace_id.as_str(),
secret_id.as_str(),
where s.workspace_id = $1 and s.id = $2 and s.status = 'active'",
)
.bind(workspace_id.as_str())
.bind(secret_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(SecretVersionRecord {
secret_version: SecretVersion {
secret_id: SecretId::new(row.secret_id),
version: from_db_version(row.version, "version")?,
ciphertext: row.ciphertext,
key_version: row.key_version,
created_at: row.created_at,
created_by: row.created_by.map(UserId::new),
secret_id: SecretId::new(row.get::<String, _>("secret_id")),
version: from_db_version(row.get::<i32, _>("version"), "version")?,
ciphertext: row.get::<String, _>("ciphertext"),
key_version: row.get::<String, _>("key_version"),
created_at: row.get("created_at"),
created_by: row.get::<Option<String>, _>("created_by").map(UserId::new),
},
master_key_epoch: row.get::<i64, _>("master_key_epoch"),
target_ciphertext: row.get::<Option<String>, _>("target_ciphertext"),
target_key_version: row.get::<Option<String>, _>("target_key_version"),
target_master_key_epoch: row.get::<Option<i64>, _>("target_master_key_epoch"),
})
})
.transpose()
@@ -128,6 +137,8 @@ impl PostgresRegistry {
request: CreateSecretRequest<'_>,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
ensure_no_active_master_key_rotation(&mut tx).await?;
ensure_active_master_key_epoch(&mut tx, request.master_key_epoch).await?;
let result = sqlx::query(
"insert into secrets (
id,
@@ -163,16 +174,18 @@ impl PostgresRegistry {
version,
ciphertext,
key_version,
master_key_epoch,
created_at,
created_by
) values (
$1, $2, $3, $4, $5::timestamptz, $6
$1, $2, $3, $4, $5, $6::timestamptz, $7
)",
)
.bind(request.secret.id.as_str())
.bind(to_db_version(request.secret.current_version))
.bind(request.ciphertext)
.bind(request.key_version)
.bind(request.master_key_epoch)
.bind(request.secret.created_at)
.bind(request.created_by.map(|value| value.as_str()))
.execute(&mut *tx)
@@ -196,31 +209,56 @@ impl PostgresRegistry {
&self,
request: RotateSecretRequest<'_>,
) -> Result<SecretVersionRecord, RegistryError> {
let existing = self
.get_secret(request.workspace_id, request.secret_id)
.await?
.ok_or_else(|| RegistryError::SecretNotFound {
secret_id: request.secret_id.as_str().to_owned(),
})?;
let next_version = existing.secret.current_version + 1;
let mut tx = self.pool.begin().await?;
ensure_no_active_master_key_rotation(&mut tx).await?;
ensure_active_master_key_epoch(&mut tx, request.master_key_epoch).await?;
let row = sqlx::query(
"select current_version, status
from secrets
where workspace_id = $1 and id = $2
for update",
)
.bind(request.workspace_id.as_str())
.bind(request.secret_id.as_str())
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(RegistryError::SecretNotFound {
secret_id: request.secret_id.as_str().to_owned(),
});
};
let status: String = row.get("status");
if status != "active" {
return Err(RegistryError::SecretInactive {
secret_id: request.secret_id.as_str().to_owned(),
});
}
let current_version =
from_db_version(row.get::<i32, _>("current_version"), "current_version")?;
let next_version = current_version.checked_add(1).ok_or_else(|| {
RegistryError::SecretConcurrentUpdate {
secret_id: request.secret_id.as_str().to_owned(),
}
})?;
sqlx::query(
"insert into secret_versions (
secret_id,
version,
ciphertext,
key_version,
master_key_epoch,
created_at,
created_by
) values (
$1, $2, $3, $4, $5::timestamptz, $6
$1, $2, $3, $4, $5, $6::timestamptz, $7
)",
)
.bind(request.secret_id.as_str())
.bind(to_db_version(next_version))
.bind(request.ciphertext)
.bind(request.key_version)
.bind(request.master_key_epoch)
.bind(request.created_at)
.bind(request.created_by.map(|value| value.as_str()))
.execute(&mut *tx)
@@ -230,14 +268,31 @@ impl PostgresRegistry {
"update secrets
set current_version = $3,
updated_at = $4::timestamptz
where workspace_id = $1 and id = $2",
where workspace_id = $1
and id = $2
and status = 'active'
and current_version = $5",
)
.bind(request.workspace_id.as_str())
.bind(request.secret_id.as_str())
.bind(to_db_version(next_version))
.bind(request.updated_at)
.bind(to_db_version(current_version))
.execute(&mut *tx)
.await?;
.await
.and_then(|result| {
if result.rows_affected() == 1 {
Ok(result)
} else {
Err(sqlx::Error::RowNotFound)
}
})
.map_err(|error| match error {
sqlx::Error::RowNotFound => RegistryError::SecretConcurrentUpdate {
secret_id: request.secret_id.as_str().to_owned(),
},
other => RegistryError::Storage(other),
})?;
tx.commit().await?;
@@ -250,6 +305,10 @@ impl PostgresRegistry {
created_at: *request.created_at,
created_by: request.created_by.cloned(),
},
master_key_epoch: request.master_key_epoch,
target_ciphertext: None,
target_key_version: None,
target_master_key_epoch: None,
})
}
@@ -258,13 +317,24 @@ impl PostgresRegistry {
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
ensure_no_active_master_key_rotation(&mut tx).await?;
lock_secret_reference(&mut tx, workspace_id, secret_id).await?;
if let Some(auth_profile_id) =
first_auth_profile_ref_in_tx(&mut tx, workspace_id, secret_id).await?
{
return Err(RegistryError::SecretReferencedByAuthProfile {
secret_id: secret_id.as_str().to_owned(),
auth_profile_id,
});
}
let result = sqlx::query(
"delete from secrets
where workspace_id = $1 and id = $2",
)
.bind(workspace_id.as_str())
.bind(secret_id.as_str())
.execute(&self.pool)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
@@ -273,6 +343,7 @@ impl PostgresRegistry {
});
}
tx.commit().await?;
Ok(())
}
@@ -282,23 +353,30 @@ impl PostgresRegistry {
secret_id: &SecretId,
used_at: &OffsetDateTime,
) -> Result<(), RegistryError> {
let exists = sqlx::query_scalar::<_, bool>(
"with target as (
select id
let row = sqlx::query(
"with target as materialized (
select id,
last_used_at,
status = 'active' as active
from secrets
where workspace_id = $1 and id = $2
for update
), updated as (
update secrets
update secrets as secret
set last_used_at = $3::timestamptz
where workspace_id = $1
and id = $2
from target
where secret.workspace_id = $1
and secret.id = target.id
and target.active
and (
last_used_at is null
or last_used_at < $3::timestamptz - interval '1 minute'
target.last_used_at is null
or target.last_used_at < $3::timestamptz - interval '1 minute'
)
returning id
returning secret.id
)
select exists(select 1 from target)",
select
exists(select 1 from target) as exists,
coalesce((select active from target), false) as active",
)
.bind(workspace_id.as_str())
.bind(secret_id.as_str())
@@ -306,12 +384,74 @@ impl PostgresRegistry {
.fetch_one(&self.pool)
.await?;
if !exists {
if !row.get::<bool, _>("exists") {
return Err(RegistryError::SecretNotFound {
secret_id: secret_id.as_str().to_owned(),
});
}
if !row.get::<bool, _>("active") {
return Err(RegistryError::SecretInactive {
secret_id: secret_id.as_str().to_owned(),
});
}
Ok(())
}
}
pub(super) async fn lock_secret_reference(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<(), RegistryError> {
sqlx::query(
"select id
from secrets
where workspace_id = $1 and id = $2
for update",
)
.bind(workspace_id.as_str())
.bind(secret_id.as_str())
.execute(&mut **transaction)
.await?;
Ok(())
}
async fn first_auth_profile_ref_in_tx(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Option<String>, RegistryError> {
let rows = sqlx::query(
"select id, name, kind, config_json, created_at, updated_at
from auth_profiles
where workspace_id = $1
order by id asc
for update",
)
.bind(workspace_id.as_str())
.fetch_all(&mut **transaction)
.await?;
for row in rows {
let profile = super::build_auth_profile(
row.get("id"),
workspace_id.as_str().to_owned(),
row.get("name"),
row.get("kind"),
row.get("config_json"),
row.get("created_at"),
row.get("updated_at"),
)?;
if profile
.config
.secret_ids()
.into_iter()
.any(|candidate| candidate == secret_id)
{
return Ok(Some(profile.id.as_str().to_owned()));
}
}
Ok(None)
}
@@ -1,8 +1,12 @@
mod integration {
mod agents_usage;
mod approval;
mod common;
mod credential_touch;
mod master_key_identity;
mod migrations;
mod observability;
mod onboarding;
mod operations_artifacts;
mod workspace_access;
}
@@ -23,8 +23,9 @@ use crank_registry::{
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UsageBucket, UsageOutcomeGroup, UsageQuery, WorkspaceRecord,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
fn test_workspace_id() -> WorkspaceId {
@@ -145,6 +146,7 @@ async fn manages_published_agent_tool_reads() {
version: version.version,
published_at: &timestamp("2026-03-25T12:11:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
@@ -170,6 +172,333 @@ async fn manages_published_agent_tool_reads() {
database.cleanup().await;
}
#[tokio::test]
async fn published_agent_snapshot_remains_immutable_after_draft_edit() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_agent_immutable_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_immutable_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let published_binding = AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: operation.version,
tool_name: "create_lead_immutable".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: Some("Published immutable binding".to_owned()),
enabled: true,
};
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: operation.version,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: std::slice::from_ref(&published_binding),
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
version: version.version,
published_at: &timestamp("2026-03-25T12:11:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
let before = registry
.get_published_agent_tools_by_slug("default", &agent.slug)
.await
.unwrap();
assert_eq!(before.len(), 1);
assert_eq!(before[0].tool_name, published_binding.tool_name);
registry
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
agent_version: version.version,
bindings: &[],
tool_selection_policy: &Default::default(),
expected_state: None,
})
.await
.expect_err("editing after publish must not mutate the published Agent Version");
let after = registry
.get_published_agent_tools_by_slug("default", &agent.slug)
.await
.unwrap();
assert_eq!(after, before);
database.cleanup().await;
}
#[tokio::test]
async fn stale_agent_revision_rejects_mutation() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_agent_stale_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_stale_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let binding = AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: operation.version,
tool_name: "create_lead_stale".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
};
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: operation.version,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: std::slice::from_ref(&binding),
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
version: version.version,
published_at: &timestamp("2026-03-25T12:11:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
let stale_save = registry
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
agent_version: version.version,
bindings: &[],
tool_selection_policy: &Default::default(),
expected_state: None,
})
.await;
assert!(
stale_save.is_err(),
"stale write against already-published Agent Version must be rejected"
);
database.cleanup().await;
}
#[tokio::test]
async fn published_agent_catalog_revision_is_durable_and_monotonic() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_agent_revision_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_revision_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let binding = AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: operation.version,
tool_name: "create_lead_revision".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
};
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: operation.version,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: std::slice::from_ref(&binding),
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
version: version.version,
published_at: &timestamp("2026-03-25T12:11:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
let first = registry
.get_published_agent_catalog_by_slug("default", &agent.slug)
.await
.unwrap()
.catalog_revision;
registry
.unpublish_agent(
&test_workspace_id(),
&agent.id,
&timestamp("2026-03-25T12:12:00Z"),
None,
)
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
version: version.version,
published_at: &timestamp("2026-03-25T12:13:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
let second = registry
.get_published_agent_catalog_by_slug("default", &agent.slug)
.await
.unwrap()
.catalog_revision;
assert_ne!(
first, second,
"unpublish/re-publish of the same Agent Version must invalidate stale catalog search results"
);
database.cleanup().await;
}
#[tokio::test]
async fn database_rejects_direct_published_agent_snapshot_mutation() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_agent_db_guard_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_db_guard_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let binding = AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: operation.version,
tool_name: "create_lead_db_guard".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
};
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: operation.version,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: std::slice::from_ref(&binding),
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent.id,
version: version.version,
published_at: &timestamp("2026-03-25T12:11:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
let policy_update = sqlx::query(
"update agent_versions
set tool_selection_policy_json = '{\"changed\":true}'::jsonb
where agent_id = $1 and version = 1",
)
.bind(agent.id.as_str())
.execute(registry.pool())
.await;
assert!(
policy_update.is_err(),
"database trigger must reject direct Published Agent Version mutation"
);
let binding_delete = sqlx::query(
"delete from agent_operation_bindings
where agent_id = $1 and agent_version = 1",
)
.bind(agent.id.as_str())
.execute(registry.pool())
.await;
assert!(
binding_delete.is_err(),
"database trigger must reject direct Published Agent binding deletion"
);
let pointer_rewind = sqlx::query(
"update published_agents
set catalog_revision = catalog_revision
where agent_id = $1",
)
.bind(agent.id.as_str())
.execute(registry.pool())
.await;
assert!(
pointer_rewind.is_err(),
"database trigger must reject non-increasing catalog revision"
);
database.cleanup().await;
}
#[tokio::test]
async fn manages_operation_usage_and_agent_ref_reads() {
let database = TestDatabase::new().await;
@@ -225,6 +554,7 @@ async fn manages_operation_usage_and_agent_ref_reads() {
version: version.version,
published_at: &timestamp("2026-03-25T12:11:00Z"),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
@@ -243,6 +573,27 @@ async fn manages_operation_usage_and_agent_ref_reads() {
.await,
crank_registry::InvocationHistoryWriteOutcome::Recorded
);
let stored = registry
.get_invocation_log(
&test_workspace_id(),
&crank_core::InvocationLogId::new("log_usage_ok"),
)
.await
.unwrap()
.expect("typed invocation history");
assert_eq!(stored.log.operation_version, Some(1));
assert_eq!(
stored.log.execution_stage,
Some(crank_core::ExecutionStage::Runtime)
);
assert_eq!(
stored.log.retryability,
Some(crank_core::Retryability::Never)
);
assert_eq!(
stored.log.outcome_certainty,
Some(crank_core::OutcomeCertainty::Certain)
);
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest {
@@ -286,3 +637,214 @@ async fn manages_operation_usage_and_agent_ref_reads() {
database.cleanup().await;
}
#[tokio::test]
async fn invocation_history_filters_cursor_and_usage_outcomes_are_bounded() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_history_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_history_01", AgentStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &test_agent_version(&agent.id, 1, AgentStatus::Draft),
bindings: &[],
})
.await
.unwrap();
let mut success = test_invocation_log(
"log_history_success",
&operation.id,
Some(agent.id.clone()),
crank_core::InvocationStatus::Ok,
50,
"2026-03-25T12:00:00Z",
);
success.execution_error_code = None;
success.message = "=formula must remain data".to_owned();
let mut upstream = test_invocation_log(
"log_history_upstream",
&operation.id,
Some(agent.id.clone()),
crank_core::InvocationStatus::Error,
150,
"2026-03-25T12:01:00Z",
);
upstream.execution_error_code = Some(crank_core::ExecutionErrorCode::UpstreamTimeout);
let mut client = test_invocation_log(
"log_history_client",
&operation.id,
Some(agent.id.clone()),
crank_core::InvocationStatus::Error,
250,
"2026-03-25T12:02:00Z",
);
client.execution_error_code = Some(crank_core::ExecutionErrorCode::InputSchemaInvalid);
let mut schema = test_invocation_log(
"log_history_schema",
&operation.id,
Some(agent.id.clone()),
crank_core::InvocationStatus::Error,
350,
"2026-03-25T12:03:00Z",
);
schema.execution_error_code = Some(crank_core::ExecutionErrorCode::OutputSchemaInvalid);
let mut crank = test_invocation_log(
"log_history_crank",
&operation.id,
Some(agent.id.clone()),
crank_core::InvocationStatus::Error,
450,
"2026-03-25T12:04:00Z",
);
crank.execution_error_code = Some(crank_core::ExecutionErrorCode::RuntimeInternal);
for log in [&success, &upstream, &client, &schema, &crank] {
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest { log })
.await,
crank_registry::InvocationHistoryWriteOutcome::Recorded
);
}
let first_page = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: Some(crank_core::InvocationStatus::Error),
outcome_group: None,
search_text: None,
source: None,
operation_id: None,
agent_id: None,
created_after: Some("2026-03-25T12:00:00Z"),
created_before: Some("2026-03-25T12:04:00Z"),
cursor_created_at: None,
cursor_id: None,
limit: 2,
})
.await
.unwrap();
assert_eq!(first_page.len(), 2);
assert_eq!(first_page[0].log.id.as_str(), "log_history_schema");
assert_eq!(first_page[1].log.id.as_str(), "log_history_client");
let second_page = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: Some(crank_core::InvocationStatus::Error),
outcome_group: None,
search_text: None,
source: None,
operation_id: None,
agent_id: None,
created_after: Some("2026-03-25T12:00:00Z"),
created_before: Some("2026-03-25T12:04:00Z"),
cursor_created_at: Some("2026-03-25T12:02:00Z"),
cursor_id: Some(&crank_core::InvocationLogId::new("log_history_client")),
limit: 2,
})
.await
.unwrap();
assert_eq!(second_page.len(), 1);
assert_eq!(second_page[0].log.id.as_str(), "log_history_upstream");
let upstream_only = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: Some(UsageOutcomeGroup::Upstream),
search_text: None,
source: None,
operation_id: None,
agent_id: None,
created_after: Some("2026-03-25T12:00:00Z"),
created_before: Some("2026-03-25T12:05:00Z"),
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
.unwrap();
assert_eq!(upstream_only.len(), 1);
assert_eq!(upstream_only[0].log.id.as_str(), "log_history_upstream");
let usage = registry
.list_usage_outcomes(UsageQuery {
workspace_id: &test_workspace_id(),
period: crank_core::UsagePeriod::Last24Hours,
source: None,
created_after: "2026-03-25T12:00:00Z",
created_before: "2026-03-25T12:05:00Z",
bucket: UsageBucket::Hour,
})
.await
.unwrap();
let by_group = usage
.iter()
.map(|item| (item.group, item.calls_total))
.collect::<BTreeMap<_, _>>();
assert_eq!(by_group.get(&UsageOutcomeGroup::Success), Some(&1));
assert_eq!(by_group.get(&UsageOutcomeGroup::Upstream), Some(&1));
assert_eq!(by_group.get(&UsageOutcomeGroup::Client), Some(&1));
assert_eq!(by_group.get(&UsageOutcomeGroup::Schema), Some(&1));
assert_eq!(by_group.get(&UsageOutcomeGroup::Crank), Some(&1));
let half_open = registry
.summarize_usage(UsageQuery {
workspace_id: &test_workspace_id(),
period: crank_core::UsagePeriod::Last24Hours,
source: None,
created_after: "2026-03-25T12:00:00Z",
created_before: "2026-03-25T12:04:00Z",
bucket: UsageBucket::Hour,
})
.await
.unwrap();
assert_eq!(half_open.rollup.calls_total, 4);
let retention = registry
.delete_invocation_logs_before(timestamp("2026-03-25T12:02:00Z"))
.await
.unwrap();
assert_eq!(retention.deleted_records, 2);
assert_eq!(
retention.status,
crank_registry::InvocationRetentionStatus::Completed
);
assert_eq!(retention.policy.preserved_usage_window_days, 90);
let retained = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: None,
operation_id: None,
agent_id: None,
created_after: Some("2026-03-25T12:00:00Z"),
created_before: Some("2026-03-25T12:05:00Z"),
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
.unwrap();
assert_eq!(retained.len(), 3);
assert_eq!(
retained.last().unwrap().log.id.as_str(),
"log_history_client"
);
database.cleanup().await;
}
@@ -0,0 +1,314 @@
use std::sync::Arc;
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, ApprovalRequest, ApprovalRequestId,
ApprovalRequestStatus, OperationApprovalRiskLevel, OperationId, PlatformApiKey,
PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, WorkspaceId,
};
use crank_registry::{
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest, DecideApprovalRequest,
FinishApprovalRequest,
};
use serde_json::{Value, json};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::common::{TestDatabase, test_agent, test_agent_version, test_operation};
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
fn approval_request(id: &str, payload: Value) -> ApprovalRequest {
ApprovalRequest {
id: ApprovalRequestId::new(id),
workspace_id: WorkspaceId::new("ws_default"),
agent_id: AgentId::new("agent_approval_atomic"),
operation_id: OperationId::new("operation_approval_atomic"),
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: payload,
response_payload: None,
created_at: timestamp("2026-03-25T12:01:00Z"),
expires_at: timestamp("2027-03-25T12:06:00Z"),
decided_at: None,
decided_by_key_id: None,
decision_note: None,
}
}
fn approval_key() -> PlatformApiKey {
PlatformApiKey {
id: PlatformApiKeyId::new("approval_atomic_key"),
workspace_id: WorkspaceId::new("ws_default"),
agent_id: Some(AgentId::new("agent_approval_atomic")),
key_kind: PlatformApiKeyKind::Approval,
name: "approval atomic key".to_owned(),
prefix: "crk_appr".to_owned(),
scopes: vec![PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-03-25T12:00:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
}
}
async fn seed_approval_graph(registry: &crank_registry::PostgresRegistry) {
let workspace_id = WorkspaceId::new("ws_default");
let operation = test_operation(
"operation_approval_atomic",
1,
crank_core::OperationStatus::Published,
);
registry
.create_operation(&workspace_id, &operation, None)
.await
.unwrap();
let agent = test_agent("agent_approval_atomic", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &[AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: 1,
tool_name: "dangerous_write".to_owned(),
tool_title: "Dangerous write".to_owned(),
tool_description_override: None,
enabled: true,
}],
})
.await
.unwrap();
let key = approval_key();
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &key,
secret_hash: "approval-key-secret-hash",
})
.await
.unwrap();
}
#[tokio::test]
async fn concurrent_equal_scope_creates_one_pending_approval_without_losing_raw_execution_payload()
{
let database = TestDatabase::new().await;
let registry = Arc::new(database.registry().await);
seed_approval_graph(&registry).await;
let mut tasks = Vec::new();
for index in 0..32 {
let registry = Arc::clone(&registry);
tasks.push(tokio::spawn(async move {
let mut approval = approval_request(
&format!("approval_concurrent_{index}"),
json!({
"email": "customer@example.com",
"token": "SECRET_APPROVAL_CANARY",
"_crank_confirmation_token": format!("ct_{index}")
}),
);
approval.created_at += time::Duration::milliseconds(index);
registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap()
}));
}
let mut ids = std::collections::BTreeSet::new();
for task in tasks {
ids.insert(task.await.unwrap().approval.id.as_str().to_owned());
}
assert_eq!(
ids.len(),
1,
"all callers must receive one canonical pending approval"
);
let pending = registry
.list_pending_approval_requests_for_agent(
&WorkspaceId::new("ws_default"),
&AgentId::new("agent_approval_atomic"),
)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(
pending[0].approval.request_payload["token"], "SECRET_APPROVAL_CANARY",
"registry keeps the raw payload for the eventual approved execution"
);
assert!(
pending[0].approval.request_payload["_crank_confirmation_token"]
.as_str()
.is_some_and(|value| value.starts_with("ct_")),
"only projections may redact control data; execution input remains intact"
);
let safe_preview =
crank_core::sanitize_invocation_preview(&pending[0].approval.request_payload);
let preview = safe_preview.to_string();
assert!(!preview.contains("SECRET_APPROVAL_CANARY"));
database.cleanup().await;
}
#[tokio::test]
async fn nested_business_control_like_fields_are_part_of_approval_fingerprint() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
seed_approval_graph(&registry).await;
let first = approval_request(
"approval_nested_control_a",
json!({
"line": {
"_crank_approval_id": "business-a"
},
"_crank_confirmation_token": "transport-a"
}),
);
registry
.create_approval_request(CreateApprovalRequest { approval: &first })
.await
.unwrap();
let second = approval_request(
"approval_nested_control_b",
json!({
"line": {
"_crank_approval_id": "business-b"
},
"_crank_confirmation_token": "transport-b"
}),
);
let created_second = registry
.create_approval_request(CreateApprovalRequest { approval: &second })
.await
.unwrap();
assert_eq!(created_second.approval.id, second.id);
let pending = registry
.list_pending_approval_requests_for_agent(
&WorkspaceId::new("ws_default"),
&AgentId::new("agent_approval_atomic"),
)
.await
.unwrap();
assert_eq!(pending.len(), 2);
database.cleanup().await;
}
#[tokio::test]
async fn decision_claim_finish_and_replay_are_single_winner_transitions() {
let database = TestDatabase::new().await;
let registry = Arc::new(database.registry().await);
seed_approval_graph(&registry).await;
let approval = approval_request(
"approval_single_winner",
json!({"email":"lead@example.com"}),
);
let created = registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap();
assert_eq!(created.approval.status, ApprovalRequestStatus::Pending);
let mut decisions = Vec::new();
for index in 0..16 {
let registry = Arc::clone(&registry);
let operation_id = approval.operation_id.clone();
let operation_version = approval.operation_version;
let request_payload = approval.request_payload.clone();
decisions.push(tokio::spawn(async move {
let key_id = PlatformApiKeyId::new("approval_atomic_key");
registry
.decide_approval_request(DecideApprovalRequest {
workspace_id: &WorkspaceId::new("ws_default"),
agent_id: &AgentId::new("agent_approval_atomic"),
approval_id: &ApprovalRequestId::new("approval_single_winner"),
operation_id: &operation_id,
operation_version,
request_payload: &request_payload,
status: if index % 2 == 0 {
ApprovalRequestStatus::Approved
} else {
ApprovalRequestStatus::Denied
},
decided_at: timestamp("2026-03-25T12:02:00Z"),
decided_by_key_id: Some(&key_id),
response_payload: Some(json!({ "decision": index })),
decision_note: Some("race decision"),
})
.await
.unwrap()
}));
}
let mut winners = 0;
for decision in decisions {
if decision.await.unwrap().is_some() {
winners += 1;
}
}
assert_eq!(winners, 1);
let mut claims = Vec::new();
for _ in 0..16 {
let registry = Arc::clone(&registry);
claims.push(tokio::spawn(async move {
registry
.claim_approval_request(
&WorkspaceId::new("ws_default"),
&AgentId::new("agent_approval_atomic"),
&ApprovalRequestId::new("approval_single_winner"),
timestamp("2026-03-25T12:02:01Z"),
)
.await
.unwrap()
}));
}
let mut claim_winners = 0;
for claim in claims {
if claim.await.unwrap().is_some() {
claim_winners += 1;
}
}
assert_eq!(claim_winners, 1);
let first_finish = registry
.finish_approval_request(FinishApprovalRequest {
workspace_id: &WorkspaceId::new("ws_default"),
agent_id: &AgentId::new("agent_approval_atomic"),
approval_id: &ApprovalRequestId::new("approval_single_winner"),
status: ApprovalRequestStatus::Completed,
response_payload: Some(json!({"ok":true})),
decision_note: None,
})
.await
.unwrap();
assert!(first_finish.is_some());
let replay_finish = registry
.finish_approval_request(FinishApprovalRequest {
workspace_id: &WorkspaceId::new("ws_default"),
agent_id: &AgentId::new("agent_approval_atomic"),
approval_id: &ApprovalRequestId::new("approval_single_winner"),
status: ApprovalRequestStatus::Completed,
response_payload: Some(json!({"ok":"replay"})),
decision_note: None,
})
.await
.unwrap();
assert!(replay_finish.is_none());
database.cleanup().await;
}
@@ -21,11 +21,12 @@ use crank_registry::{
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId,
YamlImportJobStatus,
};
fn test_workspace_id() -> WorkspaceId {
pub(super) fn test_workspace_id() -> WorkspaceId {
WorkspaceId::new("ws_default")
}
@@ -209,7 +210,9 @@ pub(super) fn test_invocation_log(
id: crank_core::InvocationLogId::new(id),
workspace_id: test_workspace_id(),
agent_id,
platform_api_key_id: None,
operation_id: operation_id.clone(),
operation_version: Some(1),
source: crank_core::InvocationSource::AgentToolCall,
level: crank_core::InvocationLevel::Info,
status,
@@ -220,6 +223,11 @@ pub(super) fn test_invocation_log(
status_code: Some(200),
duration_ms,
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Runtime),
execution_error_code: (status == crank_core::InvocationStatus::Error)
.then_some(crank_core::ExecutionErrorCode::RuntimeInternal),
retryability: Some(crank_core::Retryability::Never),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
request_preview: json!({"input":"value"}),
response_preview: json!({"ok":true}),
created_at: timestamp(created_at),
@@ -268,6 +276,18 @@ impl TestDatabase {
PostgresRegistry::connect(&database_url).await.unwrap()
}
pub(super) async fn raw_pool(&self) -> PgPool {
let database_url = format!(
"{}?options=-csearch_path%3D{}",
self.database_url, self.schema
);
PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.unwrap()
}
pub(super) async fn cleanup(&self) {
self.admin_pool
.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
@@ -0,0 +1,296 @@
use super::common::*;
use std::time::{Duration, Instant};
use crank_core::{
PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, Secret, SecretId, SecretKind, SecretStatus, Workspace, WorkspaceId,
WorkspaceStatus,
};
use serde_json::json;
use sqlx::{PgPool, Row};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crank_registry::{
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateWorkspaceRequest,
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError,
};
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
fn workspace() -> Workspace {
Workspace {
id: WorkspaceId::new("ws_credential_touch"),
slug: "credential-touch".to_owned(),
display_name: "Credential Touch".to_owned(),
status: WorkspaceStatus::Active,
settings: json!({}),
created_at: timestamp("2026-08-24T12:00:00Z"),
updated_at: timestamp("2026-08-24T12:00:00Z"),
}
}
async fn wait_for_row_lock(inspector: &PgPool, holder_pid: i32, query_marker: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let blocked: i64 = sqlx::query_scalar(
"select count(*)::bigint
from pg_stat_activity activity
where $1 = any(pg_blocking_pids(activity.pid))
and activity.wait_event_type = 'Lock'
and activity.query like '%' || $2 || '%'",
)
.bind(holder_pid)
.bind(query_marker)
.fetch_one(inspector)
.await
.unwrap();
if blocked > 0 {
return;
}
if Instant::now() >= deadline {
let diagnostics = sqlx::query(
"select pid, state, wait_event_type, wait_event, query
from pg_stat_activity
where $1 = any(pg_blocking_pids(pid))
or pid = $1",
)
.bind(holder_pid)
.fetch_all(inspector)
.await
.unwrap()
.into_iter()
.map(|row| {
format!(
"pid={}, state={}, wait_event_type={:?}, wait_event={:?}, query={}",
row.get::<i32, _>("pid"),
row.get::<String, _>("state"),
row.get::<Option<String>, _>("wait_event_type"),
row.get::<Option<String>, _>("wait_event"),
row.get::<String, _>("query"),
)
})
.collect::<Vec<_>>();
panic!(
"touch query did not block on backend {holder_pid} within 5 seconds; \
pg_stat_activity: {diagnostics:#?}"
);
}
tokio::task::yield_now().await;
}
}
#[tokio::test]
async fn touch_platform_api_key_rejects_revoke_that_won_row_lock() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace = workspace();
let key = PlatformApiKey {
id: PlatformApiKeyId::new("key_touch_race"),
workspace_id: workspace.id.clone(),
agent_id: None,
key_kind: PlatformApiKeyKind::McpClient,
name: "Race key".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-08-24T12:00:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_workspace(CreateWorkspaceRequest {
workspace: &workspace,
})
.await
.unwrap();
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &key,
secret_hash: "touch-race-secret-hash",
})
.await
.unwrap();
let lock_pool = database.raw_pool().await;
let inspector = database.raw_pool().await;
let mut lock_tx = lock_pool.begin().await.unwrap();
let holder_pid: i32 = sqlx::query_scalar("select pg_backend_pid()")
.fetch_one(&mut *lock_tx)
.await
.unwrap();
sqlx::query(
"select id
from platform_api_keys
where workspace_id = $1 and id = $2
for update",
)
.bind(workspace.id.as_str())
.bind(key.id.as_str())
.execute(&mut *lock_tx)
.await
.unwrap();
let touch_registry = registry.clone();
let touch_workspace_id = workspace.id.clone();
let touch_key_id = key.id.clone();
let touch = tokio::spawn(async move {
touch_registry
.touch_platform_api_key(
&touch_workspace_id,
&touch_key_id,
&timestamp("2026-08-24T12:05:00Z"),
)
.await
});
wait_for_row_lock(&inspector, holder_pid, "from platform_api_keys").await;
sqlx::query(
"update platform_api_keys
set status = 'revoked'
where workspace_id = $1 and id = $2",
)
.bind(workspace.id.as_str())
.bind(key.id.as_str())
.execute(&mut *lock_tx)
.await
.unwrap();
lock_tx.commit().await.unwrap();
let touch_result = touch.await.unwrap();
assert!(matches!(
touch_result,
Err(RegistryError::PlatformApiKeyInactive { key_id }) if key_id == key.id.as_str()
));
let last_used_at: Option<OffsetDateTime> = sqlx::query_scalar(
"select last_used_at
from platform_api_keys
where workspace_id = $1 and id = $2",
)
.bind(workspace.id.as_str())
.bind(key.id.as_str())
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(last_used_at, None);
database.cleanup().await;
}
#[tokio::test]
async fn touch_secret_rejects_disable_that_won_row_lock() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace = workspace();
let secret = Secret {
id: SecretId::new("secret_touch_race"),
workspace_id: workspace.id.clone(),
name: "Race secret".to_owned(),
kind: SecretKind::Token,
status: SecretStatus::Active,
current_version: 1,
created_at: timestamp("2026-08-24T12:00:00Z"),
updated_at: timestamp("2026-08-24T12:00:00Z"),
last_used_at: None,
};
registry
.create_workspace(CreateWorkspaceRequest {
workspace: &workspace,
})
.await
.unwrap();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &timestamp("2026-08-24T12:00:00Z"),
})
.await
.unwrap();
registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: "touch-race-ciphertext",
key_version: "test-key-v1",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap();
let lock_pool = database.raw_pool().await;
let inspector = database.raw_pool().await;
let mut lock_tx = lock_pool.begin().await.unwrap();
let holder_pid: i32 = sqlx::query_scalar("select pg_backend_pid()")
.fetch_one(&mut *lock_tx)
.await
.unwrap();
sqlx::query(
"select id
from secrets
where workspace_id = $1 and id = $2
for update",
)
.bind(workspace.id.as_str())
.bind(secret.id.as_str())
.execute(&mut *lock_tx)
.await
.unwrap();
let touch_registry = registry.clone();
let touch_workspace_id = workspace.id.clone();
let touch_secret_id = secret.id.clone();
let touch = tokio::spawn(async move {
touch_registry
.touch_secret(
&touch_workspace_id,
&touch_secret_id,
&timestamp("2026-08-24T12:05:00Z"),
)
.await
});
wait_for_row_lock(&inspector, holder_pid, "from secrets").await;
sqlx::query(
"update secrets
set status = 'disabled'
where workspace_id = $1 and id = $2",
)
.bind(workspace.id.as_str())
.bind(secret.id.as_str())
.execute(&mut *lock_tx)
.await
.unwrap();
lock_tx.commit().await.unwrap();
let touch_result = touch.await.unwrap();
assert!(matches!(
touch_result,
Err(RegistryError::SecretInactive { secret_id }) if secret_id == secret.id.as_str()
));
let last_used_at: Option<OffsetDateTime> = sqlx::query_scalar(
"select last_used_at
from secrets
where workspace_id = $1 and id = $2",
)
.bind(workspace.id.as_str())
.bind(secret.id.as_str())
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(last_used_at, None);
database.cleanup().await;
}
@@ -0,0 +1,526 @@
use crank_core::{Secret, SecretId, SecretKind, SecretStatus, WorkspaceId};
use crank_registry::{
CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError,
RotateSecretRequest,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::common::TestDatabase;
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
#[tokio::test]
async fn registers_and_verifies_active_master_key_identity() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let registered = registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
let verified = registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
assert_eq!(registered.epoch, 1);
assert_eq!(registered.status, "active");
assert_eq!(registered, verified);
database.cleanup().await;
}
#[tokio::test]
async fn rejects_different_active_master_key_identity() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
let error = registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap_err();
assert!(matches!(
error,
RegistryError::MasterKeyIdentityMismatch { epoch: 1 }
));
assert!(!error.to_string().contains("fedcba"));
assert!(!error.to_string().contains("012345"));
database.cleanup().await;
}
#[tokio::test]
async fn master_key_rotation_is_resumable_and_promotes_atomically() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let later = timestamp("2026-08-21T00:01:00Z");
let current_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: current_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "secret_a", "cipher-a", &now).await;
insert_secret_version(&registry, "secret_b", "cipher-b", &now).await;
let snapshot = registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap();
assert_eq!(snapshot.len(), 2);
let preflight_status = registry.master_key_rotation_status().await.unwrap();
assert_eq!(preflight_status.active_identity.unwrap().epoch, 1);
assert!(preflight_status.rotations.is_empty());
assert!(
registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap()
.iter()
.all(|version| version.target_ciphertext.is_none())
);
let rotation = registry
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &now)
.await
.unwrap();
assert_eq!(rotation.state, "running");
assert_eq!(rotation.total_secret_versions, 2);
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_a"),
1,
1,
"target-cipher-a",
"v2",
2,
&later,
)
.await
.unwrap();
let resumed = registry
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &later)
.await
.unwrap();
assert_eq!(resumed.processed_secret_versions, 1);
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_b"),
1,
1,
"target-cipher-b",
"v2",
2,
&later,
)
.await
.unwrap();
let ready = registry
.finish_master_key_rotation_batches(&rotation.id, &later)
.await
.unwrap();
assert_eq!(ready.state, "verifying");
registry
.verify_master_key_rotation(&rotation.id, 2, &later)
.await
.unwrap();
registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: 2,
fingerprint: target_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &later,
},
&later,
)
.await
.unwrap();
let status = registry.master_key_rotation_status().await.unwrap();
let active = status.active_identity.unwrap();
assert_eq!(active.epoch, 2);
assert_eq!(active.fingerprint, target_fingerprint);
assert_eq!(status.rotations[0].state, "promoted");
assert!(
registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap()
.is_empty()
);
let promoted = registry
.list_secret_versions_for_master_key_epoch(2)
.await
.unwrap();
assert_eq!(promoted.len(), 2);
assert!(
promoted
.iter()
.all(|version| version.target_ciphertext.is_none())
);
database.cleanup().await;
}
#[tokio::test]
async fn abort_preserves_current_epoch_and_clears_target_ciphertext() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "secret_abort", "cipher-a", &now).await;
let rotation = registry
.begin_master_key_rotation(
1,
2,
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
None,
&now,
)
.await
.unwrap();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_abort"),
1,
1,
"target-cipher-a",
"v2",
2,
&now,
)
.await
.unwrap();
registry
.abort_master_key_rotation(&rotation.id, &now)
.await
.unwrap();
let status = registry.master_key_rotation_status().await.unwrap();
assert_eq!(status.active_identity.unwrap().epoch, 1);
assert_eq!(status.rotations[0].state, "aborted");
let versions = registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap();
assert_eq!(versions.len(), 1);
assert!(versions[0].target_ciphertext.is_none());
database.cleanup().await;
}
#[tokio::test]
async fn aborted_rotation_can_be_rerun_for_same_source_epoch() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "secret_retry", "cipher-a", &now).await;
let rotation = registry
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
.await
.unwrap();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_retry"),
1,
1,
"target-cipher-a",
"v2",
2,
&now,
)
.await
.unwrap();
registry
.abort_master_key_rotation(&rotation.id, &now)
.await
.unwrap();
let rerun = registry
.begin_master_key_rotation(1, 2, target_fingerprint, Some("second-attempt"), &now)
.await
.unwrap();
assert_eq!(rerun.id, rotation.id);
assert_eq!(rerun.state, "running");
assert_eq!(rerun.processed_secret_versions, 0);
assert_eq!(rerun.backup_ref.as_deref(), Some("second-attempt"));
assert!(
registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap()
.iter()
.all(|version| version.target_ciphertext.is_none())
);
database.cleanup().await;
}
#[tokio::test]
async fn active_rotation_rejects_new_secret_writes() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "existing_secret", "cipher-a", &now).await;
registry
.begin_master_key_rotation(
1,
2,
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
None,
&now,
)
.await
.unwrap();
let create_error = registry
.create_secret(CreateSecretRequest {
secret: &test_secret("new_secret", &now),
ciphertext: "cipher-new",
key_version: "v2",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
create_error,
RegistryError::MasterKeyRotationInProgress
));
let rotate_error = registry
.rotate_secret(RotateSecretRequest {
workspace_id: &WorkspaceId::new("ws_default"),
secret_id: &SecretId::new("existing_secret"),
ciphertext: "cipher-rotated",
key_version: "v2",
master_key_epoch: 1,
created_at: &now,
updated_at: &now,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
rotate_error,
RegistryError::MasterKeyRotationInProgress
));
let delete_error = registry
.delete_secret(
&WorkspaceId::new("ws_default"),
&SecretId::new("existing_secret"),
)
.await
.unwrap_err();
assert!(matches!(
delete_error,
RegistryError::MasterKeyRotationInProgress
));
database.cleanup().await;
}
#[tokio::test]
async fn stale_source_epoch_writer_is_rejected_after_promotion() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let source_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: source_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "promoted_secret", "cipher-a", &now).await;
let rotation = registry
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
.await
.unwrap();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("promoted_secret"),
1,
1,
"target-cipher-a",
"v2",
2,
&now,
)
.await
.unwrap();
registry
.finish_master_key_rotation_batches(&rotation.id, &now)
.await
.unwrap();
registry
.verify_master_key_rotation(&rotation.id, 1, &now)
.await
.unwrap();
registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: 2,
fingerprint: target_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
},
&now,
)
.await
.unwrap();
let create_error = registry
.create_secret(CreateSecretRequest {
secret: &test_secret("stale_new_secret", &now),
ciphertext: "cipher-new",
key_version: "v2",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
create_error,
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
));
let rotate_error = registry
.rotate_secret(RotateSecretRequest {
workspace_id: &WorkspaceId::new("ws_default"),
secret_id: &SecretId::new("promoted_secret"),
ciphertext: "cipher-rotated",
key_version: "v2",
master_key_epoch: 1,
created_at: &now,
updated_at: &now,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
rotate_error,
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
));
database.cleanup().await;
}
async fn insert_secret_version(
registry: &crank_registry::PostgresRegistry,
id: &str,
ciphertext: &str,
now: &OffsetDateTime,
) {
registry
.create_secret(CreateSecretRequest {
secret: &test_secret(id, now),
ciphertext,
key_version: "v2",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap();
}
fn test_secret(id: &str, now: &OffsetDateTime) -> Secret {
Secret {
id: SecretId::new(id),
workspace_id: WorkspaceId::new("ws_default"),
name: id.to_owned(),
kind: SecretKind::Token,
status: SecretStatus::Active,
current_version: 1,
created_at: *now,
updated_at: *now,
last_used_at: None,
}
}
@@ -1,47 +1,62 @@
use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
use sqlx::Row;
mod rollback;
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn legacy_preservation_row_expr(table: &str) -> &'static str {
match table {
"invocation_logs" => {
"to_jsonb(t) - array['trace_id','operation_version','execution_stage','execution_error_code','retryability','outcome_certainty','platform_api_key_id']"
}
"operation_versions" => {
"to_jsonb(t) - array['name','display_name','category','protocol','security_level','snapshot_provenance','snapshot_observed_at','published_at','published_by']"
}
"approval_requests" => "to_jsonb(t) - array['request_id','trace_id']",
"agents" => "to_jsonb(t) - array['catalog_revision']",
_ => "to_jsonb(t)",
}
}
#[tokio::test]
async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
let database_url = crank_test_support::postgres_schema_url("test_core_migration").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
let (first, second) = tokio::join!(
MigrationAuthority::apply(&pool),
MigrationAuthority::apply(&pool),
);
first.expect("first controlled runner must apply the sequence");
second.expect("second controlled runner must observe the applied sequence");
let first = PostgresRegistry::connect(&database_url)
.await
.expect("service startup must verify the migrated schema");
let rows =
sqlx::query("select version, name, checksum from __crank_migrations order by version")
.fetch_all(first.pool())
.await
.expect("migration ledger must be readable");
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].get::<i64, _>("version"), 1);
assert_eq!(rows[0].get::<String, _>("name"), "community-baseline-v1");
assert_eq!(
rows[0].get::<String, _>("checksum"),
"crank-community-baseline-v1"
);
assert_eq!(rows[1].get::<i64, _>("version"), 2);
assert_eq!(rows[1].get::<String, _>("name"), "legacy-consolidation-v2");
assert_eq!(rows[1].get::<String, _>("checksum").len(), 64);
assert_eq!(rows[2].get::<i64, _>("version"), 3);
assert_eq!(
rows[2].get::<String, _>("name"),
"request-trace-identity-v3"
);
assert_eq!(rows[2].get::<String, _>("checksum").len(), 64);
let expected = [
(1, "community-baseline-v1"),
(2, "legacy-consolidation-v2"),
(3, "request-trace-identity-v3"),
(4, "operation-lifecycle-v4"),
(5, "execution-outcome-v5"),
(6, "platform-key-name-reuse-v6"),
(7, "master-key-identity-v7"),
(8, "admin-auth-lifecycle-v8"),
(9, "agent-catalog-lifecycle-v9"),
(10, "approval-side-effects-v10"),
(11, "onboarding-product-events-v11"),
];
assert_eq!(rows.len(), expected.len());
for (row, (version, name)) in rows.iter().zip(expected) {
assert_eq!(row.get::<i64, _>("version"), version);
assert_eq!(row.get::<String, _>("name"), name);
let checksum = row.get::<String, _>("checksum");
assert!(checksum == "crank-community-baseline-v1" || checksum.len() == 64);
}
let approval_columns = sqlx::query(
"select column_name
from information_schema.columns
@@ -53,22 +68,41 @@ async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
.await
.expect("approval schema must be readable");
assert_eq!(approval_columns.len(), 3);
let onboarding_relations = sqlx::query(
"select table_name
from information_schema.tables
where table_schema = current_schema()
and table_name in ('product_events', 'product_event_daily_rollups', 'onboarding_selections')
order by table_name",
)
.fetch_all(first.pool())
.await
.expect("V11 onboarding relations must be readable");
assert_eq!(onboarding_relations.len(), 3);
let key_scope_column: bool = sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'invocation_logs'
and column_name = 'platform_api_key_id'
)",
)
.fetch_one(first.pool())
.await
.expect("V11 key provenance column must be readable");
assert!(key_scope_column);
assert_eq!(
MigrationAuthority::preflight(first.pool()).await.unwrap(),
MigrationPreflight::Current { version: 3 }
MigrationPreflight::Current { version: 11 }
);
}
#[tokio::test]
async fn service_connect_is_read_only_on_fresh_database() {
let database_url = crank_test_support::postgres_schema_url("test_read_only_startup").await;
let error = PostgresRegistry::connect(&database_url)
.await
.expect_err("fresh schema must require the controlled migration command");
assert!(error.to_string().contains("schema_missing"));
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
let ledger = sqlx::query("select to_regclass('__crank_migrations')::text as name")
.fetch_one(&pool)
@@ -81,7 +115,6 @@ async fn service_connect_is_read_only_on_fresh_database() {
"startup compatibility check must not create DDL"
);
}
#[tokio::test]
async fn changed_checksum_fails_closed_without_repair() {
let database_url = crank_test_support::postgres_schema_url("test_changed_checksum").await;
@@ -91,7 +124,6 @@ async fn changed_checksum_fails_closed_without_repair() {
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool)
.await
.expect_err("published checksum mismatch must fail closed");
@@ -106,12 +138,12 @@ async fn changed_checksum_fails_closed_without_repair() {
"authority must not rewrite corrupt history"
);
}
#[tokio::test]
async fn legacy_core_baseline_is_consolidated_without_data_loss() {
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
remove_v4_schema(&pool).await;
sqlx::query(
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
values ('ws_preserved', 'preserved', 'Preserved', 'active', '{}'::jsonb, now(), now())",
@@ -164,11 +196,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
];
let mut before = Vec::new();
for table in tables {
let row = if table == "invocation_logs" {
"to_jsonb(t) - 'trace_id'"
} else {
"to_jsonb(t)"
};
let row = legacy_preservation_row_expr(table);
let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t");
before.push(
sqlx::query_scalar::<_, Option<String>>(sqlx::AssertSqlSafe(sql))
@@ -184,12 +212,11 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 1,
target: 3,
target: 11,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
@@ -201,11 +228,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
assert_eq!(display_name, "Preserved");
let mut after = Vec::new();
for table in tables {
let row = if table == "invocation_logs" {
"to_jsonb(t) - 'trace_id'"
} else {
"to_jsonb(t)"
};
let row = legacy_preservation_row_expr(table);
let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t");
after.push(
sqlx::query_scalar::<_, Option<String>>(sqlx::AssertSqlSafe(sql))
@@ -216,7 +239,6 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
}
assert_eq!(before, after, "brownfield rows must remain byte-equivalent");
}
#[tokio::test]
async fn legacy_mcp_sessions_survive_consolidation() {
let database_url = crank_test_support::postgres_schema_url("test_legacy_mcp").await;
@@ -232,12 +254,12 @@ async fn legacy_mcp_sessions_survive_consolidation() {
.execute(&pool)
.await
.unwrap();
remove_v4_schema(&pool).await;
remove_v3_schema(&pool).await;
sqlx::query("drop table __crank_migrations, __crank_migration_legacy_audit")
.execute(&pool)
.await
.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
let count = sqlx::query("select count(*)::bigint as count from mcp_transport_sessions where id = 'session_preserved'")
.fetch_one(&pool)
@@ -246,7 +268,6 @@ async fn legacy_mcp_sessions_survive_consolidation() {
.get::<i64, _>("count");
assert_eq!(count, 1);
}
#[tokio::test]
async fn repeated_apply_does_not_rewrite_audit_timestamps() {
let database_url = crank_test_support::postgres_schema_url("test_repeat_apply").await;
@@ -259,7 +280,6 @@ async fn repeated_apply_does_not_rewrite_audit_timestamps() {
.into_iter()
.map(|row| row.get::<time::OffsetDateTime, _>("applied_at"))
.collect::<Vec<_>>();
MigrationAuthority::apply(&pool).await.unwrap();
let after = sqlx::query("select applied_at from __crank_migrations order by version")
.fetch_all(&pool)
@@ -270,13 +290,12 @@ async fn repeated_apply_does_not_rewrite_audit_timestamps() {
.collect::<Vec<_>>();
assert_eq!(before, after);
}
#[tokio::test]
async fn future_sequence_fails_closed() {
let database_url = crank_test_support::postgres_schema_url("test_future_sequence").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::query("update __crank_migrations set version = 4 where version = 3")
sqlx::query("update __crank_migrations set version = 12 where version = 11")
.execute(&pool)
.await
.unwrap();
@@ -288,29 +307,28 @@ async fn future_sequence_fails_closed() {
"future_version"
);
}
#[tokio::test]
async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
let database_url = crank_test_support::postgres_schema_url("test_v2_to_v3_identity").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
remove_v4_schema(&pool).await;
sqlx::query("delete from __crank_migrations where version = 3")
.execute(&pool)
.await
.unwrap();
remove_v3_schema(&pool).await;
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 2,
target: 3,
target: 11,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 3 }
MigrationPreflight::Current { version: 11 }
);
let trace_column: bool = sqlx::query_scalar(
"select exists (
@@ -325,12 +343,183 @@ async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
.unwrap();
assert!(trace_column);
}
#[tokio::test]
async fn healthy_v3_upgrades_to_v4_with_honest_legacy_snapshot_provenance() {
let database_url = crank_test_support::postgres_schema_url("test_v3_to_v4_lifecycle").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
remove_v4_schema(&pool).await;
sqlx::raw_sql(
"insert into operations
(id, workspace_id, name, display_name, category, protocol, security_level, status,
current_draft_version, latest_published_version, created_at, updated_at, published_at)
values ('op_v3_cutover', 'ws_default', 'v3_cutover', 'V3 Cutover', 'general', 'rest',
'standard', 'published', 1, 1, now(), now(), now());
insert into operation_versions
(operation_id, version, status, target_json, input_schema_json, output_schema_json,
input_mapping_json, output_mapping_json, execution_config_json,
tool_description_json, created_at)
values ('op_v3_cutover', 1, 'published', '{}'::jsonb, '{}'::jsonb, '{}'::jsonb,
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, now());
insert into published_operations(operation_id, version, published_at, published_by)
values ('op_v3_cutover', 1, now(), 'legacy-owner');",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 3,
target: 11,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let row = sqlx::query(
"select name, display_name, snapshot_provenance, snapshot_observed_at,
published_at, published_by
from operation_versions where operation_id = 'op_v3_cutover' and version = 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.get::<String, _>("name"), "v3_cutover");
assert_eq!(row.get::<String, _>("display_name"), "V3 Cutover");
assert_eq!(
row.get::<String, _>("snapshot_provenance"),
"legacy_observed"
);
assert!(
row.try_get::<time::OffsetDateTime, _>("snapshot_observed_at")
.is_ok()
);
assert!(
row.try_get::<time::OffsetDateTime, _>("published_at")
.is_ok()
);
assert_eq!(
row.try_get::<Option<String>, _>("published_by").unwrap(),
Some("legacy-owner".to_owned())
);
}
#[tokio::test]
async fn healthy_v4_upgrades_to_v5_without_fabricating_legacy_outcomes() {
let database_url = crank_test_support::postgres_schema_url("test_v4_to_v5_outcome").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
remove_v5_schema(&pool).await;
sqlx::query(
"insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_v4_history', 'ws_default', 'v4_history', 'V4 History', 'rest',
'draft', now(), now())",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"insert into invocation_logs
(id, workspace_id, operation_id, source, level, status, tool_name, message,
duration_ms, request_preview_json, response_preview_json, created_at)
values ('legacy_v4_log', 'ws_default', 'op_v4_history', 'admin', 'info',
'success', 'legacy', 'safe', 1, '{}'::jsonb, '{}'::jsonb, now())",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 4,
target: 11,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let row = sqlx::query(
"select operation_version, execution_stage, execution_error_code,
retryability, outcome_certainty
from invocation_logs where id = 'legacy_v4_log'",
)
.fetch_one(&pool)
.await
.unwrap();
for column in [
"operation_version",
"execution_stage",
"execution_error_code",
"retryability",
"outcome_certainty",
] {
assert!(
row.try_get::<Option<String>, _>(column)
.is_ok_and(|value| value.is_none())
|| row
.try_get::<Option<i32>, _>(column)
.is_ok_and(|value| value.is_none())
);
}
}
#[tokio::test]
async fn failed_operation_lifecycle_migration_rolls_back_schema_and_ledger() {
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
let database_url =
crank_test_support::postgres_schema_url("test_operation_lifecycle_rollback").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
remove_v4_schema(&pool).await;
let schema: String = sqlx::query_scalar("select current_schema()")
.fetch_one(&pool)
.await
.unwrap();
let failure_trigger = format!(
"create function reject_story17_v4() returns event_trigger language plpgsql as $$
begin
if current_schema() = '{schema}' and current_query() like '%operation_versions_immutable_guard%' then
raise exception 'injected v4 ddl failure';
end if;
end $$;
create event trigger reject_story17_v4 on ddl_command_start
execute function reject_story17_v4();"
);
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
sqlx::raw_sql(
"drop event trigger reject_story17_v4;
drop function reject_story17_v4();",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(error.code(), "apply_failed");
assert_eq!(error.version(), Some(4));
let lifecycle_column: bool = sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'operation_versions'
and column_name = 'snapshot_provenance'
)",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!lifecycle_column);
let ledger_v4: i64 =
sqlx::query_scalar("select count(*) from __crank_migrations where version = 4")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(ledger_v4, 0);
}
#[tokio::test]
async fn v2_with_partial_v3_objects_fails_before_apply() {
let database_url = crank_test_support::postgres_schema_url("test_v2_partial_v3").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
remove_v4_schema(&pool).await;
sqlx::query("delete from __crank_migrations where version = 3")
.execute(&pool)
.await
@@ -343,12 +532,10 @@ async fn v2_with_partial_v3_objects_fails_before_apply() {
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.schema");
}
#[tokio::test]
async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
let database_url = crank_test_support::postgres_schema_url("test_v3_named_drift").await;
@@ -369,7 +556,6 @@ async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
.code(),
"partial_sequence"
);
sqlx::raw_sql(
"alter table invocation_logs drop constraint invocation_logs_trace_id_format_check;
alter table invocation_logs add constraint invocation_logs_trace_id_format_check
@@ -393,7 +579,78 @@ async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
"partial_sequence"
);
}
#[tokio::test]
async fn v5_rejects_same_named_execution_code_constraint_with_wrong_definition() {
let database_url =
crank_test_support::postgres_schema_url("test_v5_execution_code_named_drift").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::raw_sql(
"alter table invocation_logs
drop constraint invocation_logs_execution_error_code_check;
alter table invocation_logs
add constraint invocation_logs_execution_error_code_check check (true) not valid;",
)
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.schema");
}
#[tokio::test]
async fn v11_rejects_same_named_product_event_contract_drift() {
let database_url =
crank_test_support::postgres_schema_url("test_v11_product_event_named_drift").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::raw_sql(
"alter table product_events drop constraint product_events_name_check;
alter table product_events add constraint product_events_name_check check (true) not valid;",
)
.execute(&pool)
.await
.unwrap();
let constraint_error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(constraint_error.code(), "partial_sequence");
assert_eq!(constraint_error.stage(), "preflight.schema");
sqlx::raw_sql(
"alter table product_events drop constraint product_events_name_check;
alter table product_events add constraint product_events_name_check check (event_name in (
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed',
'onboarding_dismissed', 'onboarding_abandoned', 'onboarding_completed'
));
drop index product_events_workspace_occurred_idx;
create index product_events_workspace_occurred_idx
on product_events(occurred_at, workspace_id, id);",
)
.execute(&pool)
.await
.unwrap();
let index_error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(index_error.code(), "partial_sequence");
assert_eq!(index_error.stage(), "preflight.schema");
sqlx::raw_sql(
"drop index product_events_workspace_occurred_idx;
create index product_events_workspace_occurred_idx
on product_events(workspace_id, occurred_at, id);
create or replace function crank_reject_product_event_mutation()
returns trigger language plpgsql as $$
begin
raise exception 'ProductEvent is append-only' using errcode = '23514';
end;
$$;",
)
.execute(&pool)
.await
.unwrap();
let function_error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(function_error.code(), "partial_sequence");
assert_eq!(function_error.stage(), "preflight.schema");
}
#[tokio::test]
async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
let database_url = crank_test_support::postgres_schema_url("test_v3_legacy_request_id").await;
@@ -419,19 +676,18 @@ async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
.execute(&pool)
.await
.unwrap();
remove_v4_schema(&pool).await;
sqlx::query("delete from __crank_migrations where version = 3")
.execute(&pool)
.await
.unwrap();
remove_v3_schema(&pool).await;
MigrationAuthority::apply(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 3 }
MigrationPreflight::Current { version: 11 }
);
}
async fn remove_v3_schema(pool: &sqlx::PgPool) {
sqlx::raw_sql(
"drop index if exists invocation_logs_workspace_request_id_idx;
@@ -441,7 +697,147 @@ async fn remove_v3_schema(pool: &sqlx::PgPool) {
.await
.unwrap();
}
async fn remove_v4_schema(pool: &sqlx::PgPool) {
remove_v5_schema(pool).await;
sqlx::raw_sql(
"drop trigger if exists operation_versions_immutable_guard on operation_versions;
drop function if exists crank_guard_operation_version_immutable();
drop trigger if exists published_operations_monotonic_guard on published_operations;
drop function if exists crank_guard_published_operation_pointer();
drop trigger if exists operations_latest_pointer_monotonic_guard on operations;
drop function if exists crank_guard_operation_latest_pointer();
alter table operation_versions
drop constraint if exists operation_versions_snapshot_provenance_check,
drop column if exists name,
drop column if exists display_name,
drop column if exists category,
drop column if exists protocol,
drop column if exists security_level,
drop column if exists snapshot_provenance,
drop column if exists snapshot_observed_at,
drop column if exists published_at,
drop column if exists published_by;
delete from __crank_migrations where version = 4;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v5_schema(pool: &sqlx::PgPool) {
remove_v6_schema(pool).await;
sqlx::raw_sql(
"drop index if exists invocation_logs_workspace_operation_version_idx;
alter table invocation_logs
drop column if exists operation_version,
drop column if exists execution_stage,
drop column if exists execution_error_code,
drop column if exists retryability,
drop column if exists outcome_certainty;
delete from __crank_migrations where version = 5;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v6_schema(pool: &sqlx::PgPool) {
remove_v7_schema(pool).await;
sqlx::raw_sql(
"drop index if exists platform_api_keys_workspace_name_active_idx;
create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name);
delete from __crank_migrations where version = 6;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v7_schema(pool: &sqlx::PgPool) {
remove_v8_schema(pool).await;
sqlx::raw_sql(
"alter table secret_versions drop constraint if exists secret_versions_target_all_or_none_check,
drop constraint if exists secret_versions_target_epoch_check,
drop constraint if exists secret_versions_master_key_epoch_check,
drop column if exists target_master_key_epoch, drop column if exists target_key_version,
drop column if exists target_ciphertext, drop column if exists master_key_epoch;
drop table if exists master_key_rotations;
drop table if exists master_key_identities;
delete from __crank_migrations where version = 7;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v8_schema(pool: &sqlx::PgPool) {
remove_v9_schema(pool).await;
sqlx::raw_sql(
"drop table if exists admin_security_audit_events;
drop table if exists admin_login_backoff;
drop table if exists admin_bootstrap_contracts;
alter table user_sessions
drop constraint if exists user_sessions_csrf_hash_check,
drop column if exists csrf_hash,
drop column if exists revoked_at;
delete from __crank_migrations where version = 8;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v9_schema(pool: &sqlx::PgPool) {
remove_v10_schema(pool).await;
sqlx::raw_sql(
"drop trigger if exists published_agents_monotonic_guard on published_agents;
drop function if exists crank_reject_published_agent_pointer_rewind();
drop trigger if exists agent_operation_bindings_immutable_guard on agent_operation_bindings;
drop function if exists crank_reject_published_agent_binding_mutation();
drop trigger if exists agent_versions_immutable_guard on agent_versions;
drop function if exists crank_reject_published_agent_version_mutation();
alter table published_agents
drop constraint if exists published_agents_catalog_revision_check,
drop column if exists catalog_revision;
alter table agents
drop constraint if exists agents_catalog_revision_check,
drop column if exists catalog_revision;
delete from __crank_migrations where version = 9;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v10_schema(pool: &sqlx::PgPool) {
remove_v11_schema(pool).await;
sqlx::raw_sql(
"drop index if exists approval_requests_pending_scope_fingerprint_idx;
drop index if exists approval_requests_workspace_request_trace_idx;
alter table approval_requests drop constraint if exists approval_requests_request_id_check;
alter table approval_requests drop constraint if exists approval_requests_trace_id_check;
alter table approval_requests drop column if exists request_id;
alter table approval_requests drop column if exists trace_id;
create unique index if not exists approval_requests_pending_fingerprint_idx
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
where status = 'pending' and request_fingerprint is not null;
delete from __crank_migrations where version = 10;",
)
.execute(pool)
.await
.unwrap();
}
async fn remove_v11_schema(pool: &sqlx::PgPool) {
sqlx::raw_sql(
"drop table if exists onboarding_selections;
drop trigger if exists product_events_append_only_guard on product_events;
drop function if exists crank_reject_product_event_mutation();
drop table if exists product_event_daily_rollups;
drop table if exists product_events;
drop index if exists invocation_logs_workspace_agent_key_success_idx;
alter table invocation_logs drop constraint if exists invocation_logs_platform_key_scope_fk;
alter table invocation_logs drop column if exists platform_api_key_id;
alter table platform_api_keys drop constraint if exists platform_api_keys_workspace_agent_id_unique;
delete from __crank_migrations where version = 11;",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn partial_sequence_fails_closed() {
let database_url = crank_test_support::postgres_schema_url("test_partial_sequence").await;
@@ -459,7 +855,6 @@ async fn partial_sequence_fails_closed() {
"partial_sequence"
);
}
#[tokio::test]
async fn missing_relation_with_current_ledger_fails_closed() {
let database_url = crank_test_support::postgres_schema_url("test_missing_relation").await;
@@ -473,7 +868,6 @@ async fn missing_relation_with_current_ledger_fails_closed() {
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.schema");
}
#[tokio::test]
async fn unregistered_legacy_extension_provenance_is_rejected() {
let database_url = crank_test_support::postgres_schema_url("test_legacy_extension").await;
@@ -492,11 +886,9 @@ async fn unregistered_legacy_extension_provenance_is_rejected() {
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "legacy_conflict");
}
#[tokio::test]
async fn any_owned_relation_without_core_ledger_is_partial() {
let database_url = crank_test_support::postgres_schema_url("test_owned_partial").await;
@@ -508,7 +900,6 @@ async fn any_owned_relation_without_core_ledger_is_partial() {
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
}
#[tokio::test]
async fn current_ledger_with_structural_drift_fails_closed() {
let database_url = crank_test_support::postgres_schema_url("test_structural_drift").await;
@@ -522,7 +913,6 @@ async fn current_ledger_with_structural_drift_fails_closed() {
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.schema");
}
#[tokio::test]
async fn tampered_legacy_audit_fails_closed() {
let database_url = crank_test_support::postgres_schema_url("test_tampered_audit").await;
@@ -537,173 +927,3 @@ async fn tampered_legacy_audit_fails_closed() {
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "legacy_conflict");
}
#[tokio::test]
async fn failed_consolidation_rolls_back_all_changes() {
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
let database_url = crank_test_support::postgres_schema_url("test_apply_rollback").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::query(
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
values ('rollback_preserved', 'rollback-preserved', 'Rollback Preserved', 'active', '{}'::jsonb, now(), now())",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"drop table __crank_migrations, __crank_migration_legacy_audit,
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
)
.execute(&pool)
.await
.unwrap();
let schema: String = sqlx::query_scalar("select current_schema()")
.fetch_one(&pool)
.await
.unwrap();
let failure_trigger = format!(
"create function reject_story14_v2() returns event_trigger language plpgsql as $$
begin
if current_schema() = '{schema}' and current_query() like '%__crank_migrations%' then
raise exception 'injected v2 ddl failure';
end if;
end $$;
create event trigger reject_story14_v2 on ddl_command_start
execute function reject_story14_v2();"
);
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
sqlx::raw_sql(
"drop event trigger reject_story14_v2;
drop function reject_story14_v2();",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(error.code(), "apply_failed");
for relation in [
"__crank_migrations",
"__crank_migration_legacy_audit",
"__crank_mcp_migrations",
"mcp_transport_sessions",
"__crank_ext_migrations",
] {
let present: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
)
.bind(relation)
.fetch_one(&pool)
.await
.unwrap();
assert!(!present, "{relation} must roll back with failed v2 DDL");
}
let preserved: String =
sqlx::query_scalar("select display_name from workspaces where id = 'rollback_preserved'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(preserved, "Rollback Preserved");
}
#[tokio::test]
async fn failed_request_trace_identity_migration_rolls_back_all_changes() {
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
let database_url =
crank_test_support::postgres_schema_url("test_trace_identity_rollback").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::raw_sql(
"insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_trace_rollback', 'ws_default', 'trace-rollback', 'Trace rollback',
'rest', 'draft', now(), now());
insert into invocation_logs
(id, workspace_id, operation_id, source, level, status, tool_name, message,
duration_ms, request_preview_json, response_preview_json, created_at)
values ('trace_rollback_preserved', 'ws_default', 'op_trace_rollback', 'admin',
'info', 'success', 'trace_rollback', 'safe preserved row', 1,
'{}'::jsonb, '{}'::jsonb, now());",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("delete from __crank_migrations where version = 3")
.execute(&pool)
.await
.unwrap();
remove_v3_schema(&pool).await;
let schema: String = sqlx::query_scalar("select current_schema()")
.fetch_one(&pool)
.await
.unwrap();
let failure_trigger = format!(
"create function reject_story15_v3() returns event_trigger language plpgsql as $$
begin
if current_schema() = '{schema}' and current_query() like '%invocation_logs_workspace_trace_id_idx%' then
raise exception 'injected v3 ddl failure';
end if;
end $$;
create event trigger reject_story15_v3 on ddl_command_start
execute function reject_story15_v3();"
);
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
sqlx::raw_sql(
"drop event trigger reject_story15_v3;
drop function reject_story15_v3();",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(error.code(), "apply_failed");
assert_eq!(error.version(), Some(3));
let trace_column: bool = sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'invocation_logs'
and column_name = 'trace_id'
)",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!trace_column, "trace_id column must roll back with v3");
for index in [
"invocation_logs_workspace_request_id_idx",
"invocation_logs_workspace_trace_id_idx",
] {
let present: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
)
.bind(index)
.fetch_one(&pool)
.await
.unwrap();
assert!(!present, "{index} must roll back with failed v3 DDL");
}
let ledger_v3: i64 =
sqlx::query_scalar("select count(*) from __crank_migrations where version = 3")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(ledger_v3, 0, "failed v3 must not be recorded as applied");
let preserved: String = sqlx::query_scalar(
"select message from invocation_logs where id = 'trace_rollback_preserved'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(preserved, "safe preserved row");
}
@@ -0,0 +1,168 @@
use super::*;
#[tokio::test]
async fn failed_consolidation_rolls_back_all_changes() {
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
let database_url = crank_test_support::postgres_schema_url("test_apply_rollback").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::query(
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
values ('rollback_preserved', 'rollback-preserved', 'Rollback Preserved', 'active', '{}'::jsonb, now(), now())",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"drop table __crank_migrations, __crank_migration_legacy_audit,
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
)
.execute(&pool)
.await
.unwrap();
let schema: String = sqlx::query_scalar("select current_schema()")
.fetch_one(&pool)
.await
.unwrap();
let failure_trigger = format!(
"create function reject_story14_v2() returns event_trigger language plpgsql as $$
begin
if current_schema() = '{schema}' and current_query() like '%__crank_migrations%' then
raise exception 'injected v2 ddl failure';
end if;
end $$;
create event trigger reject_story14_v2 on ddl_command_start
execute function reject_story14_v2();"
);
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
sqlx::raw_sql(
"drop event trigger reject_story14_v2;
drop function reject_story14_v2();",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(error.code(), "apply_failed");
for relation in [
"__crank_migrations",
"__crank_migration_legacy_audit",
"__crank_mcp_migrations",
"mcp_transport_sessions",
"__crank_ext_migrations",
] {
let present: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
)
.bind(relation)
.fetch_one(&pool)
.await
.unwrap();
assert!(!present, "{relation} must roll back with failed v2 DDL");
}
let preserved: String =
sqlx::query_scalar("select display_name from workspaces where id = 'rollback_preserved'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(preserved, "Rollback Preserved");
}
#[tokio::test]
async fn failed_request_trace_identity_migration_rolls_back_all_changes() {
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
let database_url =
crank_test_support::postgres_schema_url("test_trace_identity_rollback").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::raw_sql(
"insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_trace_rollback', 'ws_default', 'trace-rollback', 'Trace rollback',
'rest', 'draft', now(), now());
insert into invocation_logs
(id, workspace_id, operation_id, source, level, status, tool_name, message,
duration_ms, request_preview_json, response_preview_json, created_at)
values ('trace_rollback_preserved', 'ws_default', 'op_trace_rollback', 'admin',
'info', 'success', 'trace_rollback', 'safe preserved row', 1,
'{}'::jsonb, '{}'::jsonb, now());",
)
.execute(&pool)
.await
.unwrap();
remove_v4_schema(&pool).await;
sqlx::query("delete from __crank_migrations where version = 3")
.execute(&pool)
.await
.unwrap();
remove_v3_schema(&pool).await;
let schema: String = sqlx::query_scalar("select current_schema()")
.fetch_one(&pool)
.await
.unwrap();
let failure_trigger = format!(
"create function reject_story15_v3() returns event_trigger language plpgsql as $$
begin
if current_schema() = '{schema}' and current_query() like '%invocation_logs_workspace_trace_id_idx%' then
raise exception 'injected v3 ddl failure';
end if;
end $$;
create event trigger reject_story15_v3 on ddl_command_start
execute function reject_story15_v3();"
);
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
sqlx::raw_sql(
"drop event trigger reject_story15_v3;
drop function reject_story15_v3();",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(error.code(), "apply_failed");
assert_eq!(error.version(), Some(3));
let trace_column: bool = sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'invocation_logs'
and column_name = 'trace_id'
)",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!trace_column, "trace_id column must roll back with v3");
for index in [
"invocation_logs_workspace_request_id_idx",
"invocation_logs_workspace_trace_id_idx",
] {
let present: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
)
.bind(index)
.fetch_one(&pool)
.await
.unwrap();
assert!(!present, "{index} must roll back with failed v3 DDL");
}
let ledger_v3: i64 =
sqlx::query_scalar("select count(*) from __crank_migrations where version = 3")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(ledger_v3, 0, "failed v3 must not be recorded as applied");
let preserved: String = sqlx::query_scalar(
"select message from invocation_logs where id = 'trace_rollback_preserved'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(preserved, "safe preserved row");
}
@@ -1,8 +1,10 @@
use crank_registry::{
CreateInvocationLogRequest, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
ListInvocationLogsQuery, UsageBucket, UsageQuery,
};
use sqlx::Row;
use super::common::{TestDatabase, test_invocation_log};
use super::common::{TestDatabase, test_invocation_log, test_operation, test_workspace_id};
#[tokio::test]
async fn invocation_history_write_returns_typed_loss_without_error_details() {
@@ -29,3 +31,115 @@ async fn invocation_history_write_returns_typed_loss_without_error_details() {
);
database.cleanup().await;
}
#[tokio::test]
#[ignore = "production-size query-plan evidence: inserts 1,000,000 invocation_logs rows"]
async fn production_size_invocation_history_queries_stay_bounded() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
let operation = test_operation("op_history_scale", 1, crank_core::OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
sqlx::query(
r#"
insert into invocation_logs (
id, workspace_id, operation_id, operation_version, source, level, status,
tool_name, message, request_id, trace_id, status_code, duration_ms,
error_kind, execution_stage, execution_error_code, retryability, outcome_certainty,
request_preview_json, response_preview_json, created_at
)
select
'log_scale_' || series::text,
'ws_default',
'op_history_scale',
1,
'admin_test_run',
'info',
case when series % 10 = 0 then 'error' else 'ok' end,
'scale_tool',
'scale invocation',
'018f0000-0000-7000-8000-' || lpad(series::text, 12, '0'),
'0af7651916cd43dd8448eb211c80319c',
case when series % 10 = 0 then 500 else 200 end,
20 + (series % 250),
null,
'runtime',
case when series % 10 = 0 then 'runtime_internal' else null end,
'never',
'certain',
'{"input":"bounded"}'::jsonb,
'{"ok":true}'::jsonb,
'2026-03-25T00:00:00Z'::timestamptz + (series || ' seconds')::interval
from generate_series(1, 1000000) as series
"#,
)
.execute(&pool)
.await
.unwrap();
let page = registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: None,
operation_id: Some(&operation.id),
agent_id: None,
created_after: Some("2026-03-25T00:00:00Z"),
created_before: Some("2026-04-06T00:00:00Z"),
cursor_created_at: None,
cursor_id: None,
limit: 101,
})
.await
.unwrap();
assert_eq!(page.len(), 101);
let summary = registry
.summarize_usage(UsageQuery {
workspace_id: &test_workspace_id(),
period: crank_core::UsagePeriod::Last7Days,
source: None,
created_after: "2026-03-25T00:00:00Z",
created_before: "2026-04-06T00:00:00Z",
bucket: UsageBucket::Day,
})
.await
.unwrap();
assert_eq!(summary.rollup.calls_total, 1_000_000);
let explain = sqlx::query(
r#"
explain
select id
from invocation_logs
where workspace_id = 'ws_default'
and operation_id = 'op_history_scale'
and created_at >= '2026-03-25T00:00:00Z'::timestamptz
and created_at < '2026-04-06T00:00:00Z'::timestamptz
order by created_at desc, id desc
limit 101
"#,
)
.fetch_all(&pool)
.await
.unwrap()
.into_iter()
.map(|row| row.get::<String, _>(0))
.collect::<Vec<_>>()
.join("\n");
assert!(
explain.contains("Index Scan") || explain.contains("Bitmap Index Scan"),
"{explain}"
);
assert!(!explain.contains("Seq Scan"), "{explain}");
database.cleanup().await;
}

Some files were not shown because too many files have changed in this diff Show More