diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66251563cb..10068eaaa4 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -690,7 +690,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1232,6 +1232,45 @@ mod tests { operator_audit.contains("_operator_global_tables"), "migration 39 must register relay_operator_audit in _operator_global_tables" ); + + // NIP-FI core identity + base-lifecycle foundation (migration 0041) and + // final-admission foundation (0042). Both widen the single SQL source of + // truth `community_write_fence_excluded_table` so their durable, + // immutable ledger relations are never fence-attached, purged, or + // counted as tenant-scoped drift. schema.sql keeps one consolidated + // definition of that function whose body must match 0042's exactly. + assert_eq!(migrations[40].version, 41); + let identity_foundation = migrations[40].sql.as_str(); + assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); + assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); + assert!(identity_foundation + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(identity_foundation.contains("'identity_bindings'")); + + assert_eq!(migrations[41].version, 42); + let authorization_foundation = migrations[41].sql.as_str(); + assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); + assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); + assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); + + // The consolidated desired-state exclusion function must byte-match + // migration 0042's CREATE OR REPLACE body, or a future schema + // consolidation would silently drop NIP-FI relations from the ledger. + fn extract_excluded_table_array(sql: &str) -> &str { + let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; + let start = sql.find(anchor).expect("exclusion function definition"); + let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; + let array_end = sql[array_start..] + .find("]::TEXT[]") + .expect("exclusion array end") + + array_start; + &sql[array_start..array_end] + } + assert_eq!( + extract_excluded_table_array(authorization_foundation), + extract_excluded_table_array(desired_schema), + "schema.sql exclusion list drifted from migration 0042" + ); } #[test] @@ -2609,4 +2648,194 @@ mod tests { .await .expect("drop late-table fixtures"); } + + /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) + /// alone must present a coherent catalog. Its five community-scoped ledger + /// relations are immutable and durable, so they are registered in the + /// write-fence exclusion — never counted as tenant-scoped drift, never + /// fence-attached — and the exact deletion catalog must still validate. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + // The five identity relations exist. + let identity_tables = [ + "authorization_operation_receipts", + "identity_enrollment_policies", + "identity_bindings", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + ]; + for table in identity_tables { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(exists, "migration 0041 must create {table}"); + } + + // Migration B's relations must NOT exist yet. + for table in ["authorization_events", "protected_object_authority"] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(!exists, "{table} belongs to migration 0042, not 0041"); + } + + // Every identity relation is excluded from the write fence: none may + // appear as tenant-scoped drift or carry the fence trigger. + let scoped_or_fenced: Vec = sqlx::query_scalar( + "WITH scoped AS ( \ + SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + ) \ + SELECT relname FROM scoped \ + WHERE relname = ANY($1) ORDER BY relname", + ) + .bind(&identity_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped identity relations"); + assert!( + scoped_or_fenced.is_empty(), + "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" + ); + + // The exact deletion catalog validates: the excluded ledger relations + // do not perturb the scoped-table/fence equality check. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migration 0041"); + + // The immutability contract is enforced, not merely declared. TRUNCATE + // fires the statement-level guard unconditionally, so this proves the + // rejection without constructing a fully valid ledger row. + let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") + .execute(&pool) + .await + .expect_err("identity_lifecycle_selectors truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } + + /// NIP-FI full state: migrations 0041 + 0042 together must present a + /// coherent 15-relation catalog with zero dangling foreign keys, all + /// relations write-fence excluded, and an intact exact deletion catalog. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let nip_fi_tables = [ + "authorization_admission_results", + "authorization_authentication_denial_attempts", + "authorization_authority_epochs", + "authorization_event_capacity", + "authorization_events", + "authorization_invalidation_domains", + "authorization_invalidation_floors", + "authorization_operation_receipts", + "authorization_operation_version_delta_manifests", + "authorization_operation_version_deltas", + "identity_bindings", + "identity_enrollment_policies", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + "protected_object_authority", + ]; + + // All fifteen relations exist. + let present: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read NIP-FI table catalog"); + let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); + expected.sort(); + assert_eq!( + present, expected, + "all NIP-FI relations must exist after 0042" + ); + + // Zero dangling foreign keys: every FK target is a live relation. + let invalid_fks: i64 = sqlx::query_scalar( + "SELECT count(*)::BIGINT FROM pg_constraint \ + WHERE contype = 'f' AND NOT convalidated", + ) + .fetch_one(&pool) + .await + .expect("read FK validity"); + assert_eq!( + invalid_fks, 0, + "no NIP-FI foreign key may be left unvalidated" + ); + + // None of the fifteen appear as tenant-scoped drift; all are excluded. + let scoped: Vec = sqlx::query_scalar( + "SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + AND c.relname = ANY($1) ORDER BY c.relname", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped NIP-FI relations"); + assert!( + scoped.is_empty(), + "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + ); + + // The exact deletion catalog validates with the full ledger present. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migrations 0041 + 0042"); + + // A migration-B relation is immutable too. TRUNCATE fires the + // statement-level guard unconditionally. + let rejected = sqlx::query("TRUNCATE authorization_admission_results") + .execute(&pool) + .await + .expect_err("authorization_admission_results truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } } diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql new file mode 100644 index 0000000000..e4980babf5 --- /dev/null +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -0,0 +1,879 @@ +-- Provider-free NIP-FI core identity and base-lifecycle foundation. +-- +-- This is direct-final fresh-schema DDL. It intentionally does not replay a +-- historical uid/backfill/ALTER sequence. +-- +-- Scope is NIP-FI *core* only. Base lifecycle is exactly retire, revoke, and +-- rotate (NIP-FI.md "Base lifecycle"). The extended NIP-FI-LIFECYCLE surface +-- (disabled identities, pending-replacement lineage, and their provision, +-- disable, recover, enable, and admission-loss transitions) is deferred to a +-- later migration owned by the FI-LIFECYCLE PR, per NIP-FI-MODEL.md: "NIP-FI- +-- LIFECYCLE adds disabled identities and pending replacement lineage." So the +-- closed vocabularies below are the core subset: +-- transition/operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate; +-- lifecycle selector kinds: 1 retired pair (P), 3 revoked key (Y). +-- A later migration widens these vocabularies additively; nothing here presumes +-- a single global issuer — identity is issuer-qualified (iss, sub). + +-- The sole idempotency/result root shared by identity base lifecycle, +-- protected operations, and invalidation. Pre-authentication denials never +-- write this table. ExactReplay and IntentConflict are read-time observations, +-- not persisted outcomes. +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + -- Core operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate, + -- 11 protected mutation, 12 invalidation. Extended lifecycle kinds + -- (2 provision, 4 disable, 7 recover, 8 enable, 9 admission loss) and + -- 10 operator are introduced by their owning later migrations. + operation_kind SMALLINT NOT NULL CHECK ( + operation_kind IN (1, 3, 5, 6, 11, 12) + ), + actor_fingerprint BYTEA NOT NULL CHECK (octet_length(actor_fingerprint) = 32), + -- 1 applied, 2 denied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3)), + result_digest BYTEA NOT NULL CHECK (octet_length(result_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Immutable monotonic local policy revisions. Enrollment modes are the closed +-- provider-free V1 set: 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. +CREATE TABLE identity_enrollment_policies ( + community_id UUID NOT NULL REFERENCES communities(id), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + enrollment_mode SMALLINT NOT NULL CHECK (enrollment_mode IN (1, 2, 3)), + policy_digest BYTEA NOT NULL CHECK (octet_length(policy_digest) = 32), + effective_at TIMESTAMPTZ NOT NULL, + -- Optional local binding-policy expiry. Federated token `exp` MUST NOT be + -- copied here: token lifetime bounds an authorization lease, not this + -- durable binding generation. + expires_at TIMESTAMPTZ, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, policy_revision), + UNIQUE (community_id, policy_revision, enrollment_mode), + CHECK (expires_at IS NULL OR effective_at < expires_at) +); + +-- One row is one immutable binding generation. binding_version is allocated +-- from one non-cycling PostgreSQL identity sequence and is never changed or +-- reused. Explicit lifecycle may only retire the generation; X/Y denial +-- semantics live in immutable selector facts below, not alternate row states. +CREATE TABLE identity_bindings ( + community_id UUID NOT NULL REFERENCES communities(id), + binding_id UUID NOT NULL, + binding_version BIGINT GENERATED ALWAYS AS IDENTITY ( + START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 NO CYCLE + ), + issuer TEXT COLLATE "C" NOT NULL CHECK (octet_length(issuer) BETWEEN 1 AND 2048), + subject TEXT COLLATE "C" NOT NULL CHECK (octet_length(subject) BETWEEN 1 AND 2048), + principal_fingerprint BYTEA NOT NULL CHECK (octet_length(principal_fingerprint) = 32), + event_author_pubkey BYTEA NOT NULL CHECK (octet_length(event_author_pubkey) = 32), + -- 1 active, 2 retired. + binding_state SMALLINT NOT NULL CHECK (binding_state IN (1, 2)), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision IN (1, 2)), + -- 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. + binding_provenance SMALLINT NOT NULL CHECK (binding_provenance IN (1, 2, 3)), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + -- Canonical evidence for the selected provenance. This is an assertion + -- digest for attested/TOFU admission and a provisioning receipt digest for + -- separately provisioned admission; it never stores credential bytes. + enrollment_evidence_digest BYTEA NOT NULL CHECK ( + octet_length(enrollment_evidence_digest) = 32 + ), + expires_at TIMESTAMPTZ, + birth_history_id UUID NOT NULL, + creation_operation_id UUID NOT NULL, + creation_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(creation_request_fingerprint) = 32 + ), + retirement_history_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, binding_id), + UNIQUE (community_id, binding_version), + UNIQUE (community_id, binding_id, binding_version), + FOREIGN KEY (community_id, policy_revision, binding_provenance) + REFERENCES identity_enrollment_policies + (community_id, policy_revision, enrollment_mode), + CHECK (binding_version > 0), + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (creation_operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (expires_at IS NULL OR created_at < expires_at), + CHECK ( + (binding_state = 1 AND lifecycle_revision = 1 AND retirement_history_id IS NULL) + OR (binding_state = 2 AND lifecycle_revision = 2 AND retirement_history_id IS NOT NULL) + ) +); + +-- State 1 is Active. Expiry is evaluated with authoritative PostgreSQL time +-- at read/finalization and is exclusive; it cannot appear in an index predicate. +CREATE UNIQUE INDEX identity_bindings_active_principal + ON identity_bindings (community_id, issuer, subject) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_principal_fingerprint_lookup + ON identity_bindings (community_id, principal_fingerprint) + WHERE binding_state = 1; +CREATE UNIQUE INDEX identity_bindings_active_event_author + ON identity_bindings (community_id, event_author_pubkey) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_current_lookup + ON identity_bindings (community_id, event_author_pubkey, binding_state, expires_at); + +-- The one canonical immutable lifecycle transition row for a successful or +-- no-op lifecycle operation. A transition can name an old generation, a new +-- successor generation, both (Rotate), or neither (a semantic no-op). It is not +-- a second result/effect engine: the shared receipt remains the sole persisted +-- operation outcome. Core transition kinds only: 1 enroll, 3 retire, 5 revoke, +-- 6 rotate. +CREATE TABLE identity_lifecycle_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL, + transition_kind SMALLINT NOT NULL CHECK ( + transition_kind IN (1, 3, 5, 6) + ), + -- Matches the shared receipt: 1 applied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 3)), + old_binding_id UUID, + old_binding_version BIGINT CHECK (old_binding_version IS NULL OR old_binding_version > 0), + old_prior_lifecycle_revision BIGINT CHECK ( + old_prior_lifecycle_revision IS NULL OR old_prior_lifecycle_revision IN (1, 2) + ), + old_prior_state SMALLINT CHECK (old_prior_state IS NULL OR old_prior_state IN (1, 2)), + old_resulting_lifecycle_revision BIGINT CHECK ( + old_resulting_lifecycle_revision IS NULL OR old_resulting_lifecycle_revision IN (1, 2) + ), + old_resulting_state SMALLINT CHECK ( + old_resulting_state IS NULL OR old_resulting_state IN (1, 2) + ), + successor_binding_id UUID, + successor_binding_version BIGINT CHECK ( + successor_binding_version IS NULL OR successor_binding_version > 0 + ), + successor_lifecycle_revision BIGINT CHECK ( + successor_lifecycle_revision IS NULL OR successor_lifecycle_revision = 1 + ), + successor_state SMALLINT CHECK (successor_state IS NULL OR successor_state = 1), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + transition_digest BYTEA NOT NULL CHECK (octet_length(transition_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, operation_id), + UNIQUE (community_id, history_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ), + UNIQUE ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ), + FOREIGN KEY ( + community_id, + operation_id, + request_fingerprint, + transition_kind, + outcome_code + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, old_binding_id, old_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, successor_binding_id, successor_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (old_binding_id IS NULL + AND old_binding_version IS NULL + AND old_prior_lifecycle_revision IS NULL + AND old_prior_state IS NULL + AND old_resulting_lifecycle_revision IS NULL + AND old_resulting_state IS NULL) + OR (old_binding_id IS NOT NULL + AND old_binding_version IS NOT NULL + AND old_prior_lifecycle_revision IS NOT NULL + AND old_prior_state IS NOT NULL + AND old_resulting_lifecycle_revision IS NOT NULL + AND old_resulting_state IS NOT NULL) + ), + CHECK ( + (successor_binding_id IS NULL + AND successor_binding_version IS NULL + AND successor_lifecycle_revision IS NULL + AND successor_state IS NULL) + OR (successor_binding_id IS NOT NULL + AND successor_binding_version IS NOT NULL + AND successor_lifecycle_revision = 1 + AND successor_state = 1) + ), + CHECK ( + old_binding_id IS NULL + OR successor_binding_id IS NULL + OR old_binding_id <> successor_binding_id + ), + CHECK ( + old_binding_version IS NULL + OR successor_binding_version IS NULL + OR old_binding_version <> successor_binding_version + ), + -- Core lifecycle only ever moves Active/r1 to Retired/r2 for a named old + -- generation. Extended re-enablement (recover/enable from Retired/r2) is a + -- later migration's concern. + CHECK ( + old_binding_id IS NULL + OR (old_prior_lifecycle_revision = 1 + AND old_prior_state = 1 + AND old_resulting_lifecycle_revision = 2 + AND old_resulting_state = 2) + ), + CHECK ( + (outcome_code = 3 + AND old_binding_id IS NULL + AND successor_binding_id IS NULL) + OR (outcome_code = 1 AND ( + (transition_kind = 1 + AND old_binding_id IS NULL + AND successor_binding_id IS NOT NULL) + OR (transition_kind = 3 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NULL) + OR (transition_kind = 5 + AND successor_binding_id IS NULL) + OR (transition_kind = 6 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NOT NULL) + )) + ) +); + +CREATE INDEX identity_lifecycle_history_old_binding + ON identity_lifecycle_history (community_id, old_binding_id, old_binding_version, recorded_at); +CREATE INDEX identity_lifecycle_history_successor_binding + ON identity_lifecycle_history ( + community_id, + successor_binding_id, + successor_binding_version, + recorded_at + ); + +-- Circular birth/transition ordering is deliberate and fully deferred. Every +-- generation must commit with its exact birth transition, and a retired row +-- must commit with the exact transition that changed Active/r1 to Retired/r2. +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_birth_history_fk + FOREIGN KEY ( + community_id, + birth_history_id, + binding_id, + binding_version, + creation_operation_id, + creation_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_retirement_history_fk + FOREIGN KEY ( + community_id, + retirement_history_id, + binding_id, + binding_version, + lifecycle_revision, + binding_state + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ) DEFERRABLE INITIALLY DEFERRED; + +-- One immutable closed-scope fact table. Core selector kinds only: +-- 1 retired pair (P), 3 revoked key (Y). Both are permanent. The extended +-- disabled-identity (X) and pending-replacement (Q) selectors, and their +-- one-shot consumption, are introduced by the FI-LIFECYCLE migration. +CREATE TABLE identity_lifecycle_selectors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_id UUID NOT NULL, + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 3)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + fact_generation BIGINT NOT NULL CHECK (fact_generation > 0), + principal_fingerprint BYTEA CHECK ( + principal_fingerprint IS NULL OR octet_length(principal_fingerprint) = 32 + ), + event_author_pubkey BYTEA CHECK ( + event_author_pubkey IS NULL OR octet_length(event_author_pubkey) = 32 + ), + binding_id UUID, + binding_version BIGINT CHECK (binding_version IS NULL OR binding_version > 0), + asserted_history_id UUID NOT NULL, + selected_by_operation_id UUID NOT NULL, + selected_by_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(selected_by_request_fingerprint) = 32 + ), + selected_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_id), + UNIQUE (community_id, selector_id, selector_kind), + UNIQUE (community_id, selector_kind, selector_fingerprint, fact_generation), + FOREIGN KEY ( + community_id, + asserted_history_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (selector_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (selector_kind = 1 + AND fact_generation = 1 + AND principal_fingerprint IS NOT NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NOT NULL + AND binding_version IS NOT NULL) + OR (selector_kind = 3 + AND fact_generation = 1 + AND principal_fingerprint IS NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NULL + AND binding_version IS NULL) + ) +); + +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_pair + ON identity_lifecycle_selectors (community_id, binding_id, binding_version) + WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_principal_key + ON identity_lifecycle_selectors ( + community_id, + principal_fingerprint, + event_author_pubkey + ) WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_key + ON identity_lifecycle_selectors (community_id, event_author_pubkey) + WHERE selector_kind = 3; +CREATE INDEX identity_lifecycle_selectors_principal_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, principal_fingerprint, fact_generation); +CREATE INDEX identity_lifecycle_selectors_key_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, event_author_pubkey, fact_generation); +CREATE INDEX identity_lifecycle_selectors_binding_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, binding_id, binding_version, fact_generation); +CREATE INDEX identity_lifecycle_selectors_asserted_history + ON identity_lifecycle_selectors + (community_id, asserted_history_id, selector_kind); + +CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% is immutable', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION nip_fi_reject_truncate_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +-- Every binding/selector path derives the same domain-scoped coordinates and +-- takes their signed BIGINT advisory keys in numeric order. Typed transaction +-- APIs take these locks before row mutation; the triggers are the fail-closed +-- backstop for direct SQL. +CREATE FUNCTION identity_lifecycle_lock_coordinates_v1( + locked_community_id UUID, + locked_principal_fingerprint BYTEA, + locked_event_author_pubkey BYTEA +) RETURNS VOID AS $$ +DECLARE + principal_lock_key BIGINT; + event_author_lock_key BIGINT; +BEGIN + IF locked_principal_fingerprint IS NOT NULL THEN + principal_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:principal:' + || locked_community_id::text || ':' + || encode(locked_principal_fingerprint, 'hex'), + 0 + ); + END IF; + IF locked_event_author_pubkey IS NOT NULL THEN + event_author_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:key:' + || locked_community_id::text || ':' + || encode(locked_event_author_pubkey, 'hex'), + 0 + ); + END IF; + + IF principal_lock_key IS NOT NULL AND event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(LEAST(principal_lock_key, event_author_lock_key)); + IF principal_lock_key <> event_author_lock_key THEN + PERFORM pg_advisory_xact_lock(GREATEST(principal_lock_key, event_author_lock_key)); + END IF; + ELSIF principal_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(principal_lock_key); + ELSIF event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(event_author_lock_key); + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + NEW.principal_fingerprint, + NEW.event_author_pubkey + ); + IF NEW.binding_state <> 1 + OR NEW.lifecycle_revision <> 1 + OR NEW.retirement_history_id IS NOT NULL + THEN + RAISE EXCEPTION 'identity binding birth must be Active at lifecycle revision 1' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_state'; + END IF; + NEW.created_at := transaction_timestamp(); + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_transition_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + PERFORM identity_lifecycle_lock_coordinates_v1( + OLD.community_id, + OLD.principal_fingerprint, + OLD.event_author_pubkey + ); + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.binding_id IS DISTINCT FROM OLD.binding_id + OR NEW.binding_version IS DISTINCT FROM OLD.binding_version + OR NEW.issuer IS DISTINCT FROM OLD.issuer + OR NEW.subject IS DISTINCT FROM OLD.subject + OR NEW.principal_fingerprint IS DISTINCT FROM OLD.principal_fingerprint + OR NEW.event_author_pubkey IS DISTINCT FROM OLD.event_author_pubkey + OR NEW.binding_provenance IS DISTINCT FROM OLD.binding_provenance + OR NEW.policy_revision IS DISTINCT FROM OLD.policy_revision + OR NEW.enrollment_evidence_digest IS DISTINCT FROM OLD.enrollment_evidence_digest + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + OR NEW.birth_history_id IS DISTINCT FROM OLD.birth_history_id + OR NEW.creation_operation_id IS DISTINCT FROM OLD.creation_operation_id + OR NEW.creation_request_fingerprint IS DISTINCT FROM OLD.creation_request_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'identity binding generation coordinates are immutable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_immutable_generation'; + END IF; + IF OLD.binding_state <> 1 + OR OLD.lifecycle_revision <> 1 + OR OLD.retirement_history_id IS NOT NULL + OR NEW.binding_state <> 2 + OR NEW.lifecycle_revision <> 2 + OR NEW.retirement_history_id IS NULL + OR NEW.retirement_history_id = OLD.birth_history_id + THEN + RAISE EXCEPTION 'identity binding permits only Active/r1 to Retired/r2' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_active_to_retired'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_history_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.recorded_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_history_semantics_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + retirement identity_lifecycle_history%ROWTYPE; +BEGIN + IF NEW.binding_state = 2 THEN + SELECT * INTO STRICT retirement + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.retirement_history_id + AND old_binding_id = NEW.binding_id + AND old_binding_version = NEW.binding_version; + IF retirement.outcome_code <> 1 + OR retirement.old_prior_lifecycle_revision <> 1 + OR retirement.old_prior_state <> 1 + OR retirement.old_resulting_lifecycle_revision <> 2 + OR retirement.old_resulting_state <> 2 + THEN + RAISE EXCEPTION 'retired binding must reference its exact Active-to-Retired transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_retirement_history_semantics'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_birth_eligibility_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + WHERE selector.community_id = NEW.community_id + AND ( + (selector.selector_kind = 1 + AND selector.principal_fingerprint = NEW.principal_fingerprint + AND selector.event_author_pubkey = NEW.event_author_pubkey) + OR (selector.selector_kind = 3 + AND selector.event_author_pubkey = NEW.event_author_pubkey) + ) + ) THEN + RAISE EXCEPTION 'binding birth conflicts with an effective lifecycle selector' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_eligibility'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION authorization_operation_receipt_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history_count BIGINT; + expected_count BIGINT; +BEGIN + SELECT count(*) INTO history_count + FROM identity_lifecycle_history history + WHERE history.community_id = NEW.community_id + AND history.operation_id = NEW.operation_id; + + -- Core lifecycle receipts (enroll, retire, revoke, rotate) each require + -- exactly one lifecycle-history row. Non-lifecycle receipts (protected + -- mutation, invalidation) require none. + expected_count := CASE + WHEN NEW.operation_kind IN (1, 3, 5, 6) AND NEW.outcome_code IN (1, 3) THEN 1 + ELSE 0 + END; + IF history_count <> expected_count THEN + RAISE EXCEPTION 'operation receipt requires % lifecycle history row, found %', + expected_count, history_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_history_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.selected_at := transaction_timestamp(); + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + CASE WHEN NEW.selector_kind = 1 THEN NEW.principal_fingerprint END, + NEW.event_author_pubkey + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history identity_lifecycle_history%ROWTYPE; + old_binding identity_bindings%ROWTYPE; +BEGIN + SELECT * INTO STRICT history + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id + AND operation_id = NEW.selected_by_operation_id + AND request_fingerprint = NEW.selected_by_request_fingerprint; + + IF history.old_binding_id IS NOT NULL THEN + SELECT * INTO STRICT old_binding + FROM identity_bindings + WHERE community_id = history.community_id + AND binding_id = history.old_binding_id + AND binding_version = history.old_binding_version; + END IF; + + -- A retired-pair (P) selector is asserted by retire, revoke, or rotate of a + -- named old generation; a revoked-key (Y) selector by revoke. + IF history.outcome_code <> 1 + OR (NEW.selector_kind = 1 AND ( + history.transition_kind NOT IN (3, 5, 6) + OR history.old_binding_id IS DISTINCT FROM NEW.binding_id + OR history.old_binding_version IS DISTINCT FROM NEW.binding_version + OR old_binding.principal_fingerprint IS DISTINCT FROM NEW.principal_fingerprint + OR old_binding.event_author_pubkey IS DISTINCT FROM NEW.event_author_pubkey + )) + OR (NEW.selector_kind = 3 AND ( + history.transition_kind <> 5 + OR (history.old_binding_id IS NOT NULL + AND old_binding.event_author_pubkey + IS DISTINCT FROM NEW.event_author_pubkey) + )) + THEN + RAISE EXCEPTION 'selector does not match its lifecycle transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_selector_history_semantics'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_transition_integrity_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + transition identity_lifecycle_history%ROWTYPE; + old_binding_state SMALLINT; + asserted_p BIGINT; + asserted_y BIGINT; +BEGIN + IF TG_TABLE_NAME = 'identity_lifecycle_history' THEN + transition := NEW; + ELSIF TG_TABLE_NAME = 'identity_lifecycle_selectors' THEN + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id; + ELSE + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = CASE + WHEN NEW.binding_state = 2 THEN NEW.retirement_history_id + ELSE NEW.birth_history_id + END; + END IF; + + SELECT + count(*) FILTER (WHERE selector_kind = 1), + count(*) FILTER (WHERE selector_kind = 3) + INTO asserted_p, asserted_y + FROM identity_lifecycle_selectors + WHERE community_id = transition.community_id + AND asserted_history_id = transition.history_id; + + IF transition.old_binding_id IS NOT NULL THEN + SELECT binding_state INTO STRICT old_binding_state + FROM identity_bindings + WHERE community_id = transition.community_id + AND binding_id = transition.old_binding_id + AND binding_version = transition.old_binding_version; + IF old_binding_state <> 2 THEN + RAISE EXCEPTION 'lifecycle transition old binding must be retired at commit' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + JOIN identity_bindings active + ON active.community_id = selector.community_id + AND active.binding_state = 1 + AND ( + (selector.selector_kind = 1 + AND active.principal_fingerprint = selector.principal_fingerprint + AND active.event_author_pubkey = selector.event_author_pubkey) + OR (selector.selector_kind = 3 + AND active.event_author_pubkey = selector.event_author_pubkey) + ) + WHERE selector.community_id = transition.community_id + ) THEN + RAISE EXCEPTION 'effective lifecycle selector conflicts with an active binding' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + + IF transition.outcome_code = 3 THEN + IF asserted_p + asserted_y <> 0 THEN + RAISE EXCEPTION 'no-op lifecycle transition cannot create selector facts' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; + END IF; + + -- Core selector companions per transition: + -- enroll (1): none + -- retire (3): exactly one P + -- revoke (5): one Y always; one P when a named old generation is removed + -- rotate (6): exactly one P (old generation retired) + IF (transition.transition_kind = 1 + AND (asserted_p, asserted_y) <> (0, 0)) + OR (transition.transition_kind = 3 + AND (asserted_p, asserted_y) <> (1, 0)) + OR (transition.transition_kind = 5 AND ( + (transition.old_binding_id IS NOT NULL + AND (asserted_p, asserted_y) <> (1, 1)) + OR (transition.old_binding_id IS NULL + AND (asserted_p, asserted_y) <> (0, 1)) + )) + OR (transition.transition_kind = 6 + AND (asserted_p, asserted_y) <> (1, 0)) + THEN + RAISE EXCEPTION 'lifecycle transition has incomplete or forbidden selector companions' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER identity_bindings_insert_guard + BEFORE INSERT ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_insert_guard_v1(); +CREATE TRIGGER identity_bindings_transition_guard + BEFORE UPDATE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_transition_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_history_semantics + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_history_semantics_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_birth_eligibility + AFTER INSERT ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_birth_eligibility_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_transition_integrity + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); +CREATE TRIGGER identity_bindings_no_delete + BEFORE DELETE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_bindings_no_truncate + BEFORE TRUNCATE ON identity_bindings + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_insert_guard + BEFORE INSERT ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_history_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_history_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity + AFTER INSERT ON identity_lifecycle_history + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER identity_lifecycle_selector_insert_guard + BEFORE INSERT ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_history_semantics + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_transition_integrity + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER authorization_operation_receipts_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_receipts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_receipts_no_truncate + BEFORE TRUNCATE ON authorization_operation_receipts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_enrollment_policies_immutable + BEFORE UPDATE OR DELETE ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_enrollment_policies_no_truncate + BEFORE TRUNCATE ON identity_enrollment_policies + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_history_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_history + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_selectors_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_selectors_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_selectors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +-- These identity relations are a durable, tamper-evident authorization ledger: +-- FI-INV-02 (durable binding) and FI-INV-03 (tombstone monotonicity) require +-- their denial facts to outlive any single tenant lifecycle, and the immutable +-- no_delete/no_truncate triggers above enforce exactly that. They therefore +-- carry community_id as provenance, not as deletable ownership — the same +-- posture migration 0030 took for product_feedback and rate_limit_violations. +-- Widen the single SQL source of truth so the universal write fence and the +-- deletion catalog treat them as ledger: never fence-attached, never purged, +-- never counted as tenant-scoped drift. community rows are permanent tombstones +-- (never hard-deleted), so their NOT NULL community_id references never dangle. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors' + ]::TEXT[]) +$$; diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql new file mode 100644 index 0000000000..b83469dd81 --- /dev/null +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -0,0 +1,749 @@ +-- Provider-free NIP-FI authorization, audit, fencing, and restore foundation. +-- +-- There is no provider registry/SPI/profile/evidence table, durable lease or +-- audio admission ledger, 30382 projection, delivery queue, exporter claim, +-- acknowledgement, retry scheduler, or online retention/compaction workflow. +-- +-- This migration applies to migration 0040's resulting state. Its scope is the +-- NIP-FI *final-admission* surface: replay/receipt, audit events, invalidation, +-- capacity, protected-object authority, restore version deltas, and the closed +-- admission result. Closed vocabularies below carry only the core subset; +-- delegation coordinates (owner/relationship columns, invalidation selector 7, +-- version-delta component kind 6) are deferred to the FI-DELEG migration and +-- extended-lifecycle audit kinds (recover, enable, disable, admission-loss; +-- version-delta component kind 7) to the FI-LIFECYCLE migration, matching +-- 0040's carve. A later migration widens these additively; nothing here +-- presumes a single global issuer. + +-- Durable one-way activation marker and current domain invalidation generation. +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + current_generation BIGINT NOT NULL CHECK (current_generation >= 0), + activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() +); + +-- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain, +-- 6 configuration revision. Selector 7 (delegated relationship) and its +-- relationship-revision floor are deferred to the FI-DELEG migration. +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + floor_generation BIGINT NOT NULL CHECK (floor_generation > 0), + binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK ( + (selector_kind = 3 AND binding_version_floor IS NOT NULL) + OR (selector_kind <> 3 AND binding_version_floor IS NULL) + ) +); + +-- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. Kind 7 is retired: current binding +-- status is connection-local evidence and never a durable protected object. +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, object_kind, object_key), + UNIQUE ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- Direct-final current authority for a protected object. The authorization +-- lease itself is sealed in memory and dies on restart; this durable row is the +-- exact source re-fenced immediately before a protected mutation or emission. +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + capability SMALLINT NOT NULL CHECK ( + capability IN ( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28, 29 + ) + ), + actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL CHECK (binding_version > 0), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + PRIMARY KEY (community_id, object_kind, object_key), + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) REFERENCES authorization_authority_epochs ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + CHECK (issued_at < expires_at) +); + +-- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings +-- match buzz-auth; installation limits must be sized explicitly below them. +-- V1 has no online pruning/export/reset workflow. +CREATE TABLE authorization_event_capacity ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK ( + max_events_per_domain BETWEEN 1 AND 10000 + ), + max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK ( + max_bytes_per_domain BETWEEN 1 AND 16777216 + ), + max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK ( + max_envelope_bytes BETWEEN 1 AND 16384 + ), + retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0), + retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0), + -- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1 + -- online workflow; enabled runtime latches failure when insertion aborts. + health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)), + failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)), + failure_observed_at TIMESTAMPTZ, + configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + CHECK (max_envelope_bytes <= max_bytes_per_domain), + CHECK (retained_event_count <= max_events_per_domain), + CHECK (retained_envelope_bytes <= max_bytes_per_domain), + CHECK ( + (health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL) + OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL) + ) +); + +-- Durable versioned pseudonymous authorization envelope. event_kind: +-- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied, +-- 10 protected allowed, 11 protected denied, 14 invalidation advanced. +-- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, +-- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE +-- migration, matching 0040's core lifecycle carve. Kinds 12 and 13 are +-- retired: kind 24244 publication/withdrawal is ephemeral connection state and +-- never a durable authorization event. +CREATE TABLE authorization_events ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1), + event_kind SMALLINT NOT NULL CHECK ( + event_kind IN (1, 2, 3, 6, 9, 10, 11, 14) + ), + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)), + actor_fingerprint BYTEA CHECK ( + actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32 + ), + subject_fingerprint BYTEA CHECK ( + subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32 + ), + -- Always retains attempted operation identity. Only unresolved pre-auth + -- event kind 9 omits the canonical receipt fingerprint; authenticated + -- OperatorDenied events remain linked to their exact canonical receipt. + operation_id UUID NOT NULL, + request_fingerprint BYTEA CHECK ( + request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32 + ), + correlation_id UUID NOT NULL, + attempt_id UUID NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( + octet_length(canonical_envelope) BETWEEN 1 AND 16384 + ), + envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, event_id, operation_id), + UNIQUE (community_id, event_id, event_kind, operation_id), + UNIQUE (community_id, operation_id, event_kind, attempt_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL) + ), + CHECK ( + (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) + ) +); + +-- Credential-free pre-authentication denial attempts. The five-column key is +-- exact replay identity; no row or FK occupies canonical operation/result, +-- effect, authority, approval, or consumption state. +CREATE TABLE authorization_authentication_denial_attempts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + correlation_id UUID NOT NULL, + semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), + denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)), + expected_revision BIGINT NOT NULL CHECK (expected_revision > 0), + action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + audit_event_id UUID NOT NULL, + audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY ( + community_id, + operation_id, + correlation_id, + semantic_fingerprint, + denial_reason + ), + UNIQUE (community_id, audit_event_id), + FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) + REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Exact per-operation authority-version attribution for restore. Empty +-- manifests are valid; every stored component must advance strictly. +CREATE TABLE authorization_operation_version_delta_manifests ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024), + before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32), + after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32), + manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- component_kind: 1 binding version, 2 policy revision, +-- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship +-- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector +-- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable +-- client-status revisions; retained kinds keep their original identities. +CREATE TABLE authorization_operation_version_deltas ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)), + component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32), + before_version BIGINT NOT NULL CHECK (before_version >= 0), + after_version BIGINT NOT NULL, + component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32), + PRIMARY KEY (community_id, operation_id, component_kind, component_key), + FOREIGN KEY (community_id, operation_id) + REFERENCES authorization_operation_version_delta_manifests + (community_id, operation_id), + CHECK (after_version > before_version) +); + +CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$ +DECLARE + policy authorization_event_capacity%ROWTYPE; + envelope_bytes BIGINT; +BEGIN + SELECT * INTO policy + FROM authorization_event_capacity + WHERE community_id = NEW.community_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'authorization event capacity policy missing' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_policy_required'; + END IF; + IF policy.health_state <> 1 THEN + RAISE EXCEPTION 'authorization audit is unavailable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_health'; + END IF; + + envelope_bytes := octet_length(NEW.canonical_envelope); + IF envelope_bytes > policy.max_envelope_bytes + OR policy.retained_event_count + 1 > policy.max_events_per_domain + OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain + THEN + -- The INSERT and protected mutation abort together. The runtime maps + -- this stable constraint to typed CapacityExhausted and latches audit + -- health outside the rolled-back transaction. + RAISE EXCEPTION 'authorization event capacity exhausted' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_exhausted'; + END IF; + + UPDATE authorization_event_capacity + SET retained_event_count = retained_event_count + 1, + retained_envelope_bytes = retained_envelope_bytes + envelope_bytes, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_events_capacity + BEFORE INSERT ON authorization_events + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1(); + +CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.activated_at IS DISTINCT FROM OLD.activated_at + OR NEW.current_generation <= OLD.current_generation + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_domains_monotonic + BEFORE UPDATE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_delete + BEFORE DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_domains + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind + OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint + OR NEW.floor_generation < OLD.floor_generation + OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) + OR COALESCE(NEW.relationship_revision_floor, 0) + < COALESCE(OLD.relationship_revision_floor, 0) + OR ( + NEW.floor_generation = OLD.floor_generation + AND COALESCE(NEW.binding_version_floor, 0) + = COALESCE(OLD.binding_version_floor, 0) + AND COALESCE(NEW.relationship_revision_floor, 0) + = COALESCE(OLD.relationship_revision_floor, 0) + ) + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation floor cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_floors_monotonic + BEFORE UPDATE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_delete + BEFORE DELETE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_floors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization authority epoch cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_authority_epochs_monotonic + BEFORE UPDATE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); +CREATE TRIGGER authorization_authority_epochs_no_delete + BEFORE DELETE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authority_epochs_no_truncate + BEFORE TRUNCATE ON authorization_authority_epochs + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain + OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain + OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes + OR NEW.configured_at IS DISTINCT FROM OLD.configured_at + OR NEW.retained_event_count < OLD.retained_event_count + OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes + OR NEW.updated_at < OLD.updated_at + OR (OLD.health_state = 2 AND ( + NEW.health_state <> 2 + OR NEW.failure_code IS DISTINCT FROM OLD.failure_code + OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at + )) + OR (OLD.health_state = 1 AND NEW.health_state = 1 AND ( + NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL + )) + THEN + RAISE EXCEPTION 'authorization event capacity cannot be reset online' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.issued_at <= OLD.issued_at + THEN + RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_event_capacity_monotonic + BEFORE UPDATE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1(); +CREATE TRIGGER authorization_event_capacity_no_delete + BEFORE DELETE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_event_capacity_no_truncate + BEFORE TRUNCATE ON authorization_event_capacity + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_events_immutable + BEFORE UPDATE OR DELETE ON authorization_events + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_events_no_truncate + BEFORE TRUNCATE ON authorization_events + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_authentication_denial_attempts_immutable + BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate + BEFORE TRUNCATE ON authorization_authentication_denial_attempts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + manifest authorization_operation_version_delta_manifests%ROWTYPE; + actual_component_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN + manifest := NEW; + ELSE + SELECT * INTO STRICT manifest + FROM authorization_operation_version_delta_manifests + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id + FOR NO KEY UPDATE; + END IF; + + SELECT count(*) INTO actual_component_count + FROM authorization_operation_version_deltas + WHERE community_id = manifest.community_id + AND operation_id = manifest.operation_id; + + IF actual_component_count <> manifest.component_count THEN + RAISE EXCEPTION 'operation version manifest declares % components, found %', + manifest.component_count, actual_component_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_version_delta_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality + AFTER INSERT ON authorization_operation_version_delta_manifests + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality + AFTER INSERT ON authorization_operation_version_deltas + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); + +CREATE TRIGGER authorization_operation_version_delta_manifests_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_delta_manifests + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_operation_version_deltas_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_deltas_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_deltas + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER protected_object_authority_no_delete + BEFORE DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER protected_object_authority_no_truncate + BEFORE TRUNCATE ON protected_object_authority + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER protected_object_authority_strict_replacement + BEFORE UPDATE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); + +-- Canonical admission keeps its complete logical intent and the closed, +-- credential-free application result beside the immutable receipt. This is +-- what lets an identical request replay reconstruct the same typed result +-- without repeating membership or other application DML. Object kinds match +-- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. +CREATE TABLE authorization_admission_results ( + community_id UUID NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + semantic_fingerprint BYTEA NOT NULL CHECK ( + octet_length(semantic_fingerprint) = 32 + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex') + ), + object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6), + object_key BYTEA NOT NULL CHECK ( + octet_length(object_key) = 32 + AND object_key <> decode(repeat('00', 32), 'hex') + ), + application_type BYTEA CHECK ( + application_type IS NULL + OR (octet_length(application_type) = 32 + AND application_type <> decode(repeat('00', 32), 'hex')) + ), + application_version SMALLINT CHECK (application_version > 0), + application_code SMALLINT CHECK (application_code > 0), + application_payload BYTEA CHECK ( + application_payload IS NULL OR octet_length(application_payload) <= 4096 + ), + application_intent_digest BYTEA CHECK ( + application_intent_digest IS NULL + OR (octet_length(application_intent_digest) = 32 + AND application_intent_digest <> decode(repeat('00', 32), 'hex')) + ), + application_effect_digest BYTEA CHECK ( + application_effect_digest IS NULL + OR (octet_length(application_effect_digest) = 32 + AND application_effect_digest <> decode(repeat('00', 32), 'hex')) + ), + application_result_digest BYTEA CHECK ( + application_result_digest IS NULL + OR (octet_length(application_result_digest) = 32 + AND application_result_digest <> decode(repeat('00', 32), 'hex')) + ), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint), + CHECK ( + (application_type IS NULL + AND application_version IS NULL + AND application_code IS NULL + AND application_payload IS NULL + AND application_intent_digest IS NULL + AND application_effect_digest IS NULL + AND application_result_digest IS NULL) + OR (application_type IS NOT NULL + AND application_version IS NOT NULL + AND application_code IS NOT NULL + AND application_payload IS NOT NULL + AND application_intent_digest IS NOT NULL + AND application_effect_digest IS NOT NULL + AND application_result_digest IS NOT NULL) + ) +); + +CREATE TRIGGER authorization_admission_results_no_update + BEFORE UPDATE OR DELETE ON authorization_admission_results + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_admission_results_no_truncate + BEFORE TRUNCATE ON authorization_admission_results + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +-- Every successful/no-op core lifecycle receipt has exactly one privacy-safe +-- audit event with the closed transition-kind mapping. Both directions are +-- deferred so receipt, history, event, selectors, and binding may be inserted +-- in any order inside one transaction but can never commit partially. The +-- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover, +-- 8 enable, 9 admission loss) and their event kinds arrive with the +-- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/ +-- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no +-- audit-event cardinality requirement. +CREATE FUNCTION authorization_operation_receipt_event_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + expected_event_kind SMALLINT; + matching_event_count BIGINT; + expected_event_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- Credential-free pre-authentication denials intentionally have no + -- canonical receipt. Their separate FK/shape guards still run. + RETURN NULL; + END IF; + END IF; + + expected_event_kind := CASE receipt.operation_kind + WHEN 1 THEN 1 -- enroll + WHEN 3 THEN 6 -- retire + WHEN 5 THEN 2 -- revoke + WHEN 6 THEN 3 -- rotate + ELSE NULL + END; + IF expected_event_kind IS NULL THEN + RETURN NULL; + END IF; + + SELECT + count(*), + count(*) FILTER (WHERE event_kind = expected_event_kind) + INTO matching_event_count, expected_event_count + FROM authorization_events + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id + AND request_fingerprint = receipt.request_fingerprint; + + IF matching_event_count <> 1 OR expected_event_count <> 1 THEN + RAISE EXCEPTION + 'lifecycle receipt requires exactly one event kind %, found % total and % expected', + expected_event_kind, matching_event_count, expected_event_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_event_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + +-- Same ledger posture as migration 0040's identity relations: the admission, +-- replay, audit, and invalidation relations below are append-only denial and +-- authority facts protected by immutable no_delete/no_truncate triggers, so +-- they carry community_id as provenance rather than deletable ownership. Widen +-- the single SQL source of truth so the universal write fence and the deletion +-- catalog treat all NIP-FI relations as ledger — never fence-attached, never +-- purged, never counted as tenant-scoped drift. This re-declares the full set +-- (0040's identity relations plus these) because CREATE OR REPLACE FUNCTION +-- replaces the whole body. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors', + 'authorization_invalidation_domains', 'authorization_invalidation_floors', + 'authorization_authority_epochs', 'protected_object_authority', + 'authorization_event_capacity', 'authorization_events', + 'authorization_authentication_denial_attempts', + 'authorization_operation_version_delta_manifests', + 'authorization_operation_version_deltas', 'authorization_admission_results' + ]::TEXT[]) +$$; diff --git a/schema/schema.sql b/schema/schema.sql index 5456610333..445b6d02bd 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1485,13 +1485,19 @@ $$; CREATE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ SELECT target::TEXT = ANY (ARRAY[ - 'community_deletion_requests', - 'community_deletion_approvals', - 'community_deletion_checkpoints', - 'community_serving_write_leases', - 'community_deletion_executor_heartbeats', - 'product_feedback', - 'rate_limit_violations' + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors', + 'authorization_invalidation_domains', 'authorization_invalidation_floors', + 'authorization_authority_epochs', 'protected_object_authority', + 'authorization_event_capacity', 'authorization_events', + 'authorization_authentication_denial_attempts', + 'authorization_operation_version_delta_manifests', + 'authorization_operation_version_deltas', 'authorization_admission_results' ]::TEXT[]) $$; @@ -1895,3 +1901,1561 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); + + +-- ============================================================================ +-- NIP-FI core identity + base-lifecycle foundation (mirror of migration 0040). +-- The community_write_fence_excluded_table definition above already folds in +-- the NIP-FI ledger relations; the per-migration CREATE OR REPLACE bodies are +-- intentionally omitted here (desired state keeps one consolidated definition). +-- ============================================================================ + +-- The sole idempotency/result root shared by identity base lifecycle, +-- protected operations, and invalidation. Pre-authentication denials never +-- write this table. ExactReplay and IntentConflict are read-time observations, +-- not persisted outcomes. +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + -- Core operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate, + -- 11 protected mutation, 12 invalidation. Extended lifecycle kinds + -- (2 provision, 4 disable, 7 recover, 8 enable, 9 admission loss) and + -- 10 operator are introduced by their owning later migrations. + operation_kind SMALLINT NOT NULL CHECK ( + operation_kind IN (1, 3, 5, 6, 11, 12) + ), + actor_fingerprint BYTEA NOT NULL CHECK (octet_length(actor_fingerprint) = 32), + -- 1 applied, 2 denied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3)), + result_digest BYTEA NOT NULL CHECK (octet_length(result_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Immutable monotonic local policy revisions. Enrollment modes are the closed +-- provider-free V1 set: 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. +CREATE TABLE identity_enrollment_policies ( + community_id UUID NOT NULL REFERENCES communities(id), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + enrollment_mode SMALLINT NOT NULL CHECK (enrollment_mode IN (1, 2, 3)), + policy_digest BYTEA NOT NULL CHECK (octet_length(policy_digest) = 32), + effective_at TIMESTAMPTZ NOT NULL, + -- Optional local binding-policy expiry. Federated token `exp` MUST NOT be + -- copied here: token lifetime bounds an authorization lease, not this + -- durable binding generation. + expires_at TIMESTAMPTZ, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, policy_revision), + UNIQUE (community_id, policy_revision, enrollment_mode), + CHECK (expires_at IS NULL OR effective_at < expires_at) +); + +-- One row is one immutable binding generation. binding_version is allocated +-- from one non-cycling PostgreSQL identity sequence and is never changed or +-- reused. Explicit lifecycle may only retire the generation; X/Y denial +-- semantics live in immutable selector facts below, not alternate row states. +CREATE TABLE identity_bindings ( + community_id UUID NOT NULL REFERENCES communities(id), + binding_id UUID NOT NULL, + binding_version BIGINT GENERATED ALWAYS AS IDENTITY ( + START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 NO CYCLE + ), + issuer TEXT COLLATE "C" NOT NULL CHECK (octet_length(issuer) BETWEEN 1 AND 2048), + subject TEXT COLLATE "C" NOT NULL CHECK (octet_length(subject) BETWEEN 1 AND 2048), + principal_fingerprint BYTEA NOT NULL CHECK (octet_length(principal_fingerprint) = 32), + event_author_pubkey BYTEA NOT NULL CHECK (octet_length(event_author_pubkey) = 32), + -- 1 active, 2 retired. + binding_state SMALLINT NOT NULL CHECK (binding_state IN (1, 2)), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision IN (1, 2)), + -- 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. + binding_provenance SMALLINT NOT NULL CHECK (binding_provenance IN (1, 2, 3)), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + -- Canonical evidence for the selected provenance. This is an assertion + -- digest for attested/TOFU admission and a provisioning receipt digest for + -- separately provisioned admission; it never stores credential bytes. + enrollment_evidence_digest BYTEA NOT NULL CHECK ( + octet_length(enrollment_evidence_digest) = 32 + ), + expires_at TIMESTAMPTZ, + birth_history_id UUID NOT NULL, + creation_operation_id UUID NOT NULL, + creation_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(creation_request_fingerprint) = 32 + ), + retirement_history_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, binding_id), + UNIQUE (community_id, binding_version), + UNIQUE (community_id, binding_id, binding_version), + FOREIGN KEY (community_id, policy_revision, binding_provenance) + REFERENCES identity_enrollment_policies + (community_id, policy_revision, enrollment_mode), + CHECK (binding_version > 0), + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (creation_operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (expires_at IS NULL OR created_at < expires_at), + CHECK ( + (binding_state = 1 AND lifecycle_revision = 1 AND retirement_history_id IS NULL) + OR (binding_state = 2 AND lifecycle_revision = 2 AND retirement_history_id IS NOT NULL) + ) +); + +-- State 1 is Active. Expiry is evaluated with authoritative PostgreSQL time +-- at read/finalization and is exclusive; it cannot appear in an index predicate. +CREATE UNIQUE INDEX identity_bindings_active_principal + ON identity_bindings (community_id, issuer, subject) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_principal_fingerprint_lookup + ON identity_bindings (community_id, principal_fingerprint) + WHERE binding_state = 1; +CREATE UNIQUE INDEX identity_bindings_active_event_author + ON identity_bindings (community_id, event_author_pubkey) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_current_lookup + ON identity_bindings (community_id, event_author_pubkey, binding_state, expires_at); + +-- The one canonical immutable lifecycle transition row for a successful or +-- no-op lifecycle operation. A transition can name an old generation, a new +-- successor generation, both (Rotate), or neither (a semantic no-op). It is not +-- a second result/effect engine: the shared receipt remains the sole persisted +-- operation outcome. Core transition kinds only: 1 enroll, 3 retire, 5 revoke, +-- 6 rotate. +CREATE TABLE identity_lifecycle_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL, + transition_kind SMALLINT NOT NULL CHECK ( + transition_kind IN (1, 3, 5, 6) + ), + -- Matches the shared receipt: 1 applied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 3)), + old_binding_id UUID, + old_binding_version BIGINT CHECK (old_binding_version IS NULL OR old_binding_version > 0), + old_prior_lifecycle_revision BIGINT CHECK ( + old_prior_lifecycle_revision IS NULL OR old_prior_lifecycle_revision IN (1, 2) + ), + old_prior_state SMALLINT CHECK (old_prior_state IS NULL OR old_prior_state IN (1, 2)), + old_resulting_lifecycle_revision BIGINT CHECK ( + old_resulting_lifecycle_revision IS NULL OR old_resulting_lifecycle_revision IN (1, 2) + ), + old_resulting_state SMALLINT CHECK ( + old_resulting_state IS NULL OR old_resulting_state IN (1, 2) + ), + successor_binding_id UUID, + successor_binding_version BIGINT CHECK ( + successor_binding_version IS NULL OR successor_binding_version > 0 + ), + successor_lifecycle_revision BIGINT CHECK ( + successor_lifecycle_revision IS NULL OR successor_lifecycle_revision = 1 + ), + successor_state SMALLINT CHECK (successor_state IS NULL OR successor_state = 1), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + transition_digest BYTEA NOT NULL CHECK (octet_length(transition_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, operation_id), + UNIQUE (community_id, history_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ), + UNIQUE ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ), + FOREIGN KEY ( + community_id, + operation_id, + request_fingerprint, + transition_kind, + outcome_code + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, old_binding_id, old_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, successor_binding_id, successor_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (old_binding_id IS NULL + AND old_binding_version IS NULL + AND old_prior_lifecycle_revision IS NULL + AND old_prior_state IS NULL + AND old_resulting_lifecycle_revision IS NULL + AND old_resulting_state IS NULL) + OR (old_binding_id IS NOT NULL + AND old_binding_version IS NOT NULL + AND old_prior_lifecycle_revision IS NOT NULL + AND old_prior_state IS NOT NULL + AND old_resulting_lifecycle_revision IS NOT NULL + AND old_resulting_state IS NOT NULL) + ), + CHECK ( + (successor_binding_id IS NULL + AND successor_binding_version IS NULL + AND successor_lifecycle_revision IS NULL + AND successor_state IS NULL) + OR (successor_binding_id IS NOT NULL + AND successor_binding_version IS NOT NULL + AND successor_lifecycle_revision = 1 + AND successor_state = 1) + ), + CHECK ( + old_binding_id IS NULL + OR successor_binding_id IS NULL + OR old_binding_id <> successor_binding_id + ), + CHECK ( + old_binding_version IS NULL + OR successor_binding_version IS NULL + OR old_binding_version <> successor_binding_version + ), + -- Core lifecycle only ever moves Active/r1 to Retired/r2 for a named old + -- generation. Extended re-enablement (recover/enable from Retired/r2) is a + -- later migration's concern. + CHECK ( + old_binding_id IS NULL + OR (old_prior_lifecycle_revision = 1 + AND old_prior_state = 1 + AND old_resulting_lifecycle_revision = 2 + AND old_resulting_state = 2) + ), + CHECK ( + (outcome_code = 3 + AND old_binding_id IS NULL + AND successor_binding_id IS NULL) + OR (outcome_code = 1 AND ( + (transition_kind = 1 + AND old_binding_id IS NULL + AND successor_binding_id IS NOT NULL) + OR (transition_kind = 3 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NULL) + OR (transition_kind = 5 + AND successor_binding_id IS NULL) + OR (transition_kind = 6 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NOT NULL) + )) + ) +); + +CREATE INDEX identity_lifecycle_history_old_binding + ON identity_lifecycle_history (community_id, old_binding_id, old_binding_version, recorded_at); +CREATE INDEX identity_lifecycle_history_successor_binding + ON identity_lifecycle_history ( + community_id, + successor_binding_id, + successor_binding_version, + recorded_at + ); + +-- Circular birth/transition ordering is deliberate and fully deferred. Every +-- generation must commit with its exact birth transition, and a retired row +-- must commit with the exact transition that changed Active/r1 to Retired/r2. +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_birth_history_fk + FOREIGN KEY ( + community_id, + birth_history_id, + binding_id, + binding_version, + creation_operation_id, + creation_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_retirement_history_fk + FOREIGN KEY ( + community_id, + retirement_history_id, + binding_id, + binding_version, + lifecycle_revision, + binding_state + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ) DEFERRABLE INITIALLY DEFERRED; + +-- One immutable closed-scope fact table. Core selector kinds only: +-- 1 retired pair (P), 3 revoked key (Y). Both are permanent. The extended +-- disabled-identity (X) and pending-replacement (Q) selectors, and their +-- one-shot consumption, are introduced by the FI-LIFECYCLE migration. +CREATE TABLE identity_lifecycle_selectors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_id UUID NOT NULL, + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 3)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + fact_generation BIGINT NOT NULL CHECK (fact_generation > 0), + principal_fingerprint BYTEA CHECK ( + principal_fingerprint IS NULL OR octet_length(principal_fingerprint) = 32 + ), + event_author_pubkey BYTEA CHECK ( + event_author_pubkey IS NULL OR octet_length(event_author_pubkey) = 32 + ), + binding_id UUID, + binding_version BIGINT CHECK (binding_version IS NULL OR binding_version > 0), + asserted_history_id UUID NOT NULL, + selected_by_operation_id UUID NOT NULL, + selected_by_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(selected_by_request_fingerprint) = 32 + ), + selected_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_id), + UNIQUE (community_id, selector_id, selector_kind), + UNIQUE (community_id, selector_kind, selector_fingerprint, fact_generation), + FOREIGN KEY ( + community_id, + asserted_history_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (selector_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (selector_kind = 1 + AND fact_generation = 1 + AND principal_fingerprint IS NOT NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NOT NULL + AND binding_version IS NOT NULL) + OR (selector_kind = 3 + AND fact_generation = 1 + AND principal_fingerprint IS NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NULL + AND binding_version IS NULL) + ) +); + +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_pair + ON identity_lifecycle_selectors (community_id, binding_id, binding_version) + WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_principal_key + ON identity_lifecycle_selectors ( + community_id, + principal_fingerprint, + event_author_pubkey + ) WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_key + ON identity_lifecycle_selectors (community_id, event_author_pubkey) + WHERE selector_kind = 3; +CREATE INDEX identity_lifecycle_selectors_principal_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, principal_fingerprint, fact_generation); +CREATE INDEX identity_lifecycle_selectors_key_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, event_author_pubkey, fact_generation); +CREATE INDEX identity_lifecycle_selectors_binding_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, binding_id, binding_version, fact_generation); +CREATE INDEX identity_lifecycle_selectors_asserted_history + ON identity_lifecycle_selectors + (community_id, asserted_history_id, selector_kind); + +CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% is immutable', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION nip_fi_reject_truncate_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +-- Every binding/selector path derives the same domain-scoped coordinates and +-- takes their signed BIGINT advisory keys in numeric order. Typed transaction +-- APIs take these locks before row mutation; the triggers are the fail-closed +-- backstop for direct SQL. +CREATE FUNCTION identity_lifecycle_lock_coordinates_v1( + locked_community_id UUID, + locked_principal_fingerprint BYTEA, + locked_event_author_pubkey BYTEA +) RETURNS VOID AS $$ +DECLARE + principal_lock_key BIGINT; + event_author_lock_key BIGINT; +BEGIN + IF locked_principal_fingerprint IS NOT NULL THEN + principal_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:principal:' + || locked_community_id::text || ':' + || encode(locked_principal_fingerprint, 'hex'), + 0 + ); + END IF; + IF locked_event_author_pubkey IS NOT NULL THEN + event_author_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:key:' + || locked_community_id::text || ':' + || encode(locked_event_author_pubkey, 'hex'), + 0 + ); + END IF; + + IF principal_lock_key IS NOT NULL AND event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(LEAST(principal_lock_key, event_author_lock_key)); + IF principal_lock_key <> event_author_lock_key THEN + PERFORM pg_advisory_xact_lock(GREATEST(principal_lock_key, event_author_lock_key)); + END IF; + ELSIF principal_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(principal_lock_key); + ELSIF event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(event_author_lock_key); + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + NEW.principal_fingerprint, + NEW.event_author_pubkey + ); + IF NEW.binding_state <> 1 + OR NEW.lifecycle_revision <> 1 + OR NEW.retirement_history_id IS NOT NULL + THEN + RAISE EXCEPTION 'identity binding birth must be Active at lifecycle revision 1' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_state'; + END IF; + NEW.created_at := transaction_timestamp(); + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_transition_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + PERFORM identity_lifecycle_lock_coordinates_v1( + OLD.community_id, + OLD.principal_fingerprint, + OLD.event_author_pubkey + ); + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.binding_id IS DISTINCT FROM OLD.binding_id + OR NEW.binding_version IS DISTINCT FROM OLD.binding_version + OR NEW.issuer IS DISTINCT FROM OLD.issuer + OR NEW.subject IS DISTINCT FROM OLD.subject + OR NEW.principal_fingerprint IS DISTINCT FROM OLD.principal_fingerprint + OR NEW.event_author_pubkey IS DISTINCT FROM OLD.event_author_pubkey + OR NEW.binding_provenance IS DISTINCT FROM OLD.binding_provenance + OR NEW.policy_revision IS DISTINCT FROM OLD.policy_revision + OR NEW.enrollment_evidence_digest IS DISTINCT FROM OLD.enrollment_evidence_digest + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + OR NEW.birth_history_id IS DISTINCT FROM OLD.birth_history_id + OR NEW.creation_operation_id IS DISTINCT FROM OLD.creation_operation_id + OR NEW.creation_request_fingerprint IS DISTINCT FROM OLD.creation_request_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'identity binding generation coordinates are immutable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_immutable_generation'; + END IF; + IF OLD.binding_state <> 1 + OR OLD.lifecycle_revision <> 1 + OR OLD.retirement_history_id IS NOT NULL + OR NEW.binding_state <> 2 + OR NEW.lifecycle_revision <> 2 + OR NEW.retirement_history_id IS NULL + OR NEW.retirement_history_id = OLD.birth_history_id + THEN + RAISE EXCEPTION 'identity binding permits only Active/r1 to Retired/r2' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_active_to_retired'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_history_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.recorded_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_history_semantics_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + retirement identity_lifecycle_history%ROWTYPE; +BEGIN + IF NEW.binding_state = 2 THEN + SELECT * INTO STRICT retirement + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.retirement_history_id + AND old_binding_id = NEW.binding_id + AND old_binding_version = NEW.binding_version; + IF retirement.outcome_code <> 1 + OR retirement.old_prior_lifecycle_revision <> 1 + OR retirement.old_prior_state <> 1 + OR retirement.old_resulting_lifecycle_revision <> 2 + OR retirement.old_resulting_state <> 2 + THEN + RAISE EXCEPTION 'retired binding must reference its exact Active-to-Retired transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_retirement_history_semantics'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_birth_eligibility_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + WHERE selector.community_id = NEW.community_id + AND ( + (selector.selector_kind = 1 + AND selector.principal_fingerprint = NEW.principal_fingerprint + AND selector.event_author_pubkey = NEW.event_author_pubkey) + OR (selector.selector_kind = 3 + AND selector.event_author_pubkey = NEW.event_author_pubkey) + ) + ) THEN + RAISE EXCEPTION 'binding birth conflicts with an effective lifecycle selector' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_eligibility'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION authorization_operation_receipt_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history_count BIGINT; + expected_count BIGINT; +BEGIN + SELECT count(*) INTO history_count + FROM identity_lifecycle_history history + WHERE history.community_id = NEW.community_id + AND history.operation_id = NEW.operation_id; + + -- Core lifecycle receipts (enroll, retire, revoke, rotate) each require + -- exactly one lifecycle-history row. Non-lifecycle receipts (protected + -- mutation, invalidation) require none. + expected_count := CASE + WHEN NEW.operation_kind IN (1, 3, 5, 6) AND NEW.outcome_code IN (1, 3) THEN 1 + ELSE 0 + END; + IF history_count <> expected_count THEN + RAISE EXCEPTION 'operation receipt requires % lifecycle history row, found %', + expected_count, history_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_history_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.selected_at := transaction_timestamp(); + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + CASE WHEN NEW.selector_kind = 1 THEN NEW.principal_fingerprint END, + NEW.event_author_pubkey + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history identity_lifecycle_history%ROWTYPE; + old_binding identity_bindings%ROWTYPE; +BEGIN + SELECT * INTO STRICT history + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id + AND operation_id = NEW.selected_by_operation_id + AND request_fingerprint = NEW.selected_by_request_fingerprint; + + IF history.old_binding_id IS NOT NULL THEN + SELECT * INTO STRICT old_binding + FROM identity_bindings + WHERE community_id = history.community_id + AND binding_id = history.old_binding_id + AND binding_version = history.old_binding_version; + END IF; + + -- A retired-pair (P) selector is asserted by retire, revoke, or rotate of a + -- named old generation; a revoked-key (Y) selector by revoke. + IF history.outcome_code <> 1 + OR (NEW.selector_kind = 1 AND ( + history.transition_kind NOT IN (3, 5, 6) + OR history.old_binding_id IS DISTINCT FROM NEW.binding_id + OR history.old_binding_version IS DISTINCT FROM NEW.binding_version + OR old_binding.principal_fingerprint IS DISTINCT FROM NEW.principal_fingerprint + OR old_binding.event_author_pubkey IS DISTINCT FROM NEW.event_author_pubkey + )) + OR (NEW.selector_kind = 3 AND ( + history.transition_kind <> 5 + OR (history.old_binding_id IS NOT NULL + AND old_binding.event_author_pubkey + IS DISTINCT FROM NEW.event_author_pubkey) + )) + THEN + RAISE EXCEPTION 'selector does not match its lifecycle transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_selector_history_semantics'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_transition_integrity_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + transition identity_lifecycle_history%ROWTYPE; + old_binding_state SMALLINT; + asserted_p BIGINT; + asserted_y BIGINT; +BEGIN + IF TG_TABLE_NAME = 'identity_lifecycle_history' THEN + transition := NEW; + ELSIF TG_TABLE_NAME = 'identity_lifecycle_selectors' THEN + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id; + ELSE + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = CASE + WHEN NEW.binding_state = 2 THEN NEW.retirement_history_id + ELSE NEW.birth_history_id + END; + END IF; + + SELECT + count(*) FILTER (WHERE selector_kind = 1), + count(*) FILTER (WHERE selector_kind = 3) + INTO asserted_p, asserted_y + FROM identity_lifecycle_selectors + WHERE community_id = transition.community_id + AND asserted_history_id = transition.history_id; + + IF transition.old_binding_id IS NOT NULL THEN + SELECT binding_state INTO STRICT old_binding_state + FROM identity_bindings + WHERE community_id = transition.community_id + AND binding_id = transition.old_binding_id + AND binding_version = transition.old_binding_version; + IF old_binding_state <> 2 THEN + RAISE EXCEPTION 'lifecycle transition old binding must be retired at commit' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + JOIN identity_bindings active + ON active.community_id = selector.community_id + AND active.binding_state = 1 + AND ( + (selector.selector_kind = 1 + AND active.principal_fingerprint = selector.principal_fingerprint + AND active.event_author_pubkey = selector.event_author_pubkey) + OR (selector.selector_kind = 3 + AND active.event_author_pubkey = selector.event_author_pubkey) + ) + WHERE selector.community_id = transition.community_id + ) THEN + RAISE EXCEPTION 'effective lifecycle selector conflicts with an active binding' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + + IF transition.outcome_code = 3 THEN + IF asserted_p + asserted_y <> 0 THEN + RAISE EXCEPTION 'no-op lifecycle transition cannot create selector facts' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; + END IF; + + -- Core selector companions per transition: + -- enroll (1): none + -- retire (3): exactly one P + -- revoke (5): one Y always; one P when a named old generation is removed + -- rotate (6): exactly one P (old generation retired) + IF (transition.transition_kind = 1 + AND (asserted_p, asserted_y) <> (0, 0)) + OR (transition.transition_kind = 3 + AND (asserted_p, asserted_y) <> (1, 0)) + OR (transition.transition_kind = 5 AND ( + (transition.old_binding_id IS NOT NULL + AND (asserted_p, asserted_y) <> (1, 1)) + OR (transition.old_binding_id IS NULL + AND (asserted_p, asserted_y) <> (0, 1)) + )) + OR (transition.transition_kind = 6 + AND (asserted_p, asserted_y) <> (1, 0)) + THEN + RAISE EXCEPTION 'lifecycle transition has incomplete or forbidden selector companions' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER identity_bindings_insert_guard + BEFORE INSERT ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_insert_guard_v1(); +CREATE TRIGGER identity_bindings_transition_guard + BEFORE UPDATE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_transition_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_history_semantics + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_history_semantics_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_birth_eligibility + AFTER INSERT ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_birth_eligibility_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_transition_integrity + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); +CREATE TRIGGER identity_bindings_no_delete + BEFORE DELETE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_bindings_no_truncate + BEFORE TRUNCATE ON identity_bindings + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_insert_guard + BEFORE INSERT ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_history_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_history_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity + AFTER INSERT ON identity_lifecycle_history + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER identity_lifecycle_selector_insert_guard + BEFORE INSERT ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_history_semantics + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_transition_integrity + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER authorization_operation_receipts_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_receipts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_receipts_no_truncate + BEFORE TRUNCATE ON authorization_operation_receipts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_enrollment_policies_immutable + BEFORE UPDATE OR DELETE ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_enrollment_policies_no_truncate + BEFORE TRUNCATE ON identity_enrollment_policies + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_history_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_history + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_selectors_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_selectors_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_selectors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + + +-- ============================================================================ +-- NIP-FI final-admission foundation (mirror of migration 0041). +-- ============================================================================ + +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + current_generation BIGINT NOT NULL CHECK (current_generation >= 0), + activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() +); + +-- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain, +-- 6 configuration revision. Selector 7 (delegated relationship) and its +-- relationship-revision floor are deferred to the FI-DELEG migration. +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + floor_generation BIGINT NOT NULL CHECK (floor_generation > 0), + binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK ( + (selector_kind = 3 AND binding_version_floor IS NOT NULL) + OR (selector_kind <> 3 AND binding_version_floor IS NULL) + ) +); + +-- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. Kind 7 is retired: current binding +-- status is connection-local evidence and never a durable protected object. +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, object_kind, object_key), + UNIQUE ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- Direct-final current authority for a protected object. The authorization +-- lease itself is sealed in memory and dies on restart; this durable row is the +-- exact source re-fenced immediately before a protected mutation or emission. +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + capability SMALLINT NOT NULL CHECK ( + capability IN ( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28, 29 + ) + ), + actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL CHECK (binding_version > 0), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + PRIMARY KEY (community_id, object_kind, object_key), + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) REFERENCES authorization_authority_epochs ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + CHECK (issued_at < expires_at) +); + +-- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings +-- match buzz-auth; installation limits must be sized explicitly below them. +-- V1 has no online pruning/export/reset workflow. +CREATE TABLE authorization_event_capacity ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK ( + max_events_per_domain BETWEEN 1 AND 10000 + ), + max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK ( + max_bytes_per_domain BETWEEN 1 AND 16777216 + ), + max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK ( + max_envelope_bytes BETWEEN 1 AND 16384 + ), + retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0), + retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0), + -- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1 + -- online workflow; enabled runtime latches failure when insertion aborts. + health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)), + failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)), + failure_observed_at TIMESTAMPTZ, + configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + CHECK (max_envelope_bytes <= max_bytes_per_domain), + CHECK (retained_event_count <= max_events_per_domain), + CHECK (retained_envelope_bytes <= max_bytes_per_domain), + CHECK ( + (health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL) + OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL) + ) +); + +-- Durable versioned pseudonymous authorization envelope. event_kind: +-- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied, +-- 10 protected allowed, 11 protected denied, 14 invalidation advanced. +-- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, +-- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE +-- migration, matching 0040's core lifecycle carve. Kinds 12 and 13 are +-- retired: kind 24244 publication/withdrawal is ephemeral connection state and +-- never a durable authorization event. +CREATE TABLE authorization_events ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1), + event_kind SMALLINT NOT NULL CHECK ( + event_kind IN (1, 2, 3, 6, 9, 10, 11, 14) + ), + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)), + actor_fingerprint BYTEA CHECK ( + actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32 + ), + subject_fingerprint BYTEA CHECK ( + subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32 + ), + -- Always retains attempted operation identity. Only unresolved pre-auth + -- event kind 9 omits the canonical receipt fingerprint; authenticated + -- OperatorDenied events remain linked to their exact canonical receipt. + operation_id UUID NOT NULL, + request_fingerprint BYTEA CHECK ( + request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32 + ), + correlation_id UUID NOT NULL, + attempt_id UUID NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( + octet_length(canonical_envelope) BETWEEN 1 AND 16384 + ), + envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, event_id, operation_id), + UNIQUE (community_id, event_id, event_kind, operation_id), + UNIQUE (community_id, operation_id, event_kind, attempt_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL) + ), + CHECK ( + (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) + ) +); + +-- Credential-free pre-authentication denial attempts. The five-column key is +-- exact replay identity; no row or FK occupies canonical operation/result, +-- effect, authority, approval, or consumption state. +CREATE TABLE authorization_authentication_denial_attempts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + correlation_id UUID NOT NULL, + semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), + denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)), + expected_revision BIGINT NOT NULL CHECK (expected_revision > 0), + action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + audit_event_id UUID NOT NULL, + audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY ( + community_id, + operation_id, + correlation_id, + semantic_fingerprint, + denial_reason + ), + UNIQUE (community_id, audit_event_id), + FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) + REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Exact per-operation authority-version attribution for restore. Empty +-- manifests are valid; every stored component must advance strictly. +CREATE TABLE authorization_operation_version_delta_manifests ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024), + before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32), + after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32), + manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- component_kind: 1 binding version, 2 policy revision, +-- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship +-- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector +-- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable +-- client-status revisions; retained kinds keep their original identities. +CREATE TABLE authorization_operation_version_deltas ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)), + component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32), + before_version BIGINT NOT NULL CHECK (before_version >= 0), + after_version BIGINT NOT NULL, + component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32), + PRIMARY KEY (community_id, operation_id, component_kind, component_key), + FOREIGN KEY (community_id, operation_id) + REFERENCES authorization_operation_version_delta_manifests + (community_id, operation_id), + CHECK (after_version > before_version) +); + +CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$ +DECLARE + policy authorization_event_capacity%ROWTYPE; + envelope_bytes BIGINT; +BEGIN + SELECT * INTO policy + FROM authorization_event_capacity + WHERE community_id = NEW.community_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'authorization event capacity policy missing' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_policy_required'; + END IF; + IF policy.health_state <> 1 THEN + RAISE EXCEPTION 'authorization audit is unavailable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_health'; + END IF; + + envelope_bytes := octet_length(NEW.canonical_envelope); + IF envelope_bytes > policy.max_envelope_bytes + OR policy.retained_event_count + 1 > policy.max_events_per_domain + OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain + THEN + -- The INSERT and protected mutation abort together. The runtime maps + -- this stable constraint to typed CapacityExhausted and latches audit + -- health outside the rolled-back transaction. + RAISE EXCEPTION 'authorization event capacity exhausted' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_exhausted'; + END IF; + + UPDATE authorization_event_capacity + SET retained_event_count = retained_event_count + 1, + retained_envelope_bytes = retained_envelope_bytes + envelope_bytes, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_events_capacity + BEFORE INSERT ON authorization_events + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1(); + +CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.activated_at IS DISTINCT FROM OLD.activated_at + OR NEW.current_generation <= OLD.current_generation + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_domains_monotonic + BEFORE UPDATE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_delete + BEFORE DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_domains + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind + OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint + OR NEW.floor_generation < OLD.floor_generation + OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) + OR COALESCE(NEW.relationship_revision_floor, 0) + < COALESCE(OLD.relationship_revision_floor, 0) + OR ( + NEW.floor_generation = OLD.floor_generation + AND COALESCE(NEW.binding_version_floor, 0) + = COALESCE(OLD.binding_version_floor, 0) + AND COALESCE(NEW.relationship_revision_floor, 0) + = COALESCE(OLD.relationship_revision_floor, 0) + ) + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation floor cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_floors_monotonic + BEFORE UPDATE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_delete + BEFORE DELETE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_floors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization authority epoch cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_authority_epochs_monotonic + BEFORE UPDATE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); +CREATE TRIGGER authorization_authority_epochs_no_delete + BEFORE DELETE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authority_epochs_no_truncate + BEFORE TRUNCATE ON authorization_authority_epochs + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain + OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain + OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes + OR NEW.configured_at IS DISTINCT FROM OLD.configured_at + OR NEW.retained_event_count < OLD.retained_event_count + OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes + OR NEW.updated_at < OLD.updated_at + OR (OLD.health_state = 2 AND ( + NEW.health_state <> 2 + OR NEW.failure_code IS DISTINCT FROM OLD.failure_code + OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at + )) + OR (OLD.health_state = 1 AND NEW.health_state = 1 AND ( + NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL + )) + THEN + RAISE EXCEPTION 'authorization event capacity cannot be reset online' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.issued_at <= OLD.issued_at + THEN + RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_event_capacity_monotonic + BEFORE UPDATE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1(); +CREATE TRIGGER authorization_event_capacity_no_delete + BEFORE DELETE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_event_capacity_no_truncate + BEFORE TRUNCATE ON authorization_event_capacity + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_events_immutable + BEFORE UPDATE OR DELETE ON authorization_events + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_events_no_truncate + BEFORE TRUNCATE ON authorization_events + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_authentication_denial_attempts_immutable + BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate + BEFORE TRUNCATE ON authorization_authentication_denial_attempts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + manifest authorization_operation_version_delta_manifests%ROWTYPE; + actual_component_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN + manifest := NEW; + ELSE + SELECT * INTO STRICT manifest + FROM authorization_operation_version_delta_manifests + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id + FOR NO KEY UPDATE; + END IF; + + SELECT count(*) INTO actual_component_count + FROM authorization_operation_version_deltas + WHERE community_id = manifest.community_id + AND operation_id = manifest.operation_id; + + IF actual_component_count <> manifest.component_count THEN + RAISE EXCEPTION 'operation version manifest declares % components, found %', + manifest.component_count, actual_component_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_version_delta_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality + AFTER INSERT ON authorization_operation_version_delta_manifests + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality + AFTER INSERT ON authorization_operation_version_deltas + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); + +CREATE TRIGGER authorization_operation_version_delta_manifests_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_delta_manifests + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_operation_version_deltas_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_deltas_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_deltas + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER protected_object_authority_no_delete + BEFORE DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER protected_object_authority_no_truncate + BEFORE TRUNCATE ON protected_object_authority + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER protected_object_authority_strict_replacement + BEFORE UPDATE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); + +-- Canonical admission keeps its complete logical intent and the closed, +-- credential-free application result beside the immutable receipt. This is +-- what lets an identical request replay reconstruct the same typed result +-- without repeating membership or other application DML. Object kinds match +-- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. +CREATE TABLE authorization_admission_results ( + community_id UUID NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + semantic_fingerprint BYTEA NOT NULL CHECK ( + octet_length(semantic_fingerprint) = 32 + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex') + ), + object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6), + object_key BYTEA NOT NULL CHECK ( + octet_length(object_key) = 32 + AND object_key <> decode(repeat('00', 32), 'hex') + ), + application_type BYTEA CHECK ( + application_type IS NULL + OR (octet_length(application_type) = 32 + AND application_type <> decode(repeat('00', 32), 'hex')) + ), + application_version SMALLINT CHECK (application_version > 0), + application_code SMALLINT CHECK (application_code > 0), + application_payload BYTEA CHECK ( + application_payload IS NULL OR octet_length(application_payload) <= 4096 + ), + application_intent_digest BYTEA CHECK ( + application_intent_digest IS NULL + OR (octet_length(application_intent_digest) = 32 + AND application_intent_digest <> decode(repeat('00', 32), 'hex')) + ), + application_effect_digest BYTEA CHECK ( + application_effect_digest IS NULL + OR (octet_length(application_effect_digest) = 32 + AND application_effect_digest <> decode(repeat('00', 32), 'hex')) + ), + application_result_digest BYTEA CHECK ( + application_result_digest IS NULL + OR (octet_length(application_result_digest) = 32 + AND application_result_digest <> decode(repeat('00', 32), 'hex')) + ), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint), + CHECK ( + (application_type IS NULL + AND application_version IS NULL + AND application_code IS NULL + AND application_payload IS NULL + AND application_intent_digest IS NULL + AND application_effect_digest IS NULL + AND application_result_digest IS NULL) + OR (application_type IS NOT NULL + AND application_version IS NOT NULL + AND application_code IS NOT NULL + AND application_payload IS NOT NULL + AND application_intent_digest IS NOT NULL + AND application_effect_digest IS NOT NULL + AND application_result_digest IS NOT NULL) + ) +); + +CREATE TRIGGER authorization_admission_results_no_update + BEFORE UPDATE OR DELETE ON authorization_admission_results + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_admission_results_no_truncate + BEFORE TRUNCATE ON authorization_admission_results + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +-- Every successful/no-op core lifecycle receipt has exactly one privacy-safe +-- audit event with the closed transition-kind mapping. Both directions are +-- deferred so receipt, history, event, selectors, and binding may be inserted +-- in any order inside one transaction but can never commit partially. The +-- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover, +-- 8 enable, 9 admission loss) and their event kinds arrive with the +-- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/ +-- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no +-- audit-event cardinality requirement. +CREATE FUNCTION authorization_operation_receipt_event_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + expected_event_kind SMALLINT; + matching_event_count BIGINT; + expected_event_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- Credential-free pre-authentication denials intentionally have no + -- canonical receipt. Their separate FK/shape guards still run. + RETURN NULL; + END IF; + END IF; + + expected_event_kind := CASE receipt.operation_kind + WHEN 1 THEN 1 -- enroll + WHEN 3 THEN 6 -- retire + WHEN 5 THEN 2 -- revoke + WHEN 6 THEN 3 -- rotate + ELSE NULL + END; + IF expected_event_kind IS NULL THEN + RETURN NULL; + END IF; + + SELECT + count(*), + count(*) FILTER (WHERE event_kind = expected_event_kind) + INTO matching_event_count, expected_event_count + FROM authorization_events + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id + AND request_fingerprint = receipt.request_fingerprint; + + IF matching_event_count <> 1 OR expected_event_count <> 1 THEN + RAISE EXCEPTION + 'lifecycle receipt requires exactly one event kind %, found % total and % expected', + expected_event_kind, matching_event_count, expected_event_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_event_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); +