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>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-04 13:55:18 -05:00
parent 5571b1cc41
commit 71c0f728fe
3 changed files with 1265 additions and 2 deletions
+462 -1
View File
@@ -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<String>,
principals: Vec<String>,
revoked_keys: Vec<String>,
catalog: Vec<String>,
}
async fn legacy_identity_snapshot(pool: &PgPool) -> LegacyIdentitySnapshot {
async fn json_rows(pool: &PgPool, query: &'static str) -> Vec<String> {
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::<Vec<_>>();
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<u8>, i64, Option<Vec<u8>>)> = 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<u8>, 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<String>, Option<String>, Option<String>) = 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() {
@@ -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);
+203 -1
View File
@@ -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