Files
buzz/migrations/0029_additive_identity_binding_state.sql
T
Cea Stapleton Cordasco 076f9da8b2 feat(db): add linearizable identity lifecycle
Serialize binding transitions and retain tombstone authority across enrollment, rotation, and revocation paths.

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
2026-08-04 13:55:18 -05:00

647 lines
25 KiB
SQL

-- 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. Equality is intentional because
-- NOW() is transaction-stable in the legacy rotation helper; an older row can
-- never be selected as a successor.
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
AND successor.created_at >= predecessor.rotation_completed_at
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;
-- Principal quarantine alone is insufficient: a missing or ambiguous target
-- key must not be resurrected under a different principal in the same domain.
-- Retain a domain-key denial for every stored or referenced key implicated by
-- an invalid legacy graph.
CREATE TABLE identity_migration_denied_keys (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey BYTEA NOT NULL,
reason TEXT NOT NULL,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, pubkey),
CHECK (length(pubkey) = 32),
CHECK (length(reason) > 0)
);
INSERT INTO identity_migration_denied_keys (community_id, pubkey, reason)
SELECT DISTINCT binding.community_id, binding.pubkey,
'key implicated by ambiguous legacy replacement lineage'
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
UNION
SELECT DISTINCT binding.community_id, binding.rotated_to_pubkey,
'key implicated by ambiguous legacy replacement lineage'
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
WHERE binding.rotated_to_pubkey IS NOT NULL;
-- 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
AND successor.created_at >= predecessor.rotation_completed_at
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
AND successor.created_at >= predecessor.rotation_completed_at
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
);
-- The legacy rotation helper admits `db_binding` only after privileged
-- replacement proof. Roots remain TOFU; unique imported successors retain the
-- stronger provisioned provenance implied by that helper.
UPDATE identity_bindings successor
SET binding_provenance = 'provisioned'
FROM identity_binding_lineage lineage
WHERE lineage.community_id = successor.community_id
AND lineage.successor_binding_id = successor.binding_id
AND successor.source = 'db_binding';
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);