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
+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;
}
@@ -0,0 +1,844 @@
#![allow(dead_code, unused_imports)]
use super::common::*;
use crank_core::{
AgentOperationBinding, AgentStatus, InvocationSource, InvocationStatus, OnboardingStepId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, ProductEventId, Workspace, WorkspaceId,
};
use crank_registry::{
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateWorkspaceRequest, OnboardingPresentationMilestone, PublishAgentRequest, PublishRequest,
RecordOnboardingMilestoneRequest,
};
use serde_json::json;
use sqlx::Row;
use time::{Duration, OffsetDateTime};
#[path = "onboarding_lifecycle.rs"]
mod onboarding_lifecycle;
fn truncate_to_micros(value: OffsetDateTime) -> OffsetDateTime {
value
.replace_nanosecond((value.nanosecond() / 1_000) * 1_000)
.unwrap()
}
#[tokio::test]
async fn migration_exposes_product_events_and_exact_invocation_key_scope() {
let database = TestDatabase::new().await;
let _registry = database.registry().await;
let pool = database.raw_pool().await;
let product_events = relation_exists(&pool, "product_events").await;
let product_rollups = relation_exists(&pool, "product_event_daily_rollups").await;
let key_scope = column_exists(&pool, "invocation_logs", "platform_api_key_id").await;
assert!(product_events, "V11 must own immutable local ProductEvents");
assert!(
product_rollups,
"V11 must preserve bounded UTC product-metric denominators"
);
assert!(
key_scope,
"successful MCP evidence must retain the exact non-secret key identity"
);
database.cleanup().await;
}
#[tokio::test]
async fn product_event_idempotency_is_scoped_to_workspace() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
create_workspace(&registry, "ws_onboarding_other", "onboarding-other").await;
insert_product_event(
&pool,
"pe_start_default",
"ws_default",
"onboarding_started",
"eligible:first-login",
)
.await
.unwrap();
let replay = insert_product_event(
&pool,
"pe_start_replay",
"ws_default",
"onboarding_started",
"eligible:first-login",
)
.await;
assert!(
replay.is_err(),
"same workspace/idempotency key must not double-count a milestone"
);
insert_product_event(
&pool,
"pe_start_other",
"ws_onboarding_other",
"onboarding_started",
"eligible:first-login",
)
.await
.expect("an independent workspace may use the same local idempotency key");
let count: i64 =
sqlx::query_scalar("select count(*) from product_events where idempotency_key = $1")
.bind("eligible:first-login")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 2);
database.cleanup().await;
}
#[tokio::test]
async fn exact_key_success_is_authoritative_and_revocation_regresses_progress() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
let (operation_id, agent_id, key_id) = published_path(&registry).await;
let log = test_invocation_log(
"log_onboarding_first_call",
&operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:15:00Z",
);
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.await,
crank_registry::InvocationHistoryWriteOutcome::Recorded
);
sqlx::query("update invocation_logs set platform_api_key_id = $1 where id = $2")
.bind(key_id.as_str())
.bind(log.id.as_str())
.execute(&pool)
.await
.unwrap();
assert!(
authoritative_first_call(&pool, "ws_default", agent_id.as_str(), key_id.as_str()).await,
"an active exact-scope key and real successful Agent tool invocation complete first value"
);
registry
.revoke_platform_api_key_for_agent(
&WorkspaceId::new("ws_default"),
&agent_id,
&key_id,
&OffsetDateTime::now_utc(),
)
.await
.unwrap();
assert!(
!authoritative_first_call(&pool, "ws_default", agent_id.as_str(), key_id.as_str()).await,
"revocation must return connection/onboarding to an actionable state"
);
database.cleanup().await;
}
#[tokio::test]
async fn invocation_key_scope_rejects_cross_workspace_evidence() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
assert!(
column_exists(&pool, "invocation_logs", "platform_api_key_id").await,
"cross-workspace rejection requires the exact-key evidence column"
);
let (operation_id, agent_id, _key_id) = published_path(&registry).await;
create_workspace(&registry, "ws_onboarding_foreign", "onboarding-foreign").await;
let foreign_key = PlatformApiKey {
id: PlatformApiKeyId::new("pk_onboarding_foreign"),
workspace_id: WorkspaceId::new("ws_onboarding_foreign"),
agent_id: None,
key_kind: PlatformApiKeyKind::McpClient,
name: "foreign-onboarding-key".to_owned(),
prefix: "crk_foreign".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &foreign_key,
secret_hash: "foreign-secret-hash",
})
.await
.unwrap();
let log = test_invocation_log(
"log_onboarding_cross_workspace",
&operation_id,
Some(agent_id),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.await,
crank_registry::InvocationHistoryWriteOutcome::Recorded
);
let poisoned = sqlx::query(
"update invocation_logs set platform_api_key_id = $1 where workspace_id = $2 and id = $3",
)
.bind(foreign_key.id.as_str())
.bind("ws_default")
.bind(log.id.as_str())
.execute(&pool)
.await;
assert!(
poisoned.is_err(),
"database constraints must prevent foreign-workspace key evidence"
);
database.cleanup().await;
}
#[tokio::test]
async fn projection_moves_from_empty_to_exact_terminal_success_and_regresses_on_revoke() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let empty = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!empty.completed);
assert_eq!(
empty.steps.iter().map(|step| step.id).collect::<Vec<_>>(),
OnboardingStepId::ORDERED
);
let (operation_id, agent_id, key_id) = published_path(&registry).await;
let mut test_log = test_invocation_log(
"log_onboarding_admin_test",
&operation_id,
None,
InvocationStatus::Ok,
20,
"2026-03-25T12:14:00Z",
);
test_log.source = InvocationSource::AdminTestRun;
registry
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
.await;
registry
.touch_platform_api_key(&workspace_id, &key_id, &OffsetDateTime::now_utc())
.await
.unwrap();
let mut pending = test_invocation_log(
"log_onboarding_approval_pending",
&operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
10,
"2026-03-25T12:15:00Z",
);
pending.platform_api_key_id = Some(key_id.clone());
pending.execution_stage = Some(crank_core::ExecutionStage::MandatoryPersistence);
pending.created_at = OffsetDateTime::now_utc();
registry
.create_invocation_log(CreateInvocationLogRequest { log: &pending })
.await;
let not_terminal = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!not_terminal.completed);
assert!(
!not_terminal
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed
);
let mut success = test_invocation_log(
"log_onboarding_terminal_success",
&operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
success.platform_api_key_id = Some(key_id.clone());
success.created_at = truncate_to_micros(OffsetDateTime::now_utc());
registry
.create_invocation_log(CreateInvocationLogRequest { log: &success })
.await;
let complete = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(complete.completed);
assert_eq!(complete.platform_api_key_id, Some(key_id.clone()));
assert_eq!(complete.first_call_log_id, Some(success.id));
assert_eq!(
complete.first_call_tool_name.as_deref(),
Some("create_lead")
);
assert_eq!(complete.first_call_at, Some(success.created_at));
// A different active key/path must not replace the exact path that produced
// the terminal onboarding call when that exact key is later revoked.
let (_other_agent_id, _other_key_id) =
publish_additional_agent_path(&registry, &operation_id).await;
registry
.revoke_platform_api_key_for_agent(
&workspace_id,
&agent_id,
&key_id,
&OffsetDateTime::now_utc(),
)
.await
.unwrap();
let regressed = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!regressed.completed);
assert!(!regressed.step(OnboardingStepId::Key).unwrap().completed);
assert!(
!regressed
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed
);
database.cleanup().await;
}
#[tokio::test]
async fn current_agent_publication_and_key_expiry_invalidate_old_first_call_evidence() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
let workspace_id = WorkspaceId::new("ws_default");
let (operation_id, agent_id, key_id) = published_path(&registry).await;
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"publication_before_republish",
OffsetDateTime::now_utc(),
)
.await;
let completed = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(completed.completed);
let unpublished_at = OffsetDateTime::now_utc() + Duration::seconds(1);
registry
.unpublish_agent(&workspace_id, &agent_id, &unpublished_at, None)
.await
.unwrap();
let unpublished = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!unpublished.step(OnboardingStepId::Agent).unwrap().completed);
let republished_at = unpublished_at + Duration::seconds(1);
registry
.publish_agent(PublishAgentRequest {
workspace_id: &workspace_id,
agent_id: &agent_id,
version: 1,
published_at: &republished_at,
published_by: Some("onboarding-republish-test"),
expected_state: None,
})
.await
.unwrap();
let republished = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(
!republished.step(OnboardingStepId::Agent).unwrap().completed,
"republishing must not silently replace the exact catalog revision that completed onboarding"
);
assert!(
!republished
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed,
"a call from an older Agent publication must not complete the current catalog revision"
);
assert_ne!(republished.revision, completed.revision);
let reset = registry
.reset_onboarding_selection(
&workspace_id,
republished.revision,
republished_at + Duration::milliseconds(1),
)
.await
.unwrap();
assert!(reset.step(OnboardingStepId::Agent).unwrap().completed);
assert!(reset.step(OnboardingStepId::Key).unwrap().completed);
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"publication_after_republish",
republished_at + Duration::seconds(1),
)
.await;
let completed_again = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(completed_again.completed);
sqlx::query(
"update platform_api_keys set expires_at = now() - interval '1 minute' where id = $1",
)
.bind(key_id.as_str())
.execute(&pool)
.await
.unwrap();
let expired = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!expired.step(OnboardingStepId::Key).unwrap().completed);
assert!(!expired.step(OnboardingStepId::FirstCall).unwrap().completed);
assert_ne!(expired.revision, completed_again.revision);
database.cleanup().await;
}
#[tokio::test]
async fn archiving_selected_operation_or_agent_regresses_authoritative_projection() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let (operation_id, agent_id, key_id) = published_path(&registry).await;
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"archive_agent_path",
OffsetDateTime::now_utc(),
)
.await;
assert!(
registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap()
.completed
);
// A second healthy path must not replace the exact lineage that produced
// the authoritative first call when that lineage is later archived.
let (alternative_operation_id, _, _) = publish_named_path(
&registry,
"op_onboarding_alternative",
"agent_onboarding_alternative",
"pk_onboarding_alternative",
)
.await;
registry
.archive_agent(
&workspace_id,
&agent_id,
&(OffsetDateTime::now_utc() + Duration::seconds(1)),
None,
)
.await
.unwrap();
let archived_agent = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert_eq!(archived_agent.operation_id, Some(operation_id.clone()));
assert_eq!(archived_agent.agent_id, Some(agent_id));
assert_ne!(archived_agent.operation_id, Some(alternative_operation_id));
assert!(
!archived_agent
.step(OnboardingStepId::Agent)
.unwrap()
.completed
);
assert!(
!archived_agent
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed
);
registry
.archive_operation(
&workspace_id,
&operation_id,
&(OffsetDateTime::now_utc() + Duration::seconds(2)),
)
.await
.unwrap();
let archived_operation = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(
!archived_operation
.step(OnboardingStepId::Operation)
.unwrap()
.completed
);
database.cleanup().await;
}
async fn published_path(
registry: &crank_registry::PostgresRegistry,
) -> (
crank_core::OperationId,
crank_core::AgentId,
PlatformApiKeyId,
) {
publish_named_path(
registry,
"op_onboarding",
"agent_onboarding",
"pk_onboarding_exact",
)
.await
}
async fn publish_named_path(
registry: &crank_registry::PostgresRegistry,
operation_id: &str,
agent_id: &str,
key_id: &str,
) -> (
crank_core::OperationId,
crank_core::AgentId,
PlatformApiKeyId,
) {
let workspace_id = WorkspaceId::new("ws_default");
let operation = test_operation(operation_id, 1, OperationStatus::Draft);
registry
.create_operation(&workspace_id, &operation, None)
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &workspace_id,
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::now_utc(),
published_by: Some("onboarding-test"),
})
.await
.unwrap();
let agent = test_agent(agent_id, AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let bindings = vec![AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: 1,
tool_name: "create_lead".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
}];
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &bindings,
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
version: 1,
published_at: &OffsetDateTime::now_utc(),
published_by: Some("onboarding-test"),
expected_state: None,
})
.await
.unwrap();
let key_id = PlatformApiKeyId::new(key_id);
let key = PlatformApiKey {
id: key_id.clone(),
workspace_id,
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: format!("onboarding-{key_id}-key"),
prefix: format!("crk_{}", key_id.as_str()),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &key,
secret_hash: "exact-secret-hash",
})
.await
.unwrap();
(operation.id, agent.id, key_id)
}
async fn record_onboarding_test_and_call(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
agent_id: &crank_core::AgentId,
key_id: &PlatformApiKeyId,
suffix: &str,
occurred_at: OffsetDateTime,
) {
let mut test_log = test_invocation_log(
&format!("log_onboarding_test_{suffix}"),
operation_id,
None,
InvocationStatus::Ok,
20,
"2026-03-25T12:14:00Z",
);
test_log.source = InvocationSource::AdminTestRun;
test_log.created_at = truncate_to_micros(occurred_at);
registry
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
.await;
registry
.touch_platform_api_key(
&WorkspaceId::new("ws_default"),
key_id,
&truncate_to_micros(occurred_at),
)
.await
.unwrap();
record_onboarding_call(
registry,
operation_id,
agent_id,
key_id,
suffix,
occurred_at,
)
.await;
}
async fn record_onboarding_call(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
agent_id: &crank_core::AgentId,
key_id: &PlatformApiKeyId,
suffix: &str,
occurred_at: OffsetDateTime,
) {
let mut success = test_invocation_log(
&format!("log_onboarding_call_{suffix}"),
operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
success.platform_api_key_id = Some(key_id.clone());
success.created_at = truncate_to_micros(occurred_at);
registry
.create_invocation_log(CreateInvocationLogRequest { log: &success })
.await;
}
async fn publish_additional_agent_path(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
) -> (crank_core::AgentId, PlatformApiKeyId) {
let workspace_id = WorkspaceId::new("ws_default");
let agent = test_agent("agent_onboarding_later", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let bindings = vec![AgentOperationBinding {
operation_id: operation_id.clone(),
operation_version: 1,
tool_name: "create_lead".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
}];
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &bindings,
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
version: 1,
published_at: &OffsetDateTime::now_utc(),
published_by: Some("onboarding-test"),
expected_state: None,
})
.await
.unwrap();
let key_id = PlatformApiKeyId::new("pk_onboarding_later");
let key = PlatformApiKey {
id: key_id.clone(),
workspace_id,
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: "onboarding-later-key".to_owned(),
prefix: "crk_later".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &key,
secret_hash: "later-secret-hash",
})
.await
.unwrap();
(agent.id, key_id)
}
async fn authoritative_first_call(
pool: &sqlx::PgPool,
workspace_id: &str,
agent_id: &str,
key_id: &str,
) -> bool {
sqlx::query_scalar(
"select exists (
select 1
from invocation_logs l
join published_agents pa on pa.agent_id = l.agent_id
join platform_api_keys k
on k.id = l.platform_api_key_id
and k.workspace_id = l.workspace_id
and k.agent_id = l.agent_id
where l.workspace_id = $1
and l.agent_id = $2
and l.platform_api_key_id = $3
and l.source = 'agent_tool_call'
and l.status = 'ok'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
)",
)
.bind(workspace_id)
.bind(agent_id)
.bind(key_id)
.fetch_one(pool)
.await
.unwrap()
}
async fn insert_product_event(
pool: &sqlx::PgPool,
id: &str,
workspace_id: &str,
event_name: &str,
idempotency_key: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"insert into product_events (
id, workspace_id, event_name, schema_version, occurred_at, idempotency_key,
properties_json
) values ($1, $2, $3, 1, now(), $4, $5)",
)
.bind(id)
.bind(workspace_id)
.bind(event_name)
.bind(idempotency_key)
.bind(json!({"eligible": true}))
.execute(pool)
.await?;
Ok(())
}
async fn relation_exists(pool: &sqlx::PgPool, relation: &str) -> bool {
sqlx::query_scalar(
"select exists (
select 1 from information_schema.tables
where table_schema = current_schema() and table_name = $1
)",
)
.bind(relation)
.fetch_one(pool)
.await
.unwrap()
}
async fn column_exists(pool: &sqlx::PgPool, relation: &str, column: &str) -> bool {
sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema() and table_name = $1 and column_name = $2
)",
)
.bind(relation)
.bind(column)
.fetch_one(pool)
.await
.unwrap()
}
async fn create_workspace(registry: &crank_registry::PostgresRegistry, id: &str, slug: &str) {
let now = OffsetDateTime::now_utc();
let workspace = Workspace {
id: WorkspaceId::new(id),
slug: slug.to_owned(),
display_name: slug.to_owned(),
status: crank_core::WorkspaceStatus::Active,
settings: json!({}),
created_at: now,
updated_at: now,
};
registry
.create_workspace(CreateWorkspaceRequest {
workspace: &workspace,
})
.await
.unwrap();
}
@@ -0,0 +1,219 @@
use super::*;
#[tokio::test]
async fn server_owned_lifecycle_events_are_idempotent_and_completion_uses_the_eligible_cohort() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let eligible_at = truncate_to_micros(OffsetDateTime::now_utc() - Duration::minutes(3));
let eligible = registry
.ensure_onboarding_eligibility(&workspace_id, eligible_at)
.await
.unwrap();
assert_eq!(eligible.eligible_since, Some(eligible_at));
let repeated = registry
.ensure_onboarding_eligibility(&workspace_id, OffsetDateTime::now_utc())
.await
.unwrap();
assert_eq!(repeated.eligible_since, Some(eligible_at));
let (operation_id, agent_id, key_id) = published_path(&registry).await;
let mut test_log = test_invocation_log(
"log_onboarding_server_test",
&operation_id,
None,
InvocationStatus::Ok,
20,
"2026-03-25T12:14:00Z",
);
test_log.source = InvocationSource::AdminTestRun;
registry
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
.await;
registry
.touch_platform_api_key(&workspace_id, &key_id, &OffsetDateTime::now_utc())
.await
.unwrap();
let mut success = test_invocation_log(
"log_onboarding_server_call",
&operation_id,
Some(agent_id),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
success.platform_api_key_id = Some(key_id);
success.created_at = truncate_to_micros(OffsetDateTime::now_utc());
registry
.create_invocation_log(CreateInvocationLogRequest { log: &success })
.await;
let completed = registry
.ensure_onboarding_completion(&workspace_id, OffsetDateTime::now_utc())
.await
.unwrap();
assert!(completed.completed);
let later_draft = test_operation("op_onboarding_later_draft", 1, OperationStatus::Draft);
registry
.create_operation(&workspace_id, &later_draft, None)
.await
.unwrap();
let after_later_draft = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert_eq!(after_later_draft.operation_id, Some(operation_id));
assert!(after_later_draft.completed);
let events = registry
.list_product_events(crank_registry::ListProductEventsQuery {
workspace_id: &workspace_id,
kind: Some(crank_core::ProductEventKind::OnboardingCompleted),
created_after: OffsetDateTime::UNIX_EPOCH,
created_before: OffsetDateTime::now_utc() + Duration::minutes(1),
limit: 10,
})
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event.eligible_since, Some(eligible_at));
assert_eq!(events[0].event.occurred_at, success.created_at);
registry
.ensure_onboarding_completion(&workspace_id, OffsetDateTime::now_utc())
.await
.unwrap();
let events = registry
.list_product_events(crank_registry::ListProductEventsQuery {
workspace_id: &workspace_id,
kind: Some(crank_core::ProductEventKind::OnboardingCompleted),
created_after: OffsetDateTime::UNIX_EPOCH,
created_before: OffsetDateTime::now_utc() + Duration::minutes(1),
limit: 10,
})
.await
.unwrap();
assert_eq!(events.len(), 1);
database.cleanup().await;
}
#[tokio::test]
async fn first_call_before_first_eligibility_is_repaired_with_the_invocation_timestamp() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let (operation_id, agent_id, key_id) = published_path(&registry).await;
let call_at = truncate_to_micros(OffsetDateTime::now_utc() + Duration::seconds(1));
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"before_eligibility",
call_at,
)
.await;
let before_eligibility = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(before_eligibility.completed);
assert!(!before_eligibility.was_completed);
let eligible_at = call_at + Duration::seconds(1);
let repaired = registry
.ensure_onboarding_eligibility(&workspace_id, eligible_at)
.await
.unwrap();
assert!(repaired.completed);
assert!(repaired.was_completed);
let events = registry
.list_product_events(crank_registry::ListProductEventsQuery {
workspace_id: &workspace_id,
kind: Some(crank_core::ProductEventKind::OnboardingCompleted),
created_after: OffsetDateTime::UNIX_EPOCH,
created_before: eligible_at + Duration::minutes(1),
limit: 10,
})
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event.occurred_at, call_at);
assert_eq!(events[0].event.eligible_since, Some(eligible_at));
database.cleanup().await;
}
#[tokio::test]
async fn projection_keeps_agent_and_key_on_the_same_terminal_path() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let (operation_id, _earlier_agent_id, _earlier_key_id) = published_path(&registry).await;
let (later_agent_id, later_key_id) =
publish_additional_agent_path(&registry, &operation_id).await;
let mut success = test_invocation_log(
"log_onboarding_later_agent_success",
&operation_id,
Some(later_agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:18:00Z",
);
success.platform_api_key_id = Some(later_key_id.clone());
success.created_at = OffsetDateTime::now_utc();
registry
.create_invocation_log(CreateInvocationLogRequest { log: &success })
.await;
let projection = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert_eq!(projection.agent_id, Some(later_agent_id));
assert_eq!(projection.platform_api_key_id, Some(later_key_id));
assert_eq!(projection.first_call_log_id, Some(success.id));
database.cleanup().await;
}
#[tokio::test]
async fn presentation_milestone_is_idempotent_and_revision_guarded() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let initial = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
let event_id = ProductEventId::new("pe_onboarding_eligible");
let request = RecordOnboardingMilestoneRequest {
workspace_id: &workspace_id,
event_id: &event_id,
milestone: OnboardingPresentationMilestone::Eligible,
idempotency_key: "eligible:first-login",
expected_revision: initial.revision,
occurred_at: OffsetDateTime::now_utc(),
eligible_since: Some(OffsetDateTime::now_utc()),
};
let first = registry
.record_onboarding_milestone(request.clone())
.await
.unwrap();
assert!(first.accepted);
let replay = registry
.record_onboarding_milestone(RecordOnboardingMilestoneRequest {
expected_revision: first.projection.revision,
..request.clone()
})
.await
.unwrap();
assert!(!replay.accepted);
assert_eq!(replay.projection.revision, first.projection.revision);
let stale_replay = registry.record_onboarding_milestone(request).await.unwrap();
assert!(!stale_replay.accepted);
assert_eq!(stale_replay.projection.revision, first.projection.revision);
database.cleanup().await;
}
@@ -9,8 +9,9 @@ use crank_core::{
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, Secret, SecretId, SecretKind,
SecretStatus, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState,
Workspace, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_schema::{Schema, SchemaKind};
@@ -20,11 +21,12 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crank_registry::{
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DescriptorKind, DescriptorMetadata, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest,
PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, WorkspaceRecord,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
fn test_workspace_id() -> WorkspaceId {
@@ -35,6 +37,39 @@ fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
async fn create_test_secret(registry: &PostgresRegistry, id: &SecretId, name: &str) {
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &timestamp("2026-03-25T12:00:00Z"),
})
.await
.unwrap();
let secret = Secret {
id: id.clone(),
workspace_id: test_workspace_id(),
name: name.to_owned(),
kind: SecretKind::Token,
status: SecretStatus::Active,
current_version: 1,
created_at: timestamp("2026-03-25T12:00:00Z"),
updated_at: timestamp("2026-03-25T12:00:00Z"),
last_used_at: None,
};
registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: "test-ciphertext",
key_version: "test-key-v1",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap();
}
#[tokio::test]
async fn stores_versions_and_published_operations() {
let database = TestDatabase::new().await;
@@ -137,7 +172,7 @@ async fn rejects_out_of_order_versions() {
assert!(matches!(
error,
RegistryError::InvalidVersionSequence {
RegistryError::OperationStaleVersion {
expected: 2,
actual: 3,
..
@@ -170,7 +205,7 @@ async fn update_operation_draft_persists_optional_json_columns_as_sql_null() {
.unwrap();
let stored = registry
.get_operation_version(&test_workspace_id(), &operation.id, operation.version)
.get_operation_version(&test_workspace_id(), &operation.id, operation.version + 1)
.await
.unwrap()
.unwrap();
@@ -183,6 +218,279 @@ async fn update_operation_draft_persists_optional_json_columns_as_sql_null() {
database.cleanup().await;
}
#[tokio::test]
async fn published_version_is_not_rewritten_by_a_later_save() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_immutable_publish", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
let published_before = registry
.get_published_operation(&operation.id)
.await
.unwrap()
.unwrap();
let mut changed = operation.clone();
changed.display_name = "MUTATED AFTER PUBLISH".to_owned();
changed.target = Target::Rest(RestTarget {
base_url: "https://mutated.example.com".to_owned(),
method: HttpMethod::Post,
path_template: "/mutated".to_owned(),
static_headers: BTreeMap::new(),
});
changed.updated_at = timestamp("2026-03-25T12:20:00Z");
registry
.update_operation_draft(&test_workspace_id(), &changed)
.await
.unwrap();
let published_after = registry
.get_published_operation(&operation.id)
.await
.unwrap()
.unwrap();
assert_eq!(published_after, published_before);
assert_eq!(
registry
.get_operation_summary(&test_workspace_id(), &operation.id)
.await
.unwrap()
.unwrap()
.current_draft_version,
2,
"saving after publish must append a new Draft revision"
);
database.cleanup().await;
}
#[tokio::test]
async fn database_guard_rejects_published_update_and_parent_cascade_delete() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_db_immutable", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
let update = sqlx::query(
"update operation_versions set display_name = 'tampered'
where operation_id = $1 and version = 1",
)
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(update.is_err());
let rewind = sqlx::query("update operations set latest_published_version = null where id = $1")
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(rewind.is_err());
let pointer_delete = sqlx::query("delete from published_operations where operation_id = $1")
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(pointer_delete.is_err());
let delete = sqlx::query("delete from operations where id = $1")
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(delete.is_err());
assert!(
registry
.get_published_operation(&operation.id)
.await
.unwrap()
.is_some()
);
database.cleanup().await;
}
#[tokio::test]
async fn concurrent_saves_from_one_base_have_one_winner_and_no_version_gap() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_concurrent_save", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
let mut tasks = Vec::new();
for contender in 0..32_u32 {
let registry = registry.clone();
let mut candidate = operation.clone();
candidate.display_name = format!("Contender {contender}");
candidate.updated_at = timestamp("2026-03-25T12:20:00Z");
tasks.push(tokio::spawn(async move {
registry
.update_operation_draft(&test_workspace_id(), &candidate)
.await
}));
}
let mut successes = 0;
let mut stale = 0;
for task in tasks {
match task.await.unwrap() {
Ok(()) => successes += 1,
Err(RegistryError::OperationStaleVersion { .. }) => stale += 1,
Err(error) => panic!("unexpected contender error: {error}"),
}
}
assert_eq!(successes, 1);
assert_eq!(stale, 31);
let versions = registry
.list_operation_versions(&test_workspace_id(), &operation.id)
.await
.unwrap();
assert_eq!(
versions
.iter()
.map(|record| record.version)
.collect::<Vec<_>>(),
vec![1, 2]
);
database.cleanup().await;
}
#[tokio::test]
async fn concurrent_same_name_create_maps_unique_loser_to_typed_conflict() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let first = test_operation("op_same_name_first", 1, OperationStatus::Draft);
let mut second = test_operation("op_same_name_second", 1, OperationStatus::Draft);
second.name = first.name.clone();
let first_registry = registry.clone();
let second_registry = registry.clone();
let workspace = test_workspace_id();
let first_workspace = workspace.clone();
let second_workspace = workspace.clone();
let (first_result, second_result) = tokio::join!(
async move {
first_registry
.create_operation(&first_workspace, &first, Some("alice"))
.await
},
async move {
second_registry
.create_operation(&second_workspace, &second, Some("alice"))
.await
}
);
let results = [first_result, second_result];
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(
results
.iter()
.filter(|result| matches!(result, Err(RegistryError::OperationAlreadyExists { .. })))
.count(),
1
);
database.cleanup().await;
}
#[tokio::test]
async fn archive_preserves_published_version_and_delete_is_restricted_to_unused_draft() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let published = test_operation("op_archive_preserve", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &published, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &published.id,
version: 1,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
registry
.archive_operation(
&test_workspace_id(),
&published.id,
&timestamp("2026-03-25T12:20:00Z"),
)
.await
.unwrap();
registry
.archive_operation(
&test_workspace_id(),
&published.id,
&timestamp("2026-03-25T12:21:00Z"),
)
.await
.unwrap();
assert!(matches!(
registry
.delete_operation(&test_workspace_id(), &published.id)
.await,
Err(RegistryError::OperationDeleteForbidden { .. })
));
assert_eq!(
registry
.get_published_operation(&published.id)
.await
.unwrap()
.unwrap()
.status,
OperationStatus::Published
);
let draft = test_operation("op_delete_unused", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &draft, Some("alice"))
.await
.unwrap();
registry
.delete_operation(&test_workspace_id(), &draft.id)
.await
.unwrap();
assert!(
registry
.get_operation_summary(&test_workspace_id(), &draft.id)
.await
.unwrap()
.is_none()
);
database.cleanup().await;
}
#[tokio::test]
async fn stores_auth_profiles_and_artifact_metadata() {
let database = TestDatabase::new().await;
@@ -193,6 +501,12 @@ async fn stores_auth_profiles_and_artifact_metadata() {
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
create_test_secret(
&registry,
&SecretId::new("secret_crank_api_key"),
"Crank API key",
)
.await;
let auth_profile = AuthProfile {
id: "auth_crank".into(),
@@ -275,6 +589,8 @@ async fn lists_auth_profiles_referencing_secret() {
let registry = database.registry().await;
let primary_secret_id = SecretId::new("secret_primary");
let secondary_secret_id = SecretId::new("secret_secondary");
create_test_secret(&registry, &primary_secret_id, "Primary secret").await;
create_test_secret(&registry, &secondary_secret_id, "Secondary secret").await;
let profile = AuthProfile {
id: "auth_crank".into(),
workspace_id: test_workspace_id(),
@@ -247,6 +247,7 @@ async fn lists_default_workspace_first_for_community_sessions() {
&user_id,
Some(&legacy_workspace.id),
secret_hash,
None,
&timestamp("2027-03-25T13:00:00Z"),
)
.await
@@ -368,6 +369,7 @@ async fn creates_and_loads_user_sessions_with_typed_expiration() {
&user_id,
Some(&workspace.id),
"secret-hash-01",
None,
&expires_at,
)
.await
@@ -571,6 +573,8 @@ async fn manages_approval_request_lifecycle() {
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"amount": 100}),
response_payload: None,
created_at: timestamp("2026-03-25T12:01:00Z"),
@@ -647,9 +651,12 @@ async fn manages_approval_request_lifecycle() {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &approval.id,
operation_id: &approval.operation_id,
operation_version: approval.operation_version,
request_payload: &approval.request_payload,
status: ApprovalRequestStatus::Approved,
decided_at: timestamp("2026-03-25T12:02:00Z"),
decided_by_key_id: &approval_key.id,
decided_by_key_id: Some(&approval_key.id),
response_payload: Some(json!({"approve": "yes"})),
decision_note: Some("confirmed in test"),
})
@@ -738,9 +745,12 @@ async fn manages_approval_request_lifecycle() {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &interrupted_approval.id,
operation_id: &interrupted_approval.operation_id,
operation_version: interrupted_approval.operation_version,
request_payload: &interrupted_approval.request_payload,
status: ApprovalRequestStatus::Approved,
decided_at: timestamp("2026-03-25T12:02:20Z"),
decided_by_key_id: &approval_key.id,
decided_by_key_id: Some(&approval_key.id),
response_payload: Some(json!({"approve": "yes"})),
decision_note: None,
})
@@ -810,9 +820,12 @@ async fn manages_approval_request_lifecycle() {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &approval.id,
operation_id: &approval.operation_id,
operation_version: approval.operation_version,
request_payload: &approval.request_payload,
status: ApprovalRequestStatus::Denied,
decided_at: timestamp("2026-03-25T12:03:00Z"),
decided_by_key_id: &approval_key.id,
decided_by_key_id: Some(&approval_key.id),
response_payload: Some(json!({"approve": "no"})),
decision_note: None,
})