feat: complete Epic 1 production foundation
This commit is contained in:
@@ -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;
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user