diff --git a/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs b/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs index 2e0d8b7..440058e 100644 --- a/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs +++ b/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs @@ -6,17 +6,15 @@ 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"; + "36624ca42a28c1388f9c50e6d6c489a8e5ca1f73441a894d9af89e5e115b542c"; const LEDGERLESS_MCP_FINGERPRINTS_SHA256: &[&str] = &[ // Initial published session table. - "53b58899dc388609cbd83fe8321a48878d610952a39d13b5fc8f05149b703683", + "9240c3d85dbc9eeddb1cd99661ec8f7d8d6ece0e62ec486151dcc951a7f3c81c", // Published session table after supports_elicitation was added. - "250e3e57f02283e9300bdff549305d49a6a875717ec4e4c332a2b9af3f883f09", - // Same published contract after an in-place upgrade from the initial layout. - "23c02db67cf83a3834b1ed7caa417eaafd7bcc69657d598ba831f06aeded4a62", + "ac9f99a7e667552a07480d5d45379dd0a8b529b00ba98855ce6725535198249f", ]; const LEDGERLESS_EXTENSION_FINGERPRINT_SHA256: &str = - "da06947028e3c18cc6ba93a58775722b425fd9989095f069316a146a2f45e921"; + "0809a80c0bb6e80f68d0557006c4b62f2e63556f61f6fdedc5abc324950e2587"; const BASELINE_RELATIONS: &[&str] = &[ "workspaces", @@ -134,7 +132,7 @@ async fn catalog_fingerprint( where n.nspname = current_schema() ), column_rows as ( select jsonb_build_array( - 'column', c.table_name, c.ordinal_position, c.column_name, + 'column', c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable, coalesce(c.column_default, '') ) item from information_schema.columns c diff --git a/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs b/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs index d1bc972..390f81f 100644 --- a/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs +++ b/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs @@ -105,6 +105,185 @@ async fn published_ledgerless_baseline_upgrades_without_data_loss() { ); } +#[tokio::test] +async fn published_ledgerless_historical_column_layout_upgrades_without_data_loss() { + let database_url = + crank_test_support::postgres_schema_url("test_ledgerless_historical_layout").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + ledgerless_v1(&pool).await; + sqlx::raw_sql( + "alter table platform_api_keys + drop column key_kind, + drop column expires_at, + drop column allowed_origins_json; + alter table platform_api_keys + add column key_kind text not null default 'mcp_client', + add column expires_at timestamptz null, + add column allowed_origins_json jsonb not null default '[]'::jsonb; + + drop index approval_requests_pending_fingerprint_idx; + alter table approval_requests + drop column execution_started_at, + drop column execution_attempts, + drop column request_fingerprint, + add column confirmation_title text not null default '', + add column confirmation_body text not null default ''; + alter table approval_requests + alter column confirmation_title drop default, + alter column confirmation_body drop default, + drop column confirmation_title, + drop column confirmation_body, + add column execution_started_at timestamptz null, + add column execution_attempts integer not null default 0, + add column request_fingerprint text null; + create unique index approval_requests_pending_fingerprint_idx + on approval_requests(agent_id, operation_id, operation_version, request_fingerprint) + where status = 'pending' and request_fingerprint is not null; + + insert into operations + (id, workspace_id, name, display_name, protocol, status, created_at, updated_at) + values ('op_historical_layout', 'ws_default', 'historical-layout', 'Historical layout', 'rest', 'draft', now(), now()); + insert into agents + (id, workspace_id, slug, display_name, description, status, created_at, updated_at) + values ('agent_historical_layout', 'ws_default', 'historical-layout', 'Historical layout', '', 'draft', now(), now()); + insert into platform_api_keys + (id, workspace_id, agent_id, name, prefix, secret_hash, key_kind, scopes_json, status, + created_at, expires_at, allowed_origins_json) + values ('key_historical_layout', 'ws_default', 'agent_historical_layout', 'Historical layout', + 'cp_', 'hash', 'admin', '[]'::jsonb, 'active', now(), + '2030-01-02 03:04:05+00'::timestamptz, '[\"https://example.test\"]'::jsonb); + insert into approval_requests + (id, workspace_id, agent_id, operation_id, operation_version, status, risk_level, + request_payload_json, created_at, expires_at, execution_started_at, + execution_attempts, request_fingerprint) + values ('approval_historical_layout', 'ws_default', 'agent_historical_layout', 'op_historical_layout', 1, + 'pending', 'high', '{\"layout\":\"historical\"}'::jsonb, now(), now() + interval '1 hour', + '2029-02-03 04:05:06+00'::timestamptz, 3, 'historical-fingerprint');", + ) + .execute(&pool) + .await + .unwrap(); + + let historical_ordinals: Vec<(String, i32)> = sqlx::query_as( + "select table_name || '.' || column_name, ordinal_position + from information_schema.columns + where table_schema = current_schema() + and ((table_name = 'platform_api_keys' + and column_name in ('key_kind', 'expires_at', 'allowed_origins_json')) + or (table_name = 'approval_requests' + and column_name in ('execution_started_at', 'execution_attempts', 'request_fingerprint'))) + order by table_name, ordinal_position", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + historical_ordinals, + vec![ + ("approval_requests.execution_started_at".to_owned(), 22), + ("approval_requests.execution_attempts".to_owned(), 23), + ("approval_requests.request_fingerprint".to_owned(), 24), + ("platform_api_keys.key_kind".to_owned(), 15), + ("platform_api_keys.expires_at".to_owned(), 16), + ("platform_api_keys.allowed_origins_json".to_owned(), 17), + ], + "fixture must reproduce the published in-place column layout", + ); + + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::MigrationRequired { + current: 0, + target: 13, + } + ); + MigrationAuthority::apply(&pool).await.unwrap(); + + let key_count: i64 = sqlx::query_scalar( + "select count(*) from platform_api_keys + where id = 'key_historical_layout' + and key_kind = 'admin' + and expires_at = '2030-01-02 03:04:05+00'::timestamptz + and allowed_origins_json = '[\"https://example.test\"]'::jsonb", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + key_count, 1, + "platform key row must survive the layout upgrade" + ); + let approval_count: i64 = sqlx::query_scalar( + "select count(*) from approval_requests + where id = 'approval_historical_layout' + and request_payload_json = '{\"layout\":\"historical\"}'::jsonb + and execution_started_at = '2029-02-03 04:05:06+00'::timestamptz + and execution_attempts = 3 + and request_fingerprint = 'historical-fingerprint'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + approval_count, 1, + "approval row must survive the layout upgrade" + ); + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::Current { version: 13 } + ); +} + +#[tokio::test] +async fn published_initial_mcp_layout_is_accepted() { + let database_url = crank_test_support::postgres_schema_url("test_ledgerless_initial_mcp").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);", + ) + .execute(&pool) + .await + .unwrap(); + + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::MigrationRequired { + current: 0, + target: 13, + } + ); + MigrationAuthority::apply(&pool).await.unwrap(); + let supports_elicitation: bool = sqlx::query_scalar( + "select exists ( + select 1 from information_schema.columns + where table_schema = current_schema() + and table_name = 'mcp_transport_sessions' + and column_name = 'supports_elicitation' + )", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(supports_elicitation); + 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; @@ -167,8 +346,78 @@ async fn ledgerless_baseline_with_future_drift_is_rejected_without_writes() { assert!(!core_exists, "rejected adoption must remain read-only"); } +#[tokio::test] +async fn ledgerless_baseline_with_default_drift_is_rejected_without_writes() { + let database_url = crank_test_support::postgres_schema_url("test_ledgerless_default").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + ledgerless_v1(&pool).await; + sqlx::query("alter table workspaces alter column status set default 'active'") + .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_definition").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + ledgerless_v1(&pool).await; + sqlx::query( + "alter table workspaces + add constraint workspaces_status_nonempty check (status <> '')", + ) + .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_rls_drift_is_rejected_without_writes() { + let database_url = crank_test_support::postgres_schema_url("test_ledgerless_rls").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + ledgerless_v1(&pool).await; + sqlx::query("alter table workspaces enable row level security") + .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_nullability_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;