From 71c0f728fed9cbcae6fadc1f5bdaf7e4205c4189 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:55:18 -0500 Subject: [PATCH] feat(db): add lossless identity migration state Preserve published binding history while projecting authoritative lifecycle state into additive tables. Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/migration.rs | 463 +++++++++++++- .../0029_additive_identity_binding_state.sql | 600 ++++++++++++++++++ schema/schema.sql | 204 +++++- 3 files changed, 1265 insertions(+), 2 deletions(-) create mode 100644 migrations/0029_additive_identity_binding_state.sql diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 615f71f41..ad766317f 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 28); + assert_eq!(migrations.len(), 29); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -953,6 +953,107 @@ mod tests { .sql .as_str() .contains("CREATE TABLE identity_revoked_keys")); + + // O3 is a brownfield-safe additive projection over the frozen 0027/0028 + // identity tables. It must not rewrite or discard legacy authority. + assert_eq!(migrations[28].version, 29); + let o3 = migrations[28].sql.as_str(); + for required in [ + "ADD COLUMN binding_id", + "ADD COLUMN binding_version", + "ADD COLUMN binding_state", + "ADD COLUMN binding_provenance", + "CREATE TABLE identity_retired_pairs", + "CREATE TABLE identity_pending_replacements", + "CREATE TABLE identity_binding_history", + "CREATE TABLE identity_lifecycle_operations", + "CREATE TABLE identity_migration_denials", + ] { + assert!( + o3.contains(required), + "migration 0029 is missing {required}" + ); + } + } + + fn o3_executable_sql(sql: &str) -> String { + let mut output = String::with_capacity(sql.len()); + let mut chars = sql.chars().peekable(); + let mut in_line_comment = false; + let mut in_string = false; + while let Some(ch) = chars.next() { + if in_line_comment { + if ch == '\n' { + in_line_comment = false; + output.push(ch); + } + continue; + } + if !in_string && ch == '-' && chars.peek() == Some(&'-') { + chars.next(); + in_line_comment = true; + continue; + } + if ch == '\'' { + if in_string && chars.peek() == Some(&'\'') { + chars.next(); + continue; + } + in_string = !in_string; + output.push(' '); + continue; + } + output.push(if in_string { ' ' } else { ch }); + } + output + } + + #[test] + fn migration_0029_is_strictly_additive() { + let migration = MIGRATOR + .iter() + .find(|migration| migration.version == 29) + .expect("migration 0029"); + let executable = o3_executable_sql(migration.sql.as_str()); + let normalized = normalize_sql(&executable); + + for forbidden in [" rename ", " drop ", " truncate ", " delete "] { + assert!( + !format!(" {normalized} ").contains(forbidden), + "migration 0029 contains forbidden legacy mutation: {forbidden}" + ); + } + assert!(!normalized.contains("update identity_revoked_keys")); + assert!(!normalized.contains("update identity_principals")); + assert!(!normalized.contains("insert into identity_revoked_keys")); + + let allowed_binding_columns = [ + "binding_id", + "binding_version", + "binding_state", + "binding_provenance", + "replacement_binding_id", + ]; + for statement in split_sql_statements(&executable) { + let statement = normalize_sql(&statement); + if !statement.starts_with("update identity_bindings ") { + continue; + } + let assignments = statement + .split_once(" set ") + .map(|(_, tail)| tail.split_once(" where ").map_or(tail, |(set, _)| set)) + .expect("identity binding metadata update has SET clause"); + for assignment in split_top_level_csv(assignments) { + let column = assignment + .split_once('=') + .map(|(column, _)| column.trim()) + .expect("metadata assignment"); + assert!( + allowed_binding_columns.contains(&column), + "migration 0029 rewrites legacy identity_bindings.{column}" + ); + } + } } #[test] @@ -1128,6 +1229,73 @@ mod tests { .expect("read applied migrations") } + #[derive(Debug, Clone, PartialEq, Eq)] + struct LegacyIdentitySnapshot { + bindings: Vec, + principals: Vec, + revoked_keys: Vec, + catalog: Vec, + } + + async fn legacy_identity_snapshot(pool: &PgPool) -> LegacyIdentitySnapshot { + async fn json_rows(pool: &PgPool, query: &'static str) -> Vec { + let mut rows = sqlx::query_scalar::<_, serde_json::Value>(query) + .fetch_all(pool) + .await + .expect("snapshot identity rows") + .into_iter() + .map(|value| value.to_string()) + .collect::>(); + rows.sort(); + rows + } + + let bindings = json_rows( + pool, + "SELECT to_jsonb(binding) - ARRAY[\ + 'binding_id', 'binding_version', 'binding_state',\ + 'binding_provenance', 'replacement_binding_id', 'created_by',\ + 'created_policy_version']::text[] \ + FROM identity_bindings binding", + ) + .await; + let principals = json_rows( + pool, + "SELECT to_jsonb(principal) FROM identity_principals principal", + ) + .await; + let revoked_keys = json_rows( + pool, + "SELECT to_jsonb(revoked) FROM identity_revoked_keys revoked", + ) + .await; + let mut catalog = sqlx::query_scalar::<_, String>( + r#" + SELECT definition FROM ( + SELECT 'constraint:' || conrelid::regclass::text || ':' || conname || ':' || pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE conrelid IN ('identity_bindings'::regclass, 'identity_principals'::regclass, 'identity_revoked_keys'::regclass) + UNION ALL + SELECT 'index:' || tablename || ':' || indexname || ':' || indexdef + FROM pg_indexes + WHERE schemaname='public' + AND tablename IN ('identity_bindings', 'identity_principals', 'identity_revoked_keys') + ) definitions + ORDER BY definition + "#, + ) + .fetch_all(pool) + .await + .expect("snapshot identity catalog"); + catalog.sort(); + LegacyIdentitySnapshot { + bindings, + principals, + revoked_keys, + catalog, + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn pre_0007_ambiguous_nip_rs_data_blocks_without_mutation_and_allows_retry() { @@ -1258,6 +1426,299 @@ mod tests { assert_eq!(after, vec![(1, Some(true)), (30_350, None)]); } + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn identity_0029_additive_upgrade_preserves_legacy_state_and_handles_history() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(28, &pool) + .await + .expect("apply migrations through legacy identity lifecycle"); + + let domain_a = uuid::Uuid::new_v4(); + let domain_b = uuid::Uuid::new_v4(); + for (domain, label) in [(domain_a, "a"), (domain_b, "b")] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(domain) + .bind(format!("identity-0029-{label}-{}.example", domain.simple())) + .execute(&pool) + .await + .expect("insert fixture community"); + } + + let active_key = vec![1_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \ + VALUES ($1, ' Issuer ', ' Subject ', $2, 'jwt_npub', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z')", + ) + .bind(domain_a) + .bind(&active_key) + .execute(&pool) + .await + .expect("insert literal active principal"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \ + VALUES ($1, ' Issuer ', ' Subject ', $2, 'jwt_npub', \ + TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z')", + ) + .bind(domain_b) + .bind(&active_key) + .execute(&pool) + .await + .expect("insert cross-domain control"); + + let chain_keys = [vec![10_u8; 32], vec![11_u8; 32], vec![12_u8; 32]]; + for (index, key) in chain_keys.iter().enumerate() { + if let Some(successor) = chain_keys.get(index + 1) { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at, \ + revoked_at, revoked_reason, revocation_scope, rotation_completed_at, \ + rotated_to_pubkey, rotation_reason) \ + VALUES ($1, 'https://idp.example', 'chain', $2, 'db_binding', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + 'legacy rotation', 'rotation', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + $3, 'legacy rotation')", + ) + .bind(domain_a) + .bind(key) + .bind(successor) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("insert retired chain node {index}: {error}")); + } else { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \ + VALUES ($1, 'https://idp.example', 'chain', $2, 'db_binding', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \ + TIMESTAMPTZ '2025-02-01 00:00:00Z')", + ) + .bind(domain_a) + .bind(key) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("insert active chain node {index}: {error}")); + } + } + for old in chain_keys.iter().take(chain_keys.len() - 1) { + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \ + VALUES ($1, $2, TIMESTAMPTZ '2025-02-01 00:00:00Z', 'legacy rotation')", + ) + .bind(domain_a) + .bind(old) + .execute(&pool) + .await + .expect("insert legacy rotation tombstone"); + } + + let revoked_key = vec![20_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, revoked_at, revoked_reason, revocation_scope) \ + VALUES ($1, 'https://idp.example', 'pending', $2, 'db_binding', NOW(), 'explicit revoke', 'key')", + ) + .bind(domain_a) + .bind(&revoked_key) + .execute(&pool) + .await + .expect("insert pending revoked binding"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \ + VALUES ($1, $2, TIMESTAMPTZ '2025-03-01 00:00:00Z', 'explicit revoke')", + ) + .bind(domain_a) + .bind(&revoked_key) + .execute(&pool) + .await + .expect("insert explicit key selector"); + + sqlx::query( + "INSERT INTO identity_principals \ + (community_id, issuer, uid, disabled_at, disabled_reason) \ + VALUES ($1, 'https://idp.example', 'never-enrolled', NOW(), 'disabled before enrollment')", + ) + .bind(domain_a) + .execute(&pool) + .await + .expect("insert disabled never-enrolled principal"); + + let missing_old = vec![30_u8; 32]; + let missing_target = vec![31_u8; 32]; + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, revoked_at, revoked_reason, revocation_scope, \ + rotation_completed_at, rotated_to_pubkey, rotation_reason) \ + VALUES ($1, 'https://idp.example', 'ambiguous', $2, 'jwt_npub', NOW(), \ + 'missing successor', 'rotation', NOW(), $3, 'missing successor')", + ) + .bind(domain_a) + .bind(&missing_old) + .bind(&missing_target) + .execute(&pool) + .await + .expect("insert missing-lineage fixture"); + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id, pubkey, reason) VALUES ($1, $2, 'ambiguous legacy selector')", + ) + .bind(domain_a) + .bind(&missing_old) + .execute(&pool) + .await + .expect("insert ambiguous selector"); + + let before = legacy_identity_snapshot(&pool).await; + run_migrations(&pool) + .await + .expect("additive identity migration succeeds on populated history"); + let after = legacy_identity_snapshot(&pool).await; + assert_eq!(after.bindings, before.bindings, "legacy bindings changed"); + assert_eq!( + after.principals, before.principals, + "legacy principals changed" + ); + assert_eq!(after.revoked_keys, before.revoked_keys, "legacy Y changed"); + assert!( + before + .catalog + .iter() + .all(|definition| after.catalog.contains(definition)), + "legacy identity constraints/indexes must remain unchanged" + ); + + let chain: Vec<(Vec, i64, Option>)> = sqlx::query_as( + "SELECT binding.pubkey, binding.binding_version, replacement.pubkey \ + FROM identity_bindings binding \ + LEFT JOIN identity_bindings replacement \ + ON replacement.community_id=binding.community_id \ + AND replacement.binding_id=binding.replacement_binding_id \ + WHERE binding.community_id=$1 AND binding.issuer='https://idp.example' AND binding.uid='chain' \ + ORDER BY binding.binding_version", + ) + .bind(domain_a) + .fetch_all(&pool) + .await + .expect("read migrated chain"); + assert_eq!(chain.len(), 3); + assert_eq!( + chain[0], + (chain_keys[0].clone(), 1, Some(chain_keys[1].clone())) + ); + assert_eq!( + chain[1], + (chain_keys[1].clone(), 2, Some(chain_keys[2].clone())) + ); + assert_eq!(chain[2], (chain_keys[2].clone(), 3, None)); + + let pending: (Vec, i64, i64) = sqlx::query_as( + "SELECT retired_pubkey, retired_binding_version, selector_version \ + FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer='https://idp.example' AND subject='pending' AND cleared_at IS NULL", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read pending selector"); + assert_eq!(pending, (revoked_key, 1, 1)); + + let quarantined: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM identity_migration_denials \ + WHERE community_id=$1 AND issuer='https://idp.example' AND subject='ambiguous')", + ) + .bind(domain_a) + .fetch_one(&pool) + .await + .expect("read migration quarantine"); + assert!(quarantined); + } + + #[tokio::test] + #[ignore = "requires a dedicated disposable Postgres database"] + async fn identity_0029_failure_rolls_back_every_additive_projection() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(28, &pool) + .await + .expect("apply migrations through legacy identity lifecycle"); + let domain = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(domain) + .bind(format!( + "identity-0029-rollback-{}.example", + domain.simple() + )) + .execute(&pool) + .await + .expect("insert rollback community"); + sqlx::query( + "INSERT INTO identity_bindings (community_id, issuer, uid, pubkey, source) \ + VALUES ($1,'https://idp.example','rollback',$2,'db_binding')", + ) + .bind(domain) + .bind(vec![99_u8; 32]) + .execute(&pool) + .await + .expect("insert rollback fixture"); + sqlx::query( + "CREATE FUNCTION reject_o3_projection() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN RAISE EXCEPTION 'injected O3 projection failure'; END $$", + ) + .execute(&pool) + .await + .expect("create failure function"); + sqlx::query( + "CREATE TRIGGER reject_o3_projection BEFORE UPDATE ON identity_bindings \ + FOR EACH ROW EXECUTE FUNCTION reject_o3_projection()", + ) + .execute(&pool) + .await + .expect("create failure trigger"); + + assert!(MIGRATOR.run_to(29, &pool).await.is_err()); + let projected_tables: (Option, Option, Option) = sqlx::query_as( + "SELECT to_regclass('identity_retired_pairs')::text, \ + to_regclass('identity_pending_replacements')::text, \ + to_regclass('identity_binding_history')::text", + ) + .fetch_one(&pool) + .await + .expect("inspect rolled back tables"); + assert_eq!(projected_tables, (None, None, None)); + let projected_columns: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns \ + WHERE table_schema='public' AND table_name='identity_bindings' \ + AND column_name IN ('binding_id','binding_version','binding_state','binding_provenance')", + ) + .fetch_one(&pool) + .await + .expect("inspect rolled back columns"); + assert_eq!(projected_columns, 0); + let latest: i64 = + sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success") + .fetch_one(&pool) + .await + .expect("read latest migration"); + assert_eq!(latest, 28); + sqlx::query("DROP TRIGGER reject_o3_projection ON identity_bindings") + .execute(&pool) + .await + .expect("drop failure trigger"); + sqlx::query("DROP FUNCTION reject_o3_projection()") + .execute(&pool) + .await + .expect("drop failure function"); + run_migrations(&pool) + .await + .expect("retry additive projection after rollback"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { diff --git a/migrations/0029_additive_identity_binding_state.sql b/migrations/0029_additive_identity_binding_state.sql new file mode 100644 index 000000000..f869c027c --- /dev/null +++ b/migrations/0029_additive_identity_binding_state.sql @@ -0,0 +1,600 @@ +-- Additive O3 identity-binding projection. +-- +-- Migrations 0027/0028 are a frozen compatibility boundary. This migration +-- never renames or removes their columns, constraints, indexes, rows, or +-- lifecycle selectors. In particular, every legacy identity_revoked_keys row +-- remains authoritative because rotation-created and explicitly strengthened +-- tombstones cannot be distinguished after the fact. + +ALTER TABLE identity_bindings + ADD COLUMN binding_id UUID, + ADD COLUMN binding_version BIGINT, + ADD COLUMN binding_state TEXT, + ADD COLUMN binding_provenance TEXT, + ADD COLUMN replacement_binding_id UUID, + ADD COLUMN created_by BYTEA, + ADD COLUMN created_policy_version TEXT; + +-- Every row receives a stable persisted identifier. Unique legacy exact pairs +-- use a reproducible length-prefixed hash. Byte-identical duplicate rows lack +-- a legacy row identifier, so they retain random persisted IDs and their exact +-- principal is quarantined below. +UPDATE identity_bindings +SET binding_id = gen_random_uuid(); + +WITH unique_pairs AS ( + SELECT + community_id, + issuer, + uid, + pubkey, + ( + substr(fingerprint, 1, 8) || '-' || + substr(fingerprint, 9, 4) || '-' || + substr(fingerprint, 13, 4) || '-' || + substr(fingerprint, 17, 4) || '-' || + substr(fingerprint, 21, 12) + )::UUID AS stable_id + FROM ( + SELECT + community_id, + issuer, + uid, + pubkey, + encode( + digest( + E'\\x01'::BYTEA || + uuid_send(community_id) || + int8send(octet_length(convert_to(issuer, 'UTF8'))::BIGINT) || + convert_to(issuer, 'UTF8') || + int8send(octet_length(convert_to(uid, 'UTF8'))::BIGINT) || + convert_to(uid, 'UTF8') || + int8send(octet_length(pubkey)::BIGINT) || + pubkey, + 'sha256' + ), + 'hex' + ) AS fingerprint + FROM identity_bindings + GROUP BY community_id, issuer, uid, pubkey + HAVING COUNT(*) = 1 + ) fingerprints +) +UPDATE identity_bindings binding +SET binding_id = unique_pairs.stable_id +FROM unique_pairs +WHERE binding.community_id = unique_pairs.community_id + AND binding.issuer = unique_pairs.issuer + AND binding.uid = unique_pairs.uid + AND binding.pubkey = unique_pairs.pubkey; + +UPDATE identity_bindings +SET binding_state = CASE + WHEN rotation_completed_at IS NOT NULL THEN 'rotated' + WHEN revoked_at IS NOT NULL THEN 'revoked' + ELSE 'active' + END, + binding_provenance = CASE source + WHEN 'jwt_npub' THEN 'attested_key' + ELSE 'tofu' + END; + +ALTER TABLE identity_bindings + ALTER COLUMN binding_id SET NOT NULL, + ALTER COLUMN binding_id SET DEFAULT gen_random_uuid(), + ALTER COLUMN binding_state SET NOT NULL, + ALTER COLUMN binding_state SET DEFAULT 'active', + ALTER COLUMN binding_provenance SET NOT NULL, + ALTER COLUMN binding_provenance SET DEFAULT 'tofu', + ADD CONSTRAINT identity_bindings_o3_id_unique + UNIQUE (community_id, binding_id), + ADD CONSTRAINT chk_identity_bindings_o3_id_not_nil + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID), + ADD CONSTRAINT chk_identity_bindings_o3_state + CHECK (binding_state IN ('active', 'revoked', 'rotated')), + ADD CONSTRAINT chk_identity_bindings_o3_provenance + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + ADD CONSTRAINT chk_identity_bindings_o3_created_by_len + CHECK (created_by IS NULL OR length(created_by) = 32), + ADD CONSTRAINT chk_identity_bindings_o3_policy_version + CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0); + +-- Invalid legacy graphs remain stored verbatim but are not usable as binding +-- authority. O3 exposes no operation that clears these migration denials. +CREATE TABLE identity_migration_denials ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + reason TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, issuer, subject), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(reason) > 0) +); + +INSERT INTO identity_migration_denials (community_id, issuer, subject, reason) +SELECT community_id, issuer, uid, 'duplicate legacy exact-pair rows' +FROM identity_bindings +GROUP BY community_id, issuer, uid, pubkey +HAVING COUNT(*) > 1 +ON CONFLICT (community_id, issuer, subject) DO NOTHING; + +-- A valid legacy principal is one complete, non-branching, acyclic chain. Edge +-- candidates include inactive replacements and ignore timestamps because NOW() +-- is transaction-stable in the legacy rotation helper. +WITH RECURSIVE +candidate_edges AS ( + SELECT + predecessor.community_id, + predecessor.issuer, + predecessor.uid, + predecessor.binding_id AS predecessor_id, + successor.binding_id AS successor_id, + COUNT(*) OVER ( + PARTITION BY predecessor.community_id, predecessor.binding_id + ) AS outgoing_candidates + FROM identity_bindings predecessor + JOIN identity_bindings successor + ON successor.community_id = predecessor.community_id + AND successor.issuer = predecessor.issuer + AND successor.uid = predecessor.uid + AND successor.pubkey = predecessor.rotated_to_pubkey + AND successor.binding_id <> predecessor.binding_id + WHERE predecessor.rotation_completed_at IS NOT NULL +), +resolved_edges AS ( + SELECT community_id, issuer, uid, predecessor_id, successor_id + FROM candidate_edges + WHERE outgoing_candidates = 1 +), +principals AS ( + SELECT community_id, issuer, uid, COUNT(*)::BIGINT AS node_count + FROM identity_bindings + GROUP BY community_id, issuer, uid +), +roots AS ( + SELECT node.community_id, node.issuer, node.uid, node.binding_id + FROM identity_bindings node + WHERE NOT EXISTS ( + SELECT 1 + FROM resolved_edges edge + WHERE edge.community_id = node.community_id + AND edge.successor_id = node.binding_id + ) +), +reachable AS ( + SELECT root.community_id, root.issuer, root.uid, root.binding_id + FROM roots root + UNION + SELECT edge.community_id, edge.issuer, edge.uid, edge.successor_id + FROM reachable current_node + JOIN resolved_edges edge + ON edge.community_id = current_node.community_id + AND edge.predecessor_id = current_node.binding_id +), +graph_stats AS ( + SELECT + principal.community_id, + principal.issuer, + principal.uid, + principal.node_count, + COUNT(DISTINCT edge.predecessor_id)::BIGINT AS edge_count, + COUNT(DISTINCT root.binding_id)::BIGINT AS root_count, + COUNT(DISTINCT reached.binding_id)::BIGINT AS reached_count, + COALESCE(MAX(incoming.incoming_count), 0)::BIGINT AS max_incoming + FROM principals principal + LEFT JOIN resolved_edges edge + ON edge.community_id = principal.community_id + AND edge.issuer = principal.issuer + AND edge.uid = principal.uid + LEFT JOIN roots root + ON root.community_id = principal.community_id + AND root.issuer = principal.issuer + AND root.uid = principal.uid + LEFT JOIN reachable reached + ON reached.community_id = principal.community_id + AND reached.issuer = principal.issuer + AND reached.uid = principal.uid + LEFT JOIN ( + SELECT community_id, issuer, uid, successor_id, COUNT(*)::BIGINT AS incoming_count + FROM resolved_edges + GROUP BY community_id, issuer, uid, successor_id + ) incoming + ON incoming.community_id = principal.community_id + AND incoming.issuer = principal.issuer + AND incoming.uid = principal.uid + GROUP BY principal.community_id, principal.issuer, principal.uid, principal.node_count +), +unresolved_rotations AS ( + SELECT predecessor.community_id, predecessor.issuer, predecessor.uid + FROM identity_bindings predecessor + LEFT JOIN candidate_edges edge + ON edge.community_id = predecessor.community_id + AND edge.predecessor_id = predecessor.binding_id + WHERE predecessor.rotation_completed_at IS NOT NULL + GROUP BY predecessor.community_id, predecessor.issuer, predecessor.uid, predecessor.binding_id + HAVING COUNT(edge.successor_id) <> 1 + OR COALESCE(MAX(edge.outgoing_candidates), 0) <> 1 +), +invalid_principals AS ( + SELECT community_id, issuer, uid FROM unresolved_rotations + UNION + SELECT community_id, issuer, uid + FROM graph_stats + WHERE edge_count <> node_count - 1 + OR root_count <> 1 + OR reached_count <> node_count + OR max_incoming > 1 +) +INSERT INTO identity_migration_denials (community_id, issuer, subject, reason) +SELECT community_id, issuer, uid, 'ambiguous legacy replacement lineage' +FROM invalid_principals +ON CONFLICT (community_id, issuer, subject) DO NOTHING; + +-- Topological versions are deterministic for valid histories. Quarantined +-- rows receive stable positive versions solely for attribution, never auth. +WITH RECURSIVE +candidate_edges AS ( + SELECT + predecessor.community_id, + predecessor.issuer, + predecessor.uid, + predecessor.binding_id AS predecessor_id, + successor.binding_id AS successor_id, + COUNT(*) OVER ( + PARTITION BY predecessor.community_id, predecessor.binding_id + ) AS outgoing_candidates + FROM identity_bindings predecessor + JOIN identity_bindings successor + ON successor.community_id = predecessor.community_id + AND successor.issuer = predecessor.issuer + AND successor.uid = predecessor.uid + AND successor.pubkey = predecessor.rotated_to_pubkey + AND successor.binding_id <> predecessor.binding_id + WHERE predecessor.rotation_completed_at IS NOT NULL +), +resolved_edges AS ( + SELECT community_id, issuer, uid, predecessor_id, successor_id + FROM candidate_edges + WHERE outgoing_candidates = 1 +), +roots AS ( + SELECT node.community_id, node.issuer, node.uid, node.binding_id + FROM identity_bindings node + WHERE NOT EXISTS ( + SELECT 1 FROM resolved_edges edge + WHERE edge.community_id = node.community_id + AND edge.successor_id = node.binding_id + ) +), +walk AS ( + SELECT root.community_id, root.issuer, root.uid, root.binding_id, 1::BIGINT AS binding_version + FROM roots root + WHERE NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id = root.community_id + AND denial.issuer = root.issuer + AND denial.subject = root.uid + ) + UNION ALL + SELECT edge.community_id, edge.issuer, edge.uid, edge.successor_id, walk.binding_version + 1 + FROM walk + JOIN resolved_edges edge + ON edge.community_id = walk.community_id + AND edge.predecessor_id = walk.binding_id +), +quarantined AS ( + SELECT + binding.community_id, + binding.binding_id, + ROW_NUMBER() OVER ( + PARTITION BY binding.community_id, binding.issuer, binding.uid + ORDER BY binding.created_at, binding.updated_at, + encode(binding.pubkey, 'hex'), binding.binding_id + )::BIGINT AS binding_version + FROM identity_bindings binding + JOIN identity_migration_denials denial + ON denial.community_id = binding.community_id + AND denial.issuer = binding.issuer + AND denial.subject = binding.uid +), +versions AS ( + SELECT community_id, binding_id, binding_version FROM walk + UNION ALL + SELECT community_id, binding_id, binding_version FROM quarantined +) +UPDATE identity_bindings binding +SET binding_version = versions.binding_version +FROM versions +WHERE binding.community_id = versions.community_id + AND binding.binding_id = versions.binding_id; + +ALTER TABLE identity_bindings + ALTER COLUMN binding_version SET NOT NULL, + ALTER COLUMN binding_version SET DEFAULT 1, + ADD CONSTRAINT chk_identity_bindings_o3_version_positive + CHECK (binding_version > 0), + ADD CONSTRAINT identity_bindings_o3_principal_version_unique + UNIQUE (community_id, issuer, uid, binding_version); + +CREATE TABLE identity_binding_lineage ( + community_id UUID NOT NULL REFERENCES communities(id), + predecessor_binding_id UUID NOT NULL, + successor_binding_id UUID NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, predecessor_binding_id), + UNIQUE (community_id, successor_binding_id), + FOREIGN KEY (community_id, predecessor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, successor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (predecessor_binding_id <> successor_binding_id) +); + +WITH candidate_edges AS ( + SELECT + predecessor.community_id, + predecessor.issuer, + predecessor.uid, + predecessor.binding_id AS predecessor_id, + successor.binding_id AS successor_id, + COUNT(*) OVER ( + PARTITION BY predecessor.community_id, predecessor.binding_id + ) AS outgoing_candidates + FROM identity_bindings predecessor + JOIN identity_bindings successor + ON successor.community_id = predecessor.community_id + AND successor.issuer = predecessor.issuer + AND successor.uid = predecessor.uid + AND successor.pubkey = predecessor.rotated_to_pubkey + AND successor.binding_id <> predecessor.binding_id + WHERE predecessor.rotation_completed_at IS NOT NULL +) +INSERT INTO identity_binding_lineage + (community_id, predecessor_binding_id, successor_binding_id) +SELECT edge.community_id, edge.predecessor_id, edge.successor_id +FROM candidate_edges edge +WHERE edge.outgoing_candidates = 1 + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id = edge.community_id + AND denial.issuer = edge.issuer + AND denial.subject = edge.uid + ); + +UPDATE identity_bindings predecessor +SET replacement_binding_id = lineage.successor_binding_id +FROM identity_binding_lineage lineage +WHERE lineage.community_id = predecessor.community_id + AND lineage.predecessor_binding_id = predecessor.binding_id; + +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_o3_replacement_fk + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE identity_retired_pairs ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + retired_binding_id UUID, + retired_binding_version BIGINT, + retired_at TIMESTAMPTZ NOT NULL, + retired_by BYTEA, + reason TEXT NOT NULL, + PRIMARY KEY (community_id, issuer, subject, pubkey), + UNIQUE ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + FOREIGN KEY (community_id, retired_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (retired_binding_version IS NULL OR retired_binding_version > 0), + CHECK ( + (retired_binding_id IS NULL AND retired_binding_version IS NULL) + OR + (retired_binding_id IS NOT NULL AND retired_binding_version IS NOT NULL) + ), + CHECK (retired_by IS NULL OR length(retired_by) = 32), + CHECK (length(reason) > 0) +); + +INSERT INTO identity_retired_pairs + (community_id, issuer, subject, pubkey, retired_binding_id, + retired_binding_version, retired_at, retired_by, reason) +SELECT + community_id, + issuer, + uid, + pubkey, + CASE WHEN COUNT(*) = 1 THEN (array_agg(binding_id))[1] END, + CASE WHEN COUNT(*) = 1 THEN (array_agg(binding_version))[1] END, + MIN(COALESCE(revoked_at, rotation_completed_at, updated_at)), + CASE WHEN COUNT(*) = 1 THEN (array_agg(COALESCE(rotation_by, revoked_by)))[1] END, + MIN(COALESCE(NULLIF(rotation_reason, ''), NULLIF(revoked_reason, ''), 'legacy pair retirement')) +FROM identity_bindings +WHERE revoked_at IS NOT NULL +GROUP BY community_id, issuer, uid, pubkey; + +-- Q is append-only history. cleared_at marks selector absence while retaining +-- the selector version so recreation of the same tuple cannot cause ABA. +CREATE TABLE identity_pending_replacements ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + selector_version BIGINT NOT NULL, + retired_pubkey BYTEA NOT NULL, + retired_binding_id UUID NOT NULL, + retired_binding_version BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_operation_id UUID, + cleared_at TIMESTAMPTZ, + cleared_operation_id UUID, + PRIMARY KEY (community_id, issuer, subject, selector_version), + FOREIGN KEY ( + community_id, issuer, subject, retired_pubkey, + retired_binding_id, retired_binding_version + ) REFERENCES identity_retired_pairs ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (selector_version > 0), + CHECK (length(retired_pubkey) = 32), + CHECK (retired_binding_version > 0), + CHECK ( + (cleared_at IS NULL AND cleared_operation_id IS NULL) + OR + (cleared_at IS NOT NULL AND cleared_operation_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_identity_pending_replacements_active + ON identity_pending_replacements (community_id, issuer, subject) + WHERE cleared_at IS NULL; + +INSERT INTO identity_pending_replacements + (community_id, issuer, subject, selector_version, retired_pubkey, + retired_binding_id, retired_binding_version) +SELECT + terminal.community_id, + terminal.issuer, + terminal.uid, + 1, + terminal.pubkey, + terminal.binding_id, + terminal.binding_version +FROM identity_bindings terminal +WHERE terminal.revoked_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM identity_bindings active + WHERE active.community_id = terminal.community_id + AND active.issuer = terminal.issuer + AND active.uid = terminal.uid + AND active.revoked_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM identity_binding_lineage lineage + WHERE lineage.community_id = terminal.community_id + AND lineage.predecessor_binding_id = terminal.binding_id + ) + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id = terminal.community_id + AND denial.issuer = terminal.issuer + AND denial.subject = terminal.uid + ); + +CREATE TABLE identity_binding_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL DEFAULT gen_random_uuid(), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + binding_state TEXT NOT NULL, + binding_provenance TEXT NOT NULL, + transition_kind TEXT NOT NULL, + replacement_binding_id UUID, + operation_id UUID, + actor BYTEA, + reason TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, binding_id, binding_version, transition_kind), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (binding_version > 0), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (binding_state IN ('active', 'revoked', 'rotated')), + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + CHECK (transition_kind IN ( + 'legacy_import', 'enroll', 'provision', 'provenance_strengthened', + 'retire_pair', 'disable_identity', 'revoke_key', 'rotate', + 'recover', 'enable_identity' + )), + CHECK (actor IS NULL OR length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_binding_history_principal + ON identity_binding_history (community_id, issuer, subject, recorded_at); + +INSERT INTO identity_binding_history + (community_id, binding_id, binding_version, issuer, subject, pubkey, + binding_state, binding_provenance, transition_kind, + replacement_binding_id, actor, reason, recorded_at) +SELECT + community_id, + binding_id, + binding_version, + issuer, + uid, + pubkey, + binding_state, + binding_provenance, + 'legacy_import', + replacement_binding_id, + COALESCE(rotation_by, revoked_by), + COALESCE(NULLIF(rotation_reason, ''), NULLIF(revoked_reason, ''), 'legacy import'), + updated_at +FROM identity_bindings; + +-- Idempotency and local state history only. Authorization and complete +-- operator audit authority remain outside O3. +CREATE TABLE identity_lifecycle_operations ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL, + request_fingerprint BYTEA NOT NULL, + issuer TEXT, + subject TEXT, + pubkey BYTEA, + replacement_pubkey BYTEA, + binding_id UUID, + replacement_binding_id UUID, + binding_version BIGINT, + selector_version BIGINT, + actor BYTEA, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (operation_kind IN ( + 'provision', 'retire_pair', 'disable_identity', 'revoke_key', + 'rotate', 'recover', 'enable_identity' + )), + CHECK (length(request_fingerprint) = 32), + CHECK (issuer IS NULL OR length(issuer) > 0), + CHECK (subject IS NULL OR length(subject) > 0), + CHECK (pubkey IS NULL OR length(pubkey) = 32), + CHECK (replacement_pubkey IS NULL OR length(replacement_pubkey) = 32), + CHECK (binding_version IS NULL OR binding_version > 0), + CHECK (selector_version IS NULL OR selector_version > 0), + CHECK (actor IS NULL OR length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_lifecycle_operations_principal + ON identity_lifecycle_operations (community_id, issuer, subject, created_at); + +CREATE INDEX idx_identity_lifecycle_operations_key + ON identity_lifecycle_operations (community_id, pubkey, created_at); diff --git a/schema/schema.sql b/schema/schema.sql index 3ce067ee3..e44ca54ed 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -210,6 +210,13 @@ CREATE TABLE identity_bindings ( rotated_to_pubkey BYTEA, rotation_by BYTEA, rotation_reason TEXT, + binding_id UUID NOT NULL DEFAULT gen_random_uuid(), + binding_version BIGINT NOT NULL DEFAULT 1, + binding_state TEXT NOT NULL DEFAULT 'active', + binding_provenance TEXT NOT NULL DEFAULT 'tofu', + replacement_binding_id UUID, + created_by BYTEA, + created_policy_version TEXT, CONSTRAINT chk_identity_bindings_issuer_not_empty CHECK (length(issuer) > 0), CONSTRAINT chk_identity_bindings_uid_not_empty CHECK (length(uid) > 0), CONSTRAINT chk_identity_bindings_pubkey_len CHECK (length(pubkey) = 32), @@ -226,7 +233,25 @@ CREATE TABLE identity_bindings ( AND (rotation_by IS NULL OR length(rotation_by) = 32) AND rotation_reason IS NOT NULL AND length(rotation_reason) > 0) - ) + ), + CONSTRAINT identity_bindings_o3_id_unique UNIQUE (community_id, binding_id), + CONSTRAINT identity_bindings_o3_principal_version_unique + UNIQUE (community_id, issuer, uid, binding_version), + CONSTRAINT identity_bindings_o3_replacement_fk + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT chk_identity_bindings_o3_id_not_nil + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CONSTRAINT chk_identity_bindings_o3_version_positive CHECK (binding_version > 0), + CONSTRAINT chk_identity_bindings_o3_state + CHECK (binding_state IN ('active', 'revoked', 'rotated')), + CONSTRAINT chk_identity_bindings_o3_provenance + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + CONSTRAINT chk_identity_bindings_o3_created_by_len + CHECK (created_by IS NULL OR length(created_by) = 32), + CONSTRAINT chk_identity_bindings_o3_policy_version + CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0) ); CREATE UNIQUE INDEX idx_identity_bindings_active_principal @@ -267,6 +292,183 @@ CREATE TABLE identity_revoked_keys ( CHECK (length(reason) > 0) ); +CREATE TABLE identity_migration_denials ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + reason TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, issuer, subject), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(reason) > 0) +); + +CREATE TABLE identity_binding_lineage ( + community_id UUID NOT NULL REFERENCES communities(id), + predecessor_binding_id UUID NOT NULL, + successor_binding_id UUID NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, predecessor_binding_id), + UNIQUE (community_id, successor_binding_id), + FOREIGN KEY (community_id, predecessor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, successor_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (predecessor_binding_id <> successor_binding_id) +); + +CREATE TABLE identity_retired_pairs ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + retired_binding_id UUID, + retired_binding_version BIGINT, + retired_at TIMESTAMPTZ NOT NULL, + retired_by BYTEA, + reason TEXT NOT NULL, + PRIMARY KEY (community_id, issuer, subject, pubkey), + UNIQUE ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + FOREIGN KEY (community_id, retired_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (retired_binding_version IS NULL OR retired_binding_version > 0), + CHECK ( + (retired_binding_id IS NULL AND retired_binding_version IS NULL) + OR + (retired_binding_id IS NOT NULL AND retired_binding_version IS NOT NULL) + ), + CHECK (retired_by IS NULL OR length(retired_by) = 32), + CHECK (length(reason) > 0) +); + +CREATE TABLE identity_pending_replacements ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + selector_version BIGINT NOT NULL, + retired_pubkey BYTEA NOT NULL, + retired_binding_id UUID NOT NULL, + retired_binding_version BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_operation_id UUID, + cleared_at TIMESTAMPTZ, + cleared_operation_id UUID, + PRIMARY KEY (community_id, issuer, subject, selector_version), + FOREIGN KEY ( + community_id, issuer, subject, retired_pubkey, + retired_binding_id, retired_binding_version + ) REFERENCES identity_retired_pairs ( + community_id, issuer, subject, pubkey, + retired_binding_id, retired_binding_version + ), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (selector_version > 0), + CHECK (length(retired_pubkey) = 32), + CHECK (retired_binding_version > 0), + CHECK ( + (cleared_at IS NULL AND cleared_operation_id IS NULL) + OR + (cleared_at IS NOT NULL AND cleared_operation_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_identity_pending_replacements_active + ON identity_pending_replacements (community_id, issuer, subject) + WHERE cleared_at IS NULL; + +CREATE TABLE identity_binding_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL DEFAULT gen_random_uuid(), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + pubkey BYTEA NOT NULL, + binding_state TEXT NOT NULL, + binding_provenance TEXT NOT NULL, + transition_kind TEXT NOT NULL, + replacement_binding_id UUID, + operation_id UUID, + actor BYTEA, + reason TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, binding_id, binding_version, transition_kind), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (binding_version > 0), + CHECK (length(issuer) > 0), + CHECK (length(subject) > 0), + CHECK (length(pubkey) = 32), + CHECK (binding_state IN ('active', 'revoked', 'rotated')), + CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')), + CHECK (transition_kind IN ( + 'legacy_import', 'enroll', 'provision', 'provenance_strengthened', + 'retire_pair', 'disable_identity', 'revoke_key', 'rotate', + 'recover', 'enable_identity' + )), + CHECK (actor IS NULL OR length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_binding_history_principal + ON identity_binding_history (community_id, issuer, subject, recorded_at); + +CREATE TABLE identity_lifecycle_operations ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL, + request_fingerprint BYTEA NOT NULL, + issuer TEXT, + subject TEXT, + pubkey BYTEA, + replacement_pubkey BYTEA, + binding_id UUID, + replacement_binding_id UUID, + binding_version BIGINT, + selector_version BIGINT, + actor BYTEA, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id), + FOREIGN KEY (community_id, replacement_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID), + CHECK (operation_kind IN ( + 'provision', 'retire_pair', 'disable_identity', 'revoke_key', + 'rotate', 'recover', 'enable_identity' + )), + CHECK (length(request_fingerprint) = 32), + CHECK (issuer IS NULL OR length(issuer) > 0), + CHECK (subject IS NULL OR length(subject) > 0), + CHECK (pubkey IS NULL OR length(pubkey) = 32), + CHECK (replacement_pubkey IS NULL OR length(replacement_pubkey) = 32), + CHECK (binding_version IS NULL OR binding_version > 0), + CHECK (selector_version IS NULL OR selector_version > 0), + CHECK (actor IS NULL OR length(actor) = 32), + CHECK (length(reason) > 0) +); + +CREATE INDEX idx_identity_lifecycle_operations_principal + ON identity_lifecycle_operations (community_id, issuer, subject, created_at); + +CREATE INDEX idx_identity_lifecycle_operations_key + ON identity_lifecycle_operations (community_id, pubkey, created_at); + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the