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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.d/0.87.0-retention-purge-grant-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion 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 0020 then 0018. The same call empties reconstruction children
when ADR 0021 tables exist. The published phrase is not a secret. Do not

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the ADR number. Person-catalog already claims 0021, and the current start head still calls these tables ADR 0020 (taken here by retention). Write “when start-reconstruction tables exist” so the next free ADR can be assigned on the start rebase.

`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 Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
8 changes: 8 additions & 0 deletions docs/adr/0020-analysis-run-retention-purge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +79 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same numbering collision. Describe the three optional relations and the to_regclass + DISABLE TRIGGER USER path without allocating ADR 0021. Start’s present migration is still numbered 0020 and has no analysis_source_snapshot_member; that absence must stay a no-op.


## References — APA 7th

American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC
Expand Down
2 changes: 1 addition & 1 deletion docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 45 additions & 2 deletions migrations/0020_analysis_run_retention_purge.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions tests/test_analysis_run_registry_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the successful purge, assert pg_trigger.tgenabled = 'O' for each stand-in delete-reject trigger. Row counts alone do not prove ENABLE TRIGGER USER ran, so a later insert + raw DELETE could silently succeed if that path bit-rots.



def test_retention_purge_requires_unrevoked_session_grant(registry_db) -> None:
"""Admin membership plus the published token cannot purge without a grant."""

Expand Down
Loading