fix(migrations): adopt published ledgerless baseline
CI / Rust Checks (push) Successful in 13m55s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 6m14s
CI / Frontend E2E (push) Successful in 7m11s
CI / Deploy (push) Failing after 44s

This commit is contained in:
2026-09-01 23:01:33 +03:00
parent 527efb510f
commit 2759fde81f
7 changed files with 572 additions and 21 deletions
+1
View File
@@ -11,6 +11,7 @@ mod onboarding_product_events_v11;
mod owned_relations;
mod platform_key_name_reuse_v6;
mod schema_guard;
mod schema_guard_legacy_v1;
mod schema_guard_v10;
mod schema_guard_v11;
mod schema_guard_v12;
@@ -11,6 +11,9 @@ use super::platform_key_name_reuse_v6;
use super::schema_guard::{
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
};
use super::schema_guard_legacy_v1::{
validate_ledgerless_baseline_fingerprint, validate_ledgerless_optional_fingerprints,
};
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
use crate::ext::ExtensionMigration;
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
@@ -389,6 +392,19 @@ impl MigrationAuthority {
if from < 13 {
artifact_cleanup_indexes_v13::apply(&mut transaction, &Self::sequence()[12]).await?;
}
match inspect(&mut transaction).await? {
MigrationPreflight::Current {
version: CURRENT_VERSION,
} => {}
_ => {
return Err(MigrationError::new(
"apply_failed",
"apply.postflight",
Some(CURRENT_VERSION),
"restore_known_good_backup",
));
}
}
transaction
.commit()
.await
@@ -408,6 +424,12 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
owned_exists |= relation_exists(connection, relation).await?;
}
if owned_exists {
if is_ledgerless_legacy_baseline(connection, canonical_exists).await? {
return Ok(MigrationPreflight::MigrationRequired {
current: 0,
target: CURRENT_VERSION,
});
}
return Err(MigrationError::new(
"partial_sequence",
"preflight.core_missing",
@@ -541,6 +563,47 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
Ok(MigrationPreflight::Current { version: current })
}
}
async fn is_ledgerless_legacy_baseline(
connection: &mut PgConnection,
canonical_exists: bool,
) -> Result<bool, MigrationError> {
if canonical_exists {
return Ok(false);
}
for relation in [
"__crank_migration_legacy_audit",
"master_key_identities",
"master_key_rotations",
"admin_bootstrap_contracts",
"admin_login_backoff",
"admin_security_audit_events",
"product_events",
"product_event_daily_rollups",
"onboarding_selections",
"artifact_blobs",
"artifact_sources",
] {
if relation_exists(connection, relation).await? {
return Ok(false);
}
}
for relation in owned_relations::BASELINE {
if !relation_exists(connection, relation).await? {
return Ok(false);
}
}
validate_required_relations(connection, owned_relations::BASELINE, 1).await?;
validate_ledgerless_baseline_fingerprint(connection).await?;
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
if mcp_ledger {
return Ok(false);
}
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
let extension_ledger = relation_exists(connection, "__crank_ext_migrations").await?;
validate_ledgerless_optional_fingerprints(connection, mcp_sessions, extension_ledger).await?;
inspect_optional_legacy_for_ledgerless_baseline(connection).await?;
Ok(true)
}
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)
@@ -577,9 +640,21 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
Ok(())
}
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
inspect_optional_legacy_with_policy(connection, false).await
}
async fn inspect_optional_legacy_for_ledgerless_baseline(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
inspect_optional_legacy_with_policy(connection, true).await
}
async fn inspect_optional_legacy_with_policy(
connection: &mut PgConnection,
allow_sessions_without_ledger: bool,
) -> Result<(), MigrationError> {
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
if mcp_ledger != mcp_sessions {
if mcp_ledger != mcp_sessions && !(allow_sessions_without_ledger && !mcp_ledger && mcp_sessions)
{
return Err(MigrationError::new(
"legacy_conflict",
"preflight.legacy_mcp",
@@ -325,6 +325,15 @@ pub(super) async fn validate_schema_fingerprint(
.iter()
.filter_map(|row| row.try_get::<String, _>("column_name").ok())
.collect::<Vec<_>>();
let required = required
.iter()
.copied()
.filter(|column| {
!(current_version == 1
&& *table == "__crank_ext_migrations"
&& *column == "checksum")
})
.collect::<Vec<_>>();
if actual.len() != required.len()
|| required
.iter()
@@ -337,6 +346,9 @@ pub(super) async fn validate_schema_fingerprint(
if !relation_exists(connection, table).await? {
continue;
}
if current_version == 1 && *table == "__crank_ext_migrations" && *column == "checksum" {
continue;
}
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",
@@ -424,23 +436,25 @@ pub(super) async fn validate_schema_fingerprint(
return Err(schema_error(current_version));
}
}
let required_indexes = [
"mcp_transport_sessions_workspace_agent_idx",
"mcp_transport_sessions_expires_at_idx",
];
for index in required_indexes {
let present = query(
"select exists (select 1 from pg_catalog.pg_indexes
where schemaname = current_schema() and indexname = $1) as present",
)
.bind(index)
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get::<bool, _>("present")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if !present {
return Err(schema_error(current_version));
if relation_exists(connection, "mcp_transport_sessions").await? {
let required_indexes = [
"mcp_transport_sessions_workspace_agent_idx",
"mcp_transport_sessions_expires_at_idx",
];
for index in required_indexes {
let present = query(
"select exists (select 1 from pg_catalog.pg_indexes
where schemaname = current_schema() and indexname = $1) as present",
)
.bind(index)
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.try_get::<bool, _>("present")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if !present {
return Err(schema_error(current_version));
}
}
}
if current_version >= 3 {
@@ -0,0 +1,198 @@
use sha2::{Digest, Sha256};
use sqlx::PgConnection;
use super::authority::MigrationError;
// Exact PostgreSQL 16 catalog contract produced by the last published
// pre-ledger Community schema (commit 8318e4b).
const LEDGERLESS_BASELINE_FINGERPRINT_SHA256: &str =
"0dbd7357a80c0d772de03ea2833013932697187b32fbd7b54be594e224e5f82f";
const LEDGERLESS_MCP_FINGERPRINTS_SHA256: &[&str] = &[
// Initial published session table.
"53b58899dc388609cbd83fe8321a48878d610952a39d13b5fc8f05149b703683",
// Published session table after supports_elicitation was added.
"250e3e57f02283e9300bdff549305d49a6a875717ec4e4c332a2b9af3f883f09",
// Same published contract after an in-place upgrade from the initial layout.
"23c02db67cf83a3834b1ed7caa417eaafd7bcc69657d598ba831f06aeded4a62",
];
const LEDGERLESS_EXTENSION_FINGERPRINT_SHA256: &str =
"da06947028e3c18cc6ba93a58775722b425fd9989095f069316a146a2f45e921";
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",
];
pub(super) async fn validate_ledgerless_baseline_fingerprint(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let actual = catalog_fingerprint(connection, BASELINE_RELATIONS).await?;
if actual == LEDGERLESS_BASELINE_FINGERPRINT_SHA256 {
Ok(())
} else {
Err(fingerprint_error())
}
}
pub(super) async fn validate_ledgerless_optional_fingerprints(
connection: &mut PgConnection,
has_mcp_sessions: bool,
has_extension_ledger: bool,
) -> Result<(), MigrationError> {
if has_mcp_sessions {
let actual = catalog_fingerprint(connection, &["mcp_transport_sessions"]).await?;
if !LEDGERLESS_MCP_FINGERPRINTS_SHA256.contains(&actual.as_str()) {
return Err(fingerprint_error());
}
}
if has_extension_ledger {
let actual = catalog_fingerprint(connection, &["__crank_ext_migrations"]).await?;
if actual != LEDGERLESS_EXTENSION_FINGERPRINT_SHA256 {
return Err(fingerprint_error());
}
}
Ok(())
}
async fn catalog_fingerprint(
connection: &mut PgConnection,
relations: &[&str],
) -> Result<String, MigrationError> {
let relations = relations
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>();
let unsafe_catalog_state = sqlx::query_scalar::<_, bool>(
"with selected(table_name) as (select unnest($1::text[]))
select
exists (
select 1 from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
join selected s on s.table_name = c.relname
where n.nspname = current_schema()
and (c.relpersistence <> 'p' or c.relrowsecurity
or c.relforcerowsecurity or c.relreplident <> 'd')
)
or exists (
select 1 from pg_catalog.pg_index i
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join selected s on s.table_name = t.relname
where n.nspname = current_schema()
and (not i.indisvalid or not i.indisready or not i.indislive)
)
or exists (
select 1 from pg_catalog.pg_policy p
join pg_catalog.pg_class t on t.oid = p.polrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join selected s on s.table_name = t.relname
where n.nspname = current_schema()
)
or 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
join selected s on s.table_name = t.relname
where n.nspname = current_schema() and tg.tgenabled <> 'O'
)",
)
.bind(relations.clone())
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.legacy_fingerprint"))?;
if unsafe_catalog_state {
return Err(fingerprint_error());
}
let fingerprint = sqlx::query_scalar::<_, String>(
"with baseline(table_name) as (select unnest($1::text[])), relation_rows as (
select jsonb_build_array('relation', c.relname, c.relkind::text) item
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
join baseline b on b.table_name = c.relname
where n.nspname = current_schema()
), column_rows as (
select jsonb_build_array(
'column', c.table_name, c.ordinal_position, c.column_name,
c.data_type, c.udt_name, c.is_nullable, coalesce(c.column_default, '')
) item
from information_schema.columns c
join baseline b using (table_name)
where c.table_schema = current_schema()
), constraint_rows as (
select jsonb_build_array(
'constraint', t.relname, c.conname, c.contype::text,
c.convalidated, pg_get_constraintdef(c.oid, true)
) item
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
join baseline b on b.table_name = t.relname
where n.nspname = current_schema()
), index_rows as (
select jsonb_build_array(
'index', t.relname, idx.relname,
replace(pg_get_indexdef(i.indexrelid), format('%I.', current_schema()), '')
) item
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 baseline b on b.table_name = t.relname
where n.nspname = current_schema()
), trigger_rows as (
select jsonb_build_array(
'trigger', t.relname, tg.tgname,
replace(pg_get_triggerdef(tg.oid, true), format('%I.', current_schema()), '')
) item
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
join baseline b on b.table_name = t.relname
where n.nspname = current_schema() and not tg.tgisinternal
), all_rows as (
select item from relation_rows
union all select item from column_rows
union all select item from constraint_rows
union all select item from index_rows
union all select item from trigger_rows
)
select coalesce(jsonb_agg(item order by item::text), '[]'::jsonb)::text
from all_rows",
)
.bind(relations)
.fetch_one(connection)
.await
.map_err(|_| MigrationError::storage("preflight.legacy_fingerprint"))?;
Ok(format!("{:x}", Sha256::digest(fingerprint.as_bytes())))
}
fn fingerprint_error() -> MigrationError {
MigrationError::new(
"partial_sequence",
"preflight.legacy_fingerprint",
Some(1),
"restore_known_good_backup",
)
}
@@ -2,6 +2,7 @@ use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
use sqlx::Row;
mod artifact_metadata;
mod legacy_adoption;
mod rollback;
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -153,7 +154,7 @@ async fn changed_checksum_fails_closed_without_repair() {
);
}
#[tokio::test]
async fn legacy_core_baseline_is_consolidated_without_data_loss() {
async fn ledgerless_legacy_baseline_is_consolidated_without_data_loss() {
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
@@ -221,7 +222,8 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
}
sqlx::query(
"drop table __crank_migrations, __crank_migration_legacy_audit,
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations,
__crank_core_migrations",
)
.execute(&pool)
.await
@@ -229,7 +231,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 1,
current: 0,
target: 13,
}
);
@@ -0,0 +1,190 @@
use super::*;
async fn ledgerless_v1(pool: &sqlx::PgPool) {
MigrationAuthority::apply(pool).await.unwrap();
remove_v4_schema(pool).await;
remove_v3_schema(pool).await;
sqlx::raw_sql(
"drop table __crank_migrations, __crank_migration_legacy_audit;
drop table __crank_mcp_migrations;
drop index mcp_transport_sessions_expires_at_idx;
drop table __crank_ext_migrations;
create table __crank_ext_migrations (
extension_name text not null,
version integer not null,
applied_at timestamptz not null default now(),
primary key (extension_name, version)
);
drop table __crank_core_migrations;",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn ledgerless_baseline_with_optional_index_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_index").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"drop index mcp_transport_sessions_workspace_agent_idx;
create index mcp_transport_sessions_workspace_agent_idx
on mcp_transport_sessions(id);",
)
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_disabled_integrity_triggers_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_triggers").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table memberships disable trigger all")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn published_ledgerless_baseline_upgrades_without_data_loss() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_baseline").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query(
"insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_ledgerless', 'ws_default', 'ledgerless', 'Ledgerless', 'rest', 'draft', now(), now())",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let operation_count: i64 =
sqlx::query_scalar("select count(*) from operations where id = 'op_ledgerless'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(operation_count, 1);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn published_in_place_mcp_upgrade_layout_is_accepted() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_mcp_upgrade").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"drop table mcp_transport_sessions;
create table mcp_transport_sessions (
id text primary key,
protocol_version text not null,
initialized boolean not null default false,
workspace_slug text not null,
agent_slug text not null,
created_at timestamptz not null,
updated_at timestamptz not null,
expires_at timestamptz null
);
create index mcp_transport_sessions_workspace_agent_idx
on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc);
alter table mcp_transport_sessions
add column supports_elicitation boolean not null default false;",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn ledgerless_baseline_with_future_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_drift").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table invocation_logs add column trace_id text")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_constraint_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_constraint").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table workspaces alter column status drop not null")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}