diff --git a/AGENTS.md b/AGENTS.md index 47a71c8c3..b786a48cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,8 @@ 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 +(ADR 0020 / v0.87.0). The documented phrase is not a secret. The same +call empties reconstruction children when those tables exist. Do not expose purge on a public HTTP route. ## CI gates diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..da53c8bd9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -503,7 +503,9 @@ A run-bearing registry is emptied only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')` (ADR 0020); a raw `DELETE` and a runtime role that only knows the -public phrase stay rejected. Repeated chip and close controls use +public phrase stay rejected. When start reconstruction has persisted +run-scoped edges, that same call empties those children instead of +stopping on an immutable-trigger or foreign-key error. Repeated chip and close controls use `frontend/src/styles/tokens.css` and the Storybook inventory. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md b/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md index a2bcb7d8d..8918822cf 100644 --- a/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md +++ b/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md @@ -7,6 +7,8 @@ unrevoked `analysis_run_retention_grant` and Export `analysis_run_retention_event`, delete those rows, then roll back 0020 and 0018. A raw DELETE, a published token without a grant, and a runtime role that is not the admin role still fail (ADR 0020). +After a Succeeded start, the same call also empties reconstruction +edges, the reconstruction digest, and frozen snapshot members. Designers can change chip and close-button appearance in `frontend/src/styles/tokens.css` and preview the next click in Storybook diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..aeeb8f104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ All notable changes to this project are documented here. Format follows in `frontend/src/styles/tokens.css`. Preview them in Storybook (`cd frontend && pnpm run storybook`). +### Fixed + +- The same purge empties `analysis_run_lineage_edge`, + `analysis_run_reconstruction`, and `analysis_source_snapshot_member` + when those start-reconstruction tables exist, including their + delete-reject triggers. After a Succeeded start, follow the same + grant + admin + phrase path — do not `DISABLE TRIGGER` as superuser. + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..9137f24f3 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 0020 then 0018. The same call empties reconstruction children +when ADR 0021 tables exist. 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. diff --git a/README.md b/README.md index b6a881a3a..ea138fac5 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,8 @@ cd frontend && cp .env.example .env.local && pnpm install && pnpm run dev # Empty a run-bearing registry: insert analysis_run_retention_grant # for session_user, GRANT analysis_run_retention_admin, then # select purge_analysis_run_registry('approved-retention-purge'). -# The published token is not a grant (ADR 0020). +# The published token is not a grant (ADR 0020). After a Succeeded +# start, that same call also empties reconstruction children. # -> http://localhost:5173, click "Log in", redirects through the real # Keycloak login page for demo.analyst / lineageweave-demo-only ``` diff --git a/docs/adr/0020-analysis-run-retention-purge.md b/docs/adr/0020-analysis-run-retention-purge.md index 928ba87c6..48b558f8f 100644 --- a/docs/adr/0020-analysis-run-retention-purge.md +++ b/docs/adr/0020-analysis-run-retention-purge.md @@ -76,6 +76,14 @@ operators who purge from `psql`. Do not expose purge on a public HTTP route. Split the application login from the migration owner so the product role cannot execute the function even as table owner. +Start reconstruction (ADR 0021) adds `analysis_run_lineage_edge`, +`analysis_run_reconstruction`, and `analysis_source_snapshot_member` +with delete-reject triggers. This procedure already disables those +user triggers when `to_regclass` finds the tables, deletes children +before the 0018 rows, then re-enables the triggers. A 0.87.0-only +database without those relations still purges. Do not require a +superuser `DISABLE TRIGGER` after a Succeeded start. + ## References — APA 7th American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c776053b1..f828e7f8d 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -76,7 +76,7 @@ provenance, retention, and immutable evidence rather than blanket masking. | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | -| Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. Export then delete `analysis_run_retention_event` before 0020 rollback. | +| Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. When `analysis_run_reconstruction` children exist, that same call empties them despite delete-reject triggers. Export then delete `analysis_run_retention_event` before 0020 rollback. | ## APA 7th references diff --git a/migrations/0020_analysis_run_retention_purge.sql b/migrations/0020_analysis_run_retention_purge.sql index 056d80216..cb2fd2cea 100644 --- a/migrations/0020_analysis_run_retention_purge.sql +++ b/migrations/0020_analysis_run_retention_purge.sql @@ -118,6 +118,20 @@ begin select count(*) into run_count from analysis_run; select count(*) into snapshot_count from analysis_source_snapshot; + -- ADR 0021 start reconstruction adds immutable children. Those + -- tables are absent on a 0.87.0-only database. When present, their + -- delete-reject triggers and FKs would otherwise force a superuser + -- DISABLE TRIGGER — the failure this procedure exists to remove. + if to_regclass('public.analysis_run_lineage_edge') is not null then + execute 'alter table analysis_run_lineage_edge disable trigger user'; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + execute 'alter table analysis_run_reconstruction disable trigger user'; + end if; + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'alter table analysis_source_snapshot_member disable trigger user'; + end if; + alter table analysis_run_status_event disable trigger analysis_run_status_event_delete_reject; alter table analysis_run_scope @@ -126,10 +140,19 @@ begin disable trigger analysis_run_mutation_reject; begin + if to_regclass('public.analysis_run_lineage_edge') is not null then + delete from analysis_run_lineage_edge; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + 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 to_regclass('public.analysis_source_snapshot_member') is not null then + delete from analysis_source_snapshot_member; + end if; delete from analysis_source_snapshot; exception when others then @@ -139,6 +162,15 @@ begin enable trigger analysis_run_scope_mutation_reject; alter table analysis_run_status_event enable trigger analysis_run_status_event_delete_reject; + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'alter table analysis_source_snapshot_member enable trigger user'; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + execute 'alter table analysis_run_reconstruction enable trigger user'; + end if; + if to_regclass('public.analysis_run_lineage_edge') is not null then + execute 'alter table analysis_run_lineage_edge enable trigger user'; + end if; raise; end; @@ -148,6 +180,15 @@ begin enable trigger analysis_run_scope_mutation_reject; alter table analysis_run_status_event enable trigger analysis_run_status_event_delete_reject; + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'alter table analysis_source_snapshot_member enable trigger user'; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + execute 'alter table analysis_run_reconstruction enable trigger user'; + end if; + if to_regclass('public.analysis_run_lineage_edge') is not null then + execute 'alter table analysis_run_lineage_edge enable trigger user'; + end if; insert into analysis_run_retention_event ( purged_run_count, @@ -170,8 +211,10 @@ $$; 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.'; + 'token; also empties analysis_run_lineage_edge, ' + 'analysis_run_reconstruction, and analysis_source_snapshot_member ' + 'when those ADR 0021 relations exist. Next action: export ' + 'analysis_run_retention_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) diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 3d185dbed..1d1f654cd 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -295,6 +295,10 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "analysis_run_retention_not_approved" in retention assert "analysis_run_retention_not_granted" in retention assert "analysis_run_retention_not_admin" in retention + assert "analysis_run_lineage_edge" in retention + assert "analysis_run_reconstruction" in retention + assert "analysis_source_snapshot_member" in retention + assert "to_regclass" in retention assert "analysis_run_retention_event_not_empty" in retention_rollback assert "jsonb" not in retention.casefold() for object_name in re.findall( @@ -901,6 +905,105 @@ def test_approved_retention_purge_empties_a_run_bearing_registry(registry_db) -> assert cursor.fetchone()[0] is None +def test_retention_purge_empties_optional_reconstruction_children( + registry_db, +) -> None: + """Grant plus admin empties ADR 0021 children despite delete-reject triggers.""" + + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="b" * 64, + idempotency_key="retention-purge-children", + ) + cursor.execute( + "select analysis_run_id, analysis_source_snapshot_id " + "from analysis_run" + ) + run_id, snapshot_id = cursor.fetchone() + cursor.execute( + """ + create table analysis_run_reconstruction ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + result_sha256 text not null, + edge_count integer not null, + reconstructed_at timestamptz not null + ); + create table analysis_run_lineage_edge ( + analysis_run_id uuid not null + references analysis_run_reconstruction (analysis_run_id), + child_post_id uuid not null, + parent_post_id uuid not null, + fused_score double precision not null, + primary key (analysis_run_id, child_post_id) + ); + create table analysis_source_snapshot_member ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot + (analysis_source_snapshot_id), + source_post_id uuid not null, + primary key ( + analysis_source_snapshot_id, source_post_id + ) + ); + create function reject_reconstruction_child_delete() + returns trigger language plpgsql as $fn$ + begin + raise exception 'analysis_run_reconstruction_is_immutable'; + end + $fn$; + create trigger analysis_run_reconstruction_update_reject + before update or delete on analysis_run_reconstruction + for each row execute function reject_reconstruction_child_delete(); + create trigger analysis_run_lineage_edge_update_reject + before update or delete on analysis_run_lineage_edge + for each row execute function reject_reconstruction_child_delete(); + create trigger analysis_source_snapshot_member_update_reject + before update or delete on analysis_source_snapshot_member + for each row execute function reject_reconstruction_child_delete(); + """ + ) + cursor.execute( + "insert into analysis_run_reconstruction " + "(analysis_run_id, result_sha256, edge_count, reconstructed_at) " + "values (%s, %s, 1, now())", + (run_id, "c" * 64), + ) + cursor.execute( + "insert into analysis_run_lineage_edge " + "(analysis_run_id, child_post_id, parent_post_id, fused_score) " + "values (%s, %s, %s, 0.91)", + (run_id, str(uuid.uuid4()), str(uuid.uuid4())), + ) + cursor.execute( + "insert into analysis_source_snapshot_member " + "(analysis_source_snapshot_id, source_post_id) " + "values (%s, %s)", + (snapshot_id, str(uuid.uuid4())), + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_reconstruction_is_immutable", + ): + cursor.execute("delete from analysis_run_reconstruction") + _authorize_session_for_purge(cursor) + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_source_snapshot") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_run_reconstruction") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_run_lineage_edge") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_source_snapshot_member") + assert cursor.fetchone()[0] == 0 + + def test_retention_purge_requires_unrevoked_session_grant(registry_db) -> None: """Admin membership plus the published token cannot purge without a grant."""