diff --git a/AGENTS.md b/AGENTS.md index cebc790a9..b532d47c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,9 @@ pnpm run lint && pnpm run test && pnpm run build A run-bearing analysis-run registry empties only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin` (ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not -expose purge on a public HTTP route. +expose purge on a public HTTP route. After a start, migration 0023 +(ADR 0022 / v0.88.2) makes that purge delete reconstruction and +snapshot-member rows first. `POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 / diff --git a/CHANGELOG.d/0.88.2-retention-purge-reconstruction.md b/CHANGELOG.d/0.88.2-retention-purge-reconstruction.md new file mode 100644 index 000000000..625fa9fe5 --- /dev/null +++ b/CHANGELOG.d/0.88.2-retention-purge-reconstruction.md @@ -0,0 +1,8 @@ +# 0.88.2 purge reconstruction with the granted path + +After starting a Pending lineage run, empty the registry the same way: +insert an unrevoked `analysis_run_retention_grant`, grant +`analysis_run_retention_admin`, then +`select purge_analysis_run_registry('approved-retention-purge')`. +Export the retention event, then roll back 0023, 0022, 0021, 0020, and +0018. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42239b071..eeb2eb85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.88.2] - 2026-08-16 + +### Fixed + +- A granted `purge_analysis_run_registry` now deletes reconstruction + edges and frozen snapshot members before the 0018 registry tables + (ADR 0022). After you start a Pending lineage run, the documented + grant + admin + phrase path still empties the registry. Then export + `analysis_run_retention_event` and roll back 0023 through 0018. + ## [0.88.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5096a8ea5..f8f1048b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,8 @@ To empty a run-bearing registry, insert an unrevoked `GRANT analysis_run_retention_admin` (ADR 0020). Then `select purge_analysis_run_registry('approved-retention-purge')`, export `analysis_run_retention_event`, delete those rows, and roll -back 0020 then 0018. The published phrase is not a secret. Do not +back 0023, 0022, 0021, 0020, then 0018 when reconstruction rows exist +(ADR 0022). The published phrase is not a secret. Do not `DISABLE TRIGGER` as superuser. Do not grant the admin role or a retention grant to the application `DATABASE_URL` login. ADR 0019 is the R&R catalog-id bind, not this purge. @@ -27,7 +28,8 @@ theta or a local psychometric substitute. The home list caption stays transport. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A pending TEPP row does not claim a calibrated measurement. A pending -lineage row says reconstruction has not started yet. +lineage row says reconstruction has not started yet; open it and +start reconstruction (ADR 0021). Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ef147e99f..0dcccbdf9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -39,6 +39,11 @@ _SNAPSHOT_MEMBER_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0022_analysis_source_snapshot_member.sql" ) +_PURGE_RECONSTRUCTION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0023_analysis_run_retention_purge_reconstruction.sql" +) def _postgres_available() -> bool: @@ -125,6 +130,7 @@ def seeded_db(demo_analyst_token): cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text()) + cur.execute(_PURGE_RECONSTRUCTION_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 71e9fc733..d7602aeba 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -28,6 +28,7 @@ COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-ro COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql +COPY migrations/0023_analysis_run_retention_purge_reconstruction.sql /docker-entrypoint-initdb.d/24-analysis-run-retention-purge-reconstruction.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0022-retention-purge-reconstruction.md b/docs/adr/0022-retention-purge-reconstruction.md new file mode 100644 index 000000000..62a8f27aa --- /dev/null +++ b/docs/adr/0022-retention-purge-reconstruction.md @@ -0,0 +1,61 @@ +# ADR 0022 — Granted purge also empties reconstruction evidence + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0020 granted retention purge; ADR 0021 authorized start + +## Context + +ADR 0020 added `purge_analysis_run_registry` so operators can empty a +run-bearing registry without a superuser `DISABLE TRIGGER`. ADR 0021 +then persisted `analysis_run_reconstruction`, `analysis_run_lineage_edge`, +and `analysis_source_snapshot_member`. Those rows reference +`analysis_run` and `analysis_source_snapshot`. + +After the first start, the 0020 function hits a foreign-key failure. +The documented operator path (grant + admin + published phrase) no +longer empties the registry. That is not a supported product path +(ISO 15489-1:2016 disposition; NIST SP 800-92 protected audit records). + +## Decision + +Migration `0023_analysis_run_retention_purge_reconstruction.sql` +replaces `purge_analysis_run_registry` so that, after the same +conjunctive authorization, it: + +1. disables reconstruction and snapshot-member immutability triggers + when those tables exist; +2. deletes `analysis_run_lineage_edge` then `analysis_run_reconstruction`; +3. deletes the 0018 registry rows; +4. deletes `analysis_source_snapshot_member`; +5. deletes `analysis_source_snapshot`; +6. re-enables every trigger it disabled; +7. writes one `analysis_run_retention_event`. + +Authorization, `REVOKE ALL … FROM PUBLIC`, and the published phrase +do not change. Rollback 0023 restores the 0020 function body. + +## Consequences + +Operators who started a Pending lineage run can still empty the +registry through the documented grant path, then roll back 0023, 0022, +0021, 0020, and 0018. A raw `DELETE` of reconstruction rows stays +rejected. Do not expose purge on a public HTTP route. + +## References — APA 7th + +International Organization for Standardization. (2016). *ISO 15489-1:2016: +Information and documentation—Records management—Part 1: Concepts and +principles*. + +Kent, K., & Souppaya, M. (2006). *Guide to computer security log management* +(NIST Special Publication 800-92). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-92 + +National Institute of Standards and Technology. (2020). *Security and +privacy controls for information systems and organizations* (NIST Special +Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.8. Privileges*. +https://www.postgresql.org/docs/current/ddl-priv.html diff --git a/frontend/package.json b/frontend/package.json index 4c66c7205..dfac39ab2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.88.0", + "version": "0.88.2", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 036dca1fa..562b6f8b0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.88.0" +__version__ = "0.88.2" diff --git a/migrations/0023_analysis_run_retention_purge_reconstruction.sql b/migrations/0023_analysis_run_retention_purge_reconstruction.sql new file mode 100644 index 000000000..ae11173d6 --- /dev/null +++ b/migrations/0023_analysis_run_retention_purge_reconstruction.sql @@ -0,0 +1,145 @@ +-- Granted purge must empty reconstruction evidence (ADR 0022). +-- +-- Landed 0021 / 0022 add immutable reconstruction and snapshot-member +-- rows that reference analysis_run / analysis_source_snapshot. The +-- 0020 purge deleted only the 0018 registry tables, so a granted empty +-- after start failed on foreign keys. Replace the function so the +-- documented operator path still works. +-- +-- Authorization stays conjunctive (ADR 0020): unrevoked grant, admin +-- membership, then the published phrase. PUBLIC still has no EXECUTE. + +begin; + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; + has_reconstruction boolean; + has_snapshot_member boolean; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + has_reconstruction := to_regclass('public.analysis_run_reconstruction') is not null; + has_snapshot_member := to_regclass('public.analysis_source_snapshot_member') is not null; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + if has_reconstruction then + alter table analysis_run_lineage_edge + disable trigger analysis_run_lineage_edge_update_reject; + alter table analysis_run_reconstruction + disable trigger analysis_run_reconstruction_update_reject; + end if; + if has_snapshot_member then + alter table analysis_source_snapshot_member + disable trigger analysis_source_snapshot_member_update_reject; + end if; + + begin + if has_reconstruction then + delete from analysis_run_lineage_edge; + delete from analysis_run_reconstruction; + end if; + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + if has_snapshot_member then + delete from analysis_source_snapshot_member; + end if; + delete from analysis_source_snapshot; + exception + when others then + if has_snapshot_member then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + if has_reconstruction then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + if has_snapshot_member then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + if has_reconstruction then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry, reconstruction, and snapshot-member ' + 'relations after an unrevoked role grant, analysis_run_retention_admin ' + 'membership, and the documented approval token. Next action: export ' + 'analysis_run_retention_event, delete it, then roll back 0023, 0022, ' + '0021, 0020, and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql b/migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql new file mode 100644 index 000000000..9a6a78576 --- /dev/null +++ b/migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql @@ -0,0 +1,102 @@ +-- Rollback for migration 0023 restores the 0020 purge body. +-- +-- After this script, a granted purge no longer deletes reconstruction +-- or snapshot-member rows. Export and empty those tables with the 0023 +-- function before rolling back 0022 and 0021. + +begin; + +-- Recreate the 0020 function text. Source of truth remains +-- migrations/0020_analysis_run_retention_purge.sql; this copy exists +-- only so 0023 can be reversed without re-running 0020's DDL. + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + + begin + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + delete from analysis_source_snapshot; + exception + when others then + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry relations after an unrevoked role grant, ' + 'analysis_run_retention_admin membership, and the documented approval ' + 'token; records one analysis_run_retention_event. Next action: export ' + 'that event, delete it, then roll back 0020 and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/pyproject.toml b/pyproject.toml index 5a4aa12bc..a0860a802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.88.0" +version = "0.88.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 8cb1f0ea1..c11696bb8 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -124,6 +124,7 @@ def seed( cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) cur.execute((migrations / "0021_analysis_run_reconstruction.sql").read_text()) cur.execute((migrations / "0022_analysis_source_snapshot_member.sql").read_text()) + cur.execute((migrations / "0023_analysis_run_retention_purge_reconstruction.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 6041d3090..215144332 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -278,6 +278,7 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "0020_analysis_run_retention_purge.sql" in dockerfile assert "0021_analysis_run_reconstruction.sql" in dockerfile assert "0022_analysis_source_snapshot_member.sql" in dockerfile + assert "0023_analysis_run_retention_purge_reconstruction.sql" in dockerfile seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") assert seed.index("0019_role_catalog_identity.sql") < seed.index( "0020_analysis_run_retention_purge.sql" @@ -288,6 +289,16 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert seed.index("0021_analysis_run_reconstruction.sql") < seed.index( "0022_analysis_source_snapshot_member.sql" ) + assert seed.index("0022_analysis_source_snapshot_member.sql") < seed.index( + "0023_analysis_run_retention_purge_reconstruction.sql" + ) + purge_reconstruction = ( + _ROOT / "migrations" / "0023_analysis_run_retention_purge_reconstruction.sql" + ).read_text(encoding="utf-8") + assert "delete from analysis_run_lineage_edge" in purge_reconstruction + assert "delete from analysis_run_reconstruction" in purge_reconstruction + assert "delete from analysis_source_snapshot_member" in purge_reconstruction + assert "revoke all" in purge_reconstruction.casefold() assert "analysis_run_registry_not_empty" in rollback retention = _RETENTION_MIGRATION.read_text(encoding="utf-8") retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index 6915a3531..b38c7c7eb 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.88.2" source = { virtual = "." } dependencies = [ { name = "certifi" },