Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/0.88.2-retention-purge-reconstruction.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'), "
Expand Down
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions docs/adr/0022-retention-purge-reconstruction.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.88.0",
"version": "0.88.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@
"sentence_excerpts",
]

__version__ = "0.88.0"
__version__ = "0.88.2"
145 changes: 145 additions & 0 deletions migrations/0023_analysis_run_retention_purge_reconstruction.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading