diff --git a/AGENTS.md b/AGENTS.md index c97691319..ae19095b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,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. +(ADR 0020 / v0.87.0). The documented phrase is not a secret. The same +call empties reconstruction children when those tables exist +(ADR 0032 / v2.10.4). Do not expose purge on a public HTTP route. `POST /api/analysis-runs` records Pending lineage only (ADR 0017 / v2.7.1). TEPP and period-report kinds 422 before any snapshot write. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0525a018c..5307a0d8e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -531,7 +531,10 @@ 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 (ADR 0032). +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/2.10.4-retention-purge-reconstruction-children.md b/CHANGELOG.d/2.10.4-retention-purge-reconstruction-children.md new file mode 100644 index 000000000..1343d7588 --- /dev/null +++ b/CHANGELOG.d/2.10.4-retention-purge-reconstruction-children.md @@ -0,0 +1,6 @@ +# 2.10.4 Empty reconstruction children during granted retention purge + +After a Demo Corp lineage reconstruction has started, the same +granted retention purge empties reconstruction edges, the +reconstruction digest, and frozen snapshot members (ADR 0032). +Do not `DISABLE TRIGGER` as superuser. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dff8f6a..9c836ba87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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). +## [2.10.4] - 2026-08-17 + +### Fixed + +- After a Demo Corp lineage reconstruction has started, the same + granted retention purge empties `analysis_run_lineage_edge`, + `analysis_run_reconstruction`, and `analysis_source_snapshot_member` + when those tables exist, including their delete-reject triggers + (ADR 0032). Follow the same grant + admin + phrase path — do not + `DISABLE TRIGGER` as superuser. + ## [2.10.3] - 2026-08-17 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c9636ef64..34ffdc39d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,11 +10,12 @@ 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 -`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. Person catalog identity -on that role row is ADR 0027 (`cataloged_person_id`). +back 0020 then 0018. The same call empties reconstruction children +when ADR 0021 tables exist (ADR 0032). 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. Person catalog +identity on that role row is ADR 0027 (`cataloged_person_id`). ## Analysis-run seed (v0.96.0) diff --git a/README.md b/README.md index 52b54b711..c143f84fc 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,9 @@ 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 +# (ADR 0032). # -> 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..5d294c41b 100644 --- a/docs/adr/0020-analysis-run-retention-purge.md +++ b/docs/adr/0020-analysis-run-retention-purge.md @@ -76,6 +76,15 @@ 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 lineage +edges, then reconstruction, then the 0018 rows, then snapshot +members, then the snapshot, and re-enables the triggers (ADR 0032). +A 0020-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/adr/0032-retention-purge-reconstruction-children.md b/docs/adr/0032-retention-purge-reconstruction-children.md new file mode 100644 index 000000000..16fbf7a99 --- /dev/null +++ b/docs/adr/0032-retention-purge-reconstruction-children.md @@ -0,0 +1,62 @@ +# ADR 0032 — Granted retention purge empties reconstruction children + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0020 granted retention purge; ADR 0021 start reconstruction + +## Context + +ADR 0020 added `purge_analysis_run_registry` so a run-bearing registry +can empty without a superuser `DISABLE TRIGGER`. ADR 0021 then persisted +immutable `analysis_run_lineage_edge`, `analysis_run_reconstruction`, +and `analysis_source_snapshot_member` rows. After a Demo Corp lineage +reconstruction has started, those children and their delete-reject +triggers would fail-close the published grant + admin + phrase path +(ISO 15489-1:2016 disposition; NIST SP 800-92 protected audit records). + +Dirty draft #177 ported this procedure onto a stale 0.87.0-only head. +This decision is the successor on live #74 after v2.10.3. It does not +start reconstruction, invent a theta, expose purge on a public HTTP +route, or grant `analysis_run_retention_admin` to `DATABASE_URL`. + +## Decision + +`purge_analysis_run_registry` disables user triggers on the three +optional children when `to_regclass` finds them, deletes in this +order, then re-enables the triggers on success and in the exception +handler: + +1. `analysis_run_lineage_edge` +2. `analysis_run_reconstruction` +3. migration 0018 registry rows (`analysis_run_status_event`, + `analysis_run_scope`, `analysis_run`, `analysis_source_count`) +4. `analysis_source_snapshot_member` +5. `analysis_source_snapshot` + +A database without those relations still purges. Operators follow the +same unrevoked `analysis_run_retention_grant`, +`GRANT analysis_run_retention_admin`, and +`approved-retention-purge` phrase (ADR 0020). Do not `DISABLE TRIGGER` +as superuser after a Succeeded start. + +## Consequences + +- After a Demo Corp lineage reconstruction has started, the granted + retention purge still empties the registry. +- Missing child tables stay a no-op. A 0020-only database still + purges. +- Purge remains an audited SQL operator action, not a public 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 + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 +documentation: 9.29. System information functions and operators*. +https://www.postgresql.org/docs/current/functions-info.html diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 2b963cf6d..81b178665 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -79,7 +79,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 (ADR 0032). Export then delete `analysis_run_retention_event` before 0020 rollback. | | Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag via `records_from_source_posts`; the pricing follow-up must parent both the revised quote and the delivery question. A period-report start must 422 without a theta. TEPP start submits through `tepp_client` and stays Failed (`tepp_not_available` / `tepp_result_not_persisted`) without a theta. Snapshot members exclude a later backfill. A concurrent or Running start is 409. A Succeeded retry returns the stored digest. | ## APA 7th references diff --git a/frontend/package.json b/frontend/package.json index 709b32e5c..f191172d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.10.3", + "version": "2.10.4", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index aa53eb9eb..7e9c6fa77 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.10.3" +__version__ = "2.10.4" diff --git a/migrations/0020_analysis_run_retention_purge.sql b/migrations/0020_analysis_run_retention_purge.sql index 056d80216..311fa0283 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 0020-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/migrations/0023_analysis_run_outbox.sql b/migrations/0023_analysis_run_outbox.sql index 2aaef8c2c..d766fbc5f 100644 --- a/migrations/0023_analysis_run_outbox.sql +++ b/migrations/0023_analysis_run_outbox.sql @@ -155,15 +155,17 @@ begin end if; if to_regclass('public.analysis_run_lineage_edge') is not null then delete from analysis_run_lineage_edge; - delete from analysis_run_reconstruction; end if; - if to_regclass('public.analysis_source_snapshot_member') is not null then - delete from analysis_source_snapshot_member; + 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 diff --git a/pyproject.toml b/pyproject.toml index 5cb375082..b32772d24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.10.3" +version = "2.10.4" 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/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index df7f56901..d6843c783 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -319,6 +319,37 @@ 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 retention.index("delete from analysis_run_lineage_edge") < ( + retention.index("delete from analysis_run_reconstruction") + ) + assert retention.index("delete from analysis_run_reconstruction") < ( + retention.index("delete from analysis_run_status_event") + ) + assert retention.index("delete from analysis_source_count") < ( + retention.index("delete from analysis_source_snapshot_member") + ) + assert retention.index("delete from analysis_source_snapshot_member") < ( + retention.index("delete from analysis_source_snapshot;") + ) + outbox_purge = ( + _ROOT / "migrations" / "0023_analysis_run_outbox.sql" + ).read_text(encoding="utf-8") + assert outbox_purge.index("delete from analysis_run_lineage_edge") < ( + outbox_purge.index("delete from analysis_run_reconstruction") + ) + assert outbox_purge.index("delete from analysis_run_reconstruction") < ( + outbox_purge.index("delete from analysis_run_status_event") + ) + assert outbox_purge.index("delete from analysis_source_count") < ( + outbox_purge.index("delete from analysis_source_snapshot_member") + ) + assert outbox_purge.index("delete from analysis_source_snapshot_member") < ( + outbox_purge.index("delete from analysis_source_snapshot;") + ) assert "analysis_run_retention_event_not_empty" in retention_rollback assert "jsonb" not in retention.casefold() for object_name in re.findall( @@ -925,6 +956,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."""