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
+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)
);
}
}