diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd37023a..3b3038764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `persistence_postgres` append-only immutability migration (`0004`): `reject_append_only_mutation`, statement-level `BEFORE UPDATE OR DELETE OR TRUNCATE` triggers on identity/manifest tables, `REVOKE UPDATE`/`DELETE`/`TRUNCATE` from `tepp_app_runtime`, executable DDL/rollback contracts, and live representative mutation proof. - `persistence_postgres` model-run artifact chain: migration `0003_model_run_artifact_chain` for append-only `corpus_split_manifest`, `model_run`, and `model_artifact` with FORCE RLS; SQL insert/lookup contracts and live repository methods binding runs to reproducibility manifests and optional splits. - `persistence_postgres` append-only reproducibility-manifest SQL contracts and live repository methods (`insert_reproducibility_manifest`, digest/id lookup) with fail-closed SHA-256 and commit identity validation for `reproducibility_manifest`. - `persistence_postgres` tenant row-level security: migration `0002_tenant_row_level_security`, `tepp_app_runtime` role, session GUC `tepp.current_tenant_record_id`, multi-word isolation policies with FORCE RLS, session helpers, contract validation, and live isolation proof under `TEPP_LIVE_POSTGRES=1`. diff --git a/crates/persistence_postgres/src/error.rs b/crates/persistence_postgres/src/error.rs index 063f0193a..1130c346f 100644 --- a/crates/persistence_postgres/src/error.rs +++ b/crates/persistence_postgres/src/error.rs @@ -65,6 +65,8 @@ pub enum MigrationContractError { MissingAppRuntimeRole, /// Tenant RLS was declared without the session tenant GUC contract. MissingTenantSessionGuc, + /// Append-only immutability triggers or revoke statements were incomplete. + MissingAppendOnlyTrigger, } impl fmt::Display for MigrationContractError { @@ -78,6 +80,7 @@ impl fmt::Display for MigrationContractError { Self::MissingRlsPolicy => "missing tenant isolation policy", Self::MissingAppRuntimeRole => "missing application runtime role", Self::MissingTenantSessionGuc => "missing tenant session guc", + Self::MissingAppendOnlyTrigger => "missing append-only immutability trigger", }; formatter.write_str(message) } @@ -159,5 +162,9 @@ mod tests { MigrationContractError::MissingTenantSessionGuc.to_string(), "missing tenant session guc" ); + assert_eq!( + MigrationContractError::MissingAppendOnlyTrigger.to_string(), + "missing append-only immutability trigger" + ); } } diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 4c1bff8ae..a18046b50 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -12,6 +12,10 @@ const RLS_DOWN: &str = include_str!("../../../migrations/0002_tenant_row_level_s const MODEL_RUN_UP: &str = include_str!("../../../migrations/0003_model_run_artifact_chain.up.sql"); const MODEL_RUN_DOWN: &str = include_str!("../../../migrations/0003_model_run_artifact_chain.down.sql"); +const APPEND_ONLY_UP: &str = + include_str!("../../../migrations/0004_append_only_immutability_triggers.up.sql"); +const APPEND_ONLY_DOWN: &str = + include_str!("../../../migrations/0004_append_only_immutability_triggers.down.sql"); /// Forward and rollback SQL for one migration unit. #[derive(Clone, Debug, Eq, PartialEq)] @@ -28,8 +32,9 @@ impl MigrationCatalog { /// Returns [`MigrationContractError::EmptyMigrationSql`] when embedded /// sources are unexpectedly empty. pub fn from_embedded() -> Result { - let up_sql = format!("{FOUNDATION_UP}\n{RLS_UP}\n{MODEL_RUN_UP}"); - let down_sql = format!("{MODEL_RUN_DOWN}\n{RLS_DOWN}\n{FOUNDATION_DOWN}"); + let up_sql = format!("{FOUNDATION_UP}\n{RLS_UP}\n{MODEL_RUN_UP}\n{APPEND_ONLY_UP}"); + let down_sql = + format!("{APPEND_ONLY_DOWN}\n{MODEL_RUN_DOWN}\n{RLS_DOWN}\n{FOUNDATION_DOWN}"); Self::from_sources(&up_sql, &down_sql) } @@ -97,7 +102,40 @@ pub fn validate_migration_catalog( if declares_row_level_security(catalog.up_sql()) { validate_tenant_rls_contract(catalog.up_sql(), &tables)?; } + if declares_append_only_immutability(catalog.up_sql()) { + validate_append_only_immutability(catalog.up_sql())?; + } + + Ok(()) +} + +fn declares_append_only_immutability(up_sql: &str) -> bool { + let lower = up_sql.to_ascii_lowercase(); + lower.contains("reject_append_only_mutation") || lower.contains("_reject_mutation") +} +fn validate_append_only_immutability(up_sql: &str) -> Result<(), MigrationContractError> { + let lower = up_sql.to_ascii_lowercase(); + if !lower.contains("create or replace function reject_append_only_mutation") { + return Err(MigrationContractError::MissingAppendOnlyTrigger); + } + let required = [ + "source_artifact", + "audit_event", + "reproducibility_manifest", + "corpus_split_manifest", + "model_run", + "model_artifact", + ]; + for table in required { + let trigger = format!("{table}_reject_mutation"); + if !lower.contains(&format!("create trigger {trigger}")) { + return Err(MigrationContractError::MissingAppendOnlyTrigger); + } + if !lower.contains(&format!("revoke update, delete on table {table}")) { + return Err(MigrationContractError::MissingAppendOnlyTrigger); + } + } Ok(()) } @@ -548,6 +586,95 @@ mod tests { ); } + #[test] + fn append_only_immutability_contract_fails_closed() { + let missing_function = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE TRIGGER source_artifact_reject_mutation + BEFORE UPDATE ON source_artifact + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_function), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + + let missing_trigger = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE OR REPLACE FUNCTION reject_append_only_mutation() + RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_trigger), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + + // All triggers present; REVOKE omitted only for model_artifact so the + // last revoke branch returns MissingAppendOnlyTrigger. + let missing_revoke = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE OR REPLACE FUNCTION reject_append_only_mutation() + RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + CREATE TRIGGER source_artifact_reject_mutation + BEFORE UPDATE ON source_artifact + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE source_artifact FROM tepp_app_runtime; + CREATE TRIGGER audit_event_reject_mutation + BEFORE UPDATE ON audit_event + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE audit_event FROM tepp_app_runtime; + CREATE TRIGGER reproducibility_manifest_reject_mutation + BEFORE UPDATE ON reproducibility_manifest + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE reproducibility_manifest FROM tepp_app_runtime; + CREATE TRIGGER corpus_split_manifest_reject_mutation + BEFORE UPDATE ON corpus_split_manifest + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE corpus_split_manifest FROM tepp_app_runtime; + CREATE TRIGGER model_run_reject_mutation + BEFORE UPDATE ON model_run + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE model_run FROM tepp_app_runtime; + CREATE TRIGGER model_artifact_reject_mutation + BEFORE UPDATE ON model_artifact + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_revoke), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + + assert!(super::declares_append_only_immutability( + "CREATE TRIGGER source_artifact_reject_mutation" + )); + assert!(!super::declares_append_only_immutability("CREATE TABLE x")); + assert_eq!( + super::validate_append_only_immutability( + "CREATE TRIGGER source_artifact_reject_mutation BEFORE UPDATE ON source_artifact \ + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation();" + ), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + } + #[test] fn empty_and_malformed_sql_fail_closed() { let empty = MigrationCatalog::from_sql(" ", "DROP TABLE x;"); diff --git a/crates/persistence_postgres/tests/append_only_migration_contract.rs b/crates/persistence_postgres/tests/append_only_migration_contract.rs new file mode 100644 index 000000000..1262df373 --- /dev/null +++ b/crates/persistence_postgres/tests/append_only_migration_contract.rs @@ -0,0 +1,79 @@ +//! Append-only migration DDL must block every destructive table operation. + +use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; + +const APPEND_ONLY_TABLES: [&str; 6] = [ + "source_artifact", + "audit_event", + "reproducibility_manifest", + "corpus_split_manifest", + "model_run", + "model_artifact", +]; + +fn normalized(sql: &str) -> String { + sql.split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +#[test] +fn embedded_append_only_ddl_rejects_update_delete_and_truncate() { + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + validate_migration_catalog(&catalog).expect("embedded migration contract"); + + let up_sql = normalized(catalog.up_sql()); + assert!(up_sql.contains("raise exception 'append-only table % rejects %'")); + assert!(up_sql.contains("errcode = 'integrity_constraint_violation'")); + + for table in APPEND_ONLY_TABLES { + assert!( + up_sql.contains(&format!( + "revoke update, delete on table {table} from tepp_app_runtime" + )), + "runtime role must not retain UPDATE/DELETE on {table}" + ); + assert!( + up_sql.contains(&format!( + "revoke truncate on table {table} from tepp_app_runtime" + )), + "runtime role must not retain TRUNCATE on {table}" + ); + assert!( + up_sql.contains(&format!( + "create trigger {table}_reject_mutation before update or delete or truncate on {table} for each statement execute function reject_append_only_mutation()" + )), + "statement trigger must reject UPDATE, DELETE, and TRUNCATE on {table}" + ); + } +} + +#[test] +fn rollback_removes_rejection_triggers_without_granting_truncate() { + let catalog = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + let down_sql = normalized(catalog.down_sql()); + + assert!(down_sql.contains("drop function if exists reject_append_only_mutation()")); + assert!(down_sql.contains( + "drop trigger if exists %i on %i', trigger_table || '_reject_mutation', trigger_table" + )); + for table in APPEND_ONLY_TABLES { + assert!( + down_sql.contains(&format!("'{table}'")), + "rollback trigger inventory must include {table}" + ); + assert!( + down_sql.contains(&format!( + "grant update, delete on table {table} to tepp_app_runtime" + )), + "rollback must restore only the privileges granted before migration 0004 on {table}" + ); + assert!( + !down_sql.contains(&format!( + "grant truncate on table {table} to tepp_app_runtime" + )), + "rollback must not introduce a new TRUNCATE privilege on {table}" + ); + } +} diff --git a/crates/persistence_postgres/tests/live_postgres.rs b/crates/persistence_postgres/tests/live_postgres.rs index 122dd28a8..093664e8f 100644 --- a/crates/persistence_postgres/tests/live_postgres.rs +++ b/crates/persistence_postgres/tests/live_postgres.rs @@ -176,9 +176,35 @@ fn live_postgres_applies_migrations_and_document_sql() { .expect("select reproducibility_manifest by id"); exercise_model_run_artifact_chain(&mut repo, tenant_record_id, &manifest, available); + prove_append_only_immutability(&mut repo, &manifest); prove_tenant_rls_isolation(&mut repo); } +/// Append-only triggers must reject UPDATE/DELETE on identity tables. +fn prove_append_only_immutability( + repo: &mut LiveDocumentRepository, + manifest: &ReproducibilityManifestRecord, +) { + let update = format!( + "UPDATE reproducibility_manifest SET code_commit_sha = 'deadbeef' \ + WHERE reproducibility_manifest_id = '{}'::uuid", + manifest.reproducibility_manifest_id + ); + assert!( + repo.session_mut().execute(&update).is_err(), + "UPDATE must fail on append-only reproducibility_manifest" + ); + let delete = format!( + "DELETE FROM reproducibility_manifest \ + WHERE reproducibility_manifest_id = '{}'::uuid", + manifest.reproducibility_manifest_id + ); + assert!( + repo.session_mut().execute(&delete).is_err(), + "DELETE must fail on append-only reproducibility_manifest" + ); +} + fn exercise_model_run_artifact_chain( repo: &mut LiveDocumentRepository, tenant_record_id: Uuid, diff --git a/docs/ERD.md b/docs/ERD.md index 8dc9f742b..ae00ac89e 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -3,7 +3,7 @@ **Status:** Accepted logical target model with current implementation maturity explicitly marked. **Last reviewed:** 2026-08-13 -Protected main implements storage-independent domain objects plus `persistence_postgres` foundation tables (`0001`) and tenant row-level security (`0002`) as executable migration contracts with live CI. Broader planned ERD entities, exactly-one membership constraints, and backup/recovery gates remain accepted-target until each lands with exact-head evidence. +Protected main implements storage-independent domain objects plus `persistence_postgres` foundation tables (`0001`), tenant row-level security (`0002`), and the model-run/artifact chain (`0003`) as executable migration contracts with live CI. Migration `0004` adds append-only mutation controls on the active PR and must not be treated as implemented-main until exact-head checks, review, and protected-main integration complete. Broader planned ERD entities, exactly-one membership constraints, concurrent-write acceptance, and backup/recovery gates remain accepted-target until each lands with exact-head evidence. ## Current domain foundation @@ -355,7 +355,7 @@ Every `MODEL_RUN` binds two immutable identities: - `corpus_split_manifest_id`: the exact relation-aware train/validation/test split, including the relation-component digest, split policy version, partition hashes, and knowledge cutoff used to prevent translation/revision/episode leakage; - `reproducibility_manifest_id`: the exact source/evidence manifests, preprocessing and concept-dictionary versions, model contract/configuration, dependency lock, Git commit, and provenance-manifest identity used for the run. -Both manifest tables are append-only identity records. Their `canonical_payload_hash` is the lowercase SHA-256 digest of a versioned, deterministically encoded payload containing every identity-bearing field; `split_manifest_hash` and `reproducibility_manifest_hash` remain the public domain-specific identities and must match that canonical payload under their declared algorithm version. Database roles deny UPDATE and DELETE, and a defense-in-depth trigger rejects either operation. When `protected_object_ref` is present, it addresses a versioned immutable object; every read recomputes and compares the object digest before trusting its payload. A missing object, mutable reference, digest mismatch, or changed payload fails closed. +Both manifest tables are append-only identity records. Their `canonical_payload_hash` is the lowercase SHA-256 digest of a versioned, deterministically encoded payload containing every identity-bearing field; `split_manifest_hash` and `reproducibility_manifest_hash` remain the public domain-specific identities and must match that canonical payload under their declared algorithm version. Migration `0004` applies defense in depth by revoking `UPDATE`, `DELETE`, and `TRUNCATE` from the application runtime role and installing statement-level `BEFORE UPDATE OR DELETE OR TRUNCATE` triggers on governed identity/manifest tables. These controls prevent ordinary application-role mutation and trip owner-session mistakes inside the governed migration lifecycle; they do not claim to resist a superuser or owner who deliberately drops or disables the controls. When `protected_object_ref` is present, it addresses a versioned immutable object; every read recomputes and compares the object digest before trusting its payload. A missing object, mutable reference, digest mismatch, or changed payload fails closed. `MODEL_RUN.random_seed_manifest_hash` resolves to an immutable, digest-verified seed manifest that fixes every model/sampler seed without exposing secret entropy in ordinary logs. `MODEL_RUN.compute_backend_code` fixes the governed CPU/GPU backend contract used by that run, including the referenced implementation/version evidence. These fields are part of the run identity and must agree with the referenced reproducibility manifest's configuration payload. @@ -369,4 +369,4 @@ Every published analytical artifact must be traceable to source hashes, evidence ## Migration acceptance -Before this planned ERD becomes as-built, migrations must include rollback, tenant/RLS policy, temporal relation constraints/indexes, exactly-one membership constraints, relation-aware split/manifests, lineage integrity, idempotency/concurrency, retention/deletion, backup/recovery, and synthetic known-truth integration tests. The documentation maturity label then changes only after protected-main integration and exact-current-head evidence. \ No newline at end of file +Before the remaining planned ERD becomes as-built, migrations must include rollback, tenant/RLS policy, temporal relation constraints/indexes, exactly-one membership constraints, relation-aware split/manifests, lineage integrity, idempotency/concurrency, retention/deletion, backup/recovery, and synthetic known-truth integration tests. The documentation maturity label then changes only after protected-main integration and exact-current-head evidence. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index fecd046f2..c18b3c94c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -17,7 +17,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; multilevel estimators remaining | partial | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | -| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof; remaining physical ERD/constraints/backup remaining | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers; remaining physical ERD/constraints/backup remaining | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | diff --git a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md index ffd395100..42fe2bcf4 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -1,7 +1,7 @@ # ADR 0013 — Bitemporal persistence, reproducibility manifests, and split authority **Decision status:** Accepted -**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, and model-run / model-artifact / corpus-split-manifest chain (migration `0003`) implemented; full physical ERD constraints, concurrent write stress, and backup/restore remain accepted-target +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), and append-only immutability triggers (migration `0004`) implemented; remaining physical ERD constraints, concurrent write stress, and backup/restore remain accepted-target **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership). diff --git a/docs/research/append-only-immutability-triggers.md b/docs/research/append-only-immutability-triggers.md new file mode 100644 index 000000000..9d8c46660 --- /dev/null +++ b/docs/research/append-only-immutability-triggers.md @@ -0,0 +1,30 @@ +# Append-only immutability triggers (doctoring) + +## Scope + +Migration `0004` adds defense-in-depth so identity and manifest tables cannot be rewritten after insert: + +- `tepp_app_runtime` loses `UPDATE`, `DELETE`, and `TRUNCATE` privileges; +- one `BEFORE UPDATE OR DELETE OR TRUNCATE` trigger is attached to every append-only table; and +- triggers run `FOR EACH STATEMENT`, so a destructive statement is rejected even when its predicate would affect zero rows. + +PostgreSQL permits multiple trigger events in one definition, but `TRUNCATE` triggers are statement-level only. The shared trigger function does not inspect row images, so a statement-level trigger is the narrowest contract that blocks all three destructive operations consistently. + +The migration does not claim protection from a database superuser or owner who deliberately drops or disables the control. It provides enforceable application-role least privilege and a table-owner mutation tripwire within the governed schema lifecycle. + +## Authority + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +National Academies of Sciences, Engineering, and Medicine. (2019). *Reproducibility and replicability in science*. The National Academies Press. https://doi.org/10.17226/25303 + +International Organization for Standardization. (2011). *Information technology—Database languages—SQL—Part 2: Foundation (SQL/Foundation)* (ISO/IEC Standard No. 9075-2:2011). + +PostgreSQL Global Development Group. (2026). *CREATE TRIGGER*. In *PostgreSQL 18 documentation*. https://www.postgresql.org/docs/18/sql-createtrigger.html + +## Verification + +- catalog validation requires the rejection function, multi-word trigger identities, and `UPDATE`/`DELETE` revokes for every append-only table; +- an executable migration contract test verifies every embedded trigger is bound to its intended table, covers `UPDATE`, `DELETE`, and `TRUNCATE`, is statement-level, and invokes `reject_append_only_mutation`; +- the same contract verifies `TRUNCATE` is revoked without being newly granted by rollback; and +- live PostgreSQL CI proves representative `UPDATE`/`DELETE` mutation attempts on `reproducibility_manifest` fail closed when `TEPP_LIVE_POSTGRES=1`. diff --git a/migrations/0004_append_only_immutability_triggers.down.sql b/migrations/0004_append_only_immutability_triggers.down.sql new file mode 100644 index 000000000..a5fff6f71 --- /dev/null +++ b/migrations/0004_append_only_immutability_triggers.down.sql @@ -0,0 +1,49 @@ +-- Rollback for 0004_append_only_immutability_triggers. +-- Safe on empty databases via existence guards. + +DO $tepp$ +DECLARE + trigger_table text; +BEGIN + FOREACH trigger_table IN ARRAY ARRAY[ + 'source_artifact', + 'audit_event', + 'reproducibility_manifest', + 'corpus_split_manifest', + 'model_run', + 'model_artifact' + ] + LOOP + IF to_regclass(format('public.%I', trigger_table)) IS NOT NULL THEN + EXECUTE format( + 'DROP TRIGGER IF EXISTS %I ON %I', + trigger_table || '_reject_mutation', + trigger_table + ); + END IF; + END LOOP; + + DROP FUNCTION IF EXISTS reject_append_only_mutation(); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tepp_app_runtime') THEN + IF to_regclass('public.source_artifact') IS NOT NULL THEN + EXECUTE 'GRANT UPDATE, DELETE ON TABLE source_artifact TO tepp_app_runtime'; + END IF; + IF to_regclass('public.audit_event') IS NOT NULL THEN + EXECUTE 'GRANT UPDATE, DELETE ON TABLE audit_event TO tepp_app_runtime'; + END IF; + IF to_regclass('public.reproducibility_manifest') IS NOT NULL THEN + EXECUTE 'GRANT UPDATE, DELETE ON TABLE reproducibility_manifest TO tepp_app_runtime'; + END IF; + IF to_regclass('public.corpus_split_manifest') IS NOT NULL THEN + EXECUTE 'GRANT UPDATE, DELETE ON TABLE corpus_split_manifest TO tepp_app_runtime'; + END IF; + IF to_regclass('public.model_run') IS NOT NULL THEN + EXECUTE 'GRANT UPDATE, DELETE ON TABLE model_run TO tepp_app_runtime'; + END IF; + IF to_regclass('public.model_artifact') IS NOT NULL THEN + EXECUTE 'GRANT UPDATE, DELETE ON TABLE model_artifact TO tepp_app_runtime'; + END IF; + END IF; +END +$tepp$; diff --git a/migrations/0004_append_only_immutability_triggers.up.sql b/migrations/0004_append_only_immutability_triggers.up.sql new file mode 100644 index 000000000..274ef4d8c --- /dev/null +++ b/migrations/0004_append_only_immutability_triggers.up.sql @@ -0,0 +1,52 @@ +-- Append-only immutability defense-in-depth (ADR 0013 / ERD). +-- Database roles lose UPDATE/DELETE/TRUNCATE on identity/manifest tables; +-- statement-level triggers reject all destructive operations, including +-- zero-row UPDATE/DELETE statements and TRUNCATE by table owners. + +CREATE OR REPLACE FUNCTION reject_append_only_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RAISE EXCEPTION 'append-only table % rejects %', TG_TABLE_NAME, TG_OP + USING ERRCODE = 'integrity_constraint_violation'; +END +$tepp$; + +-- Least-privilege application role: insert/select only on identity tables. +REVOKE UPDATE, DELETE ON TABLE source_artifact FROM tepp_app_runtime; +REVOKE TRUNCATE ON TABLE source_artifact FROM tepp_app_runtime; +REVOKE UPDATE, DELETE ON TABLE audit_event FROM tepp_app_runtime; +REVOKE TRUNCATE ON TABLE audit_event FROM tepp_app_runtime; +REVOKE UPDATE, DELETE ON TABLE reproducibility_manifest FROM tepp_app_runtime; +REVOKE TRUNCATE ON TABLE reproducibility_manifest FROM tepp_app_runtime; +REVOKE UPDATE, DELETE ON TABLE corpus_split_manifest FROM tepp_app_runtime; +REVOKE TRUNCATE ON TABLE corpus_split_manifest FROM tepp_app_runtime; +REVOKE UPDATE, DELETE ON TABLE model_run FROM tepp_app_runtime; +REVOKE TRUNCATE ON TABLE model_run FROM tepp_app_runtime; +REVOKE UPDATE, DELETE ON TABLE model_artifact FROM tepp_app_runtime; +REVOKE TRUNCATE ON TABLE model_artifact FROM tepp_app_runtime; + +CREATE TRIGGER source_artifact_reject_mutation + BEFORE UPDATE OR DELETE OR TRUNCATE ON source_artifact + FOR EACH STATEMENT EXECUTE FUNCTION reject_append_only_mutation(); + +CREATE TRIGGER audit_event_reject_mutation + BEFORE UPDATE OR DELETE OR TRUNCATE ON audit_event + FOR EACH STATEMENT EXECUTE FUNCTION reject_append_only_mutation(); + +CREATE TRIGGER reproducibility_manifest_reject_mutation + BEFORE UPDATE OR DELETE OR TRUNCATE ON reproducibility_manifest + FOR EACH STATEMENT EXECUTE FUNCTION reject_append_only_mutation(); + +CREATE TRIGGER corpus_split_manifest_reject_mutation + BEFORE UPDATE OR DELETE OR TRUNCATE ON corpus_split_manifest + FOR EACH STATEMENT EXECUTE FUNCTION reject_append_only_mutation(); + +CREATE TRIGGER model_run_reject_mutation + BEFORE UPDATE OR DELETE OR TRUNCATE ON model_run + FOR EACH STATEMENT EXECUTE FUNCTION reject_append_only_mutation(); + +CREATE TRIGGER model_artifact_reject_mutation + BEFORE UPDATE OR DELETE OR TRUNCATE ON model_artifact + FOR EACH STATEMENT EXECUTE FUNCTION reject_append_only_mutation();